Преглед на файлове

1

Signed-off-by: lcw <1324309909@qq.com>
lcw преди 3 години
ревизия
abb6b1ef6a
променени са 100 файла, в които са добавени 3584 реда и са изтрити 0 реда
  1. 10 0
      .dockerignore
  2. 12 0
      .editorconfig
  3. 8 0
      .env.development
  4. 11 0
      .env.production
  5. 8 0
      .eslintignore
  6. 7 0
      .eslintrc.json
  7. 1 0
      .gitattributes
  8. 1 0
      .github/FUNDING.yml
  9. 2 0
      .github/assets/crowdin.svg
  10. BIN
      .github/assets/logo.png
  11. 9 0
      .github/assets/sentry.svg
  12. 3 0
      .github/assets/vercel.svg
  13. 37 0
      .github/dependabot.yml
  14. 27 0
      .github/workflows/autorelease-excalidraw.yml
  15. 55 0
      .github/workflows/autorelease-preview.yml
  16. 13 0
      .github/workflows/build-docker.yml
  17. 29 0
      .github/workflows/build-packages.yml
  18. 17 0
      .github/workflows/cancel.yml
  19. 22 0
      .github/workflows/lint.yml
  20. 47 0
      .github/workflows/locales-coverage.yml
  21. 20 0
      .github/workflows/publish-docker.yml
  22. 16 0
      .github/workflows/semantic-pr-title.yml
  23. 37 0
      .github/workflows/sentry-production.yml
  24. 17 0
      .github/workflows/test.yml
  25. 29 0
      .gitignore
  26. 2 0
      .husky/pre-commit
  27. 14 0
      .lintstagedrc.js
  28. 1 0
      .npmrc
  29. 1 0
      .nvmrc
  30. 0 0
      .prettierignore
  31. 1 0
      .watchmanconfig
  32. 3 0
      CHANGELOG.md
  33. 63 0
      CONTRIBUTING.md
  34. 17 0
      Dockerfile
  35. 21 0
      LICENSE
  36. 170 0
      README.md
  37. 3 0
      crowdin.yml
  38. 25 0
      docker-compose.yml
  39. 5 0
      firebase-project/.firebaserc
  40. 66 0
      firebase-project/.gitignore
  41. 9 0
      firebase-project/firebase.json
  42. 4 0
      firebase-project/firestore.indexes.json
  43. 10 0
      firebase-project/firestore.rules
  44. 12 0
      firebase-project/storage.rules
  45. 117 0
      package.json
  46. BIN
      public/Cascadia.ttf
  47. BIN
      public/Cascadia.woff2
  48. BIN
      public/FG_Virgil.ttf
  49. BIN
      public/FG_Virgil.woff2
  50. BIN
      public/Virgil.woff2
  51. 2 0
      public/_headers
  52. BIN
      public/apple-touch-icon.png
  53. BIN
      public/favicon.ico
  54. 13 0
      public/fonts.css
  55. 185 0
      public/index.html
  56. BIN
      public/logo-180x180.png
  57. 74 0
      public/manifest.json
  58. BIN
      public/og-image-sm.png
  59. BIN
      public/og-image.png
  60. 3 0
      public/robots.txt
  61. BIN
      public/screenshots/collaboration.png
  62. BIN
      public/screenshots/export.png
  63. BIN
      public/screenshots/illustration.png
  64. BIN
      public/screenshots/shapes.png
  65. BIN
      public/screenshots/virtual-whiteboard.png
  66. BIN
      public/screenshots/wireframe.png
  67. 0 0
      public/workbox/workbox-background-sync.prod.js
  68. 2 0
      public/workbox/workbox-broadcast-update.prod.js
  69. 2 0
      public/workbox/workbox-cacheable-response.prod.js
  70. 0 0
      public/workbox/workbox-core.prod.js
  71. 0 0
      public/workbox/workbox-expiration.prod.js
  72. 2 0
      public/workbox/workbox-navigation-preload.prod.js
  73. 2 0
      public/workbox/workbox-offline-ga.prod.js
  74. 0 0
      public/workbox/workbox-precaching.prod.js
  75. 2 0
      public/workbox/workbox-range-requests.prod.js
  76. 0 0
      public/workbox/workbox-routing.prod.js
  77. 0 0
      public/workbox/workbox-strategies.prod.js
  78. 2 0
      public/workbox/workbox-streams.prod.js
  79. 2 0
      public/workbox/workbox-sw.js
  80. 0 0
      public/workbox/workbox-window.prod.es5.mjs
  81. 0 0
      public/workbox/workbox-window.prod.mjs
  82. 0 0
      public/workbox/workbox-window.prod.umd.js
  83. 77 0
      scripts/autorelease.js
  84. 35 0
      scripts/build-locales-coverage.js
  85. 40 0
      scripts/build-node.js
  86. 61 0
      scripts/build-version.js
  87. 193 0
      scripts/locales-coverage-description.js
  88. 39 0
      scripts/release.js
  89. 104 0
      scripts/updateChangelog.js
  90. 27 0
      scripts/updateReadme.js
  91. 59 0
      src/actions/actionAddToLibrary.ts
  92. 208 0
      src/actions/actionAlign.tsx
  93. 289 0
      src/actions/actionCanvas.tsx
  94. 124 0
      src/actions/actionClipboard.tsx
  95. 157 0
      src/actions/actionDeleteSelected.tsx
  96. 92 0
      src/actions/actionDistribute.tsx
  97. 140 0
      src/actions/actionDuplicateSelection.tsx
  98. 279 0
      src/actions/actionExport.tsx
  99. 178 0
      src/actions/actionFinalize.tsx
  100. 209 0
      src/actions/actionFlip.ts

+ 10 - 0
.dockerignore

@@ -0,0 +1,10 @@
+*
+!.env
+!.eslintrc.json
+!.npmrc
+!.prettierrc
+!package.json
+!public/
+!src/
+!tsconfig.json
+!yarn.lock

+ 12 - 0
.editorconfig

@@ -0,0 +1,12 @@
+# http://EditorConfig.org
+
+# top-level EditorConfig file
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 2
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true

+ 8 - 0
.env.development

@@ -0,0 +1,8 @@
+REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
+REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
+
+REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
+REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
+
+REACT_APP_SOCKET_SERVER_URL=http://localhost:3002
+REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'

+ 11 - 0
.env.production

@@ -0,0 +1,11 @@
+REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
+REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
+
+REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
+REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
+
+REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com
+REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
+
+# production-only vars
+REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13

+ 8 - 0
.eslintignore

@@ -0,0 +1,8 @@
+node_modules/
+build/
+package-lock.json
+.vscode/
+firebase/
+dist/
+public/workbox
+src/packages/excalidraw/types

+ 7 - 0
.eslintrc.json

@@ -0,0 +1,7 @@
+{
+  "extends": ["@excalidraw/eslint-config", "react-app"],
+  "rules": {
+    "import/no-anonymous-default-export": "off",
+    "no-restricted-globals": "off"
+  }
+}

+ 1 - 0
.gitattributes

@@ -0,0 +1 @@
+* text=auto eol=lf

+ 1 - 0
.github/FUNDING.yml

@@ -0,0 +1 @@
+open_collective: excalidraw

Файловите разлики са ограничени, защото са твърде много
+ 2 - 0
.github/assets/crowdin.svg


BIN
.github/assets/logo.png


+ 9 - 0
.github/assets/sentry.svg

@@ -0,0 +1,9 @@
+<svg class="__sntry__ css-15xgryy e10nushx5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 222 66" height="50" style="background-color: rgb(255, 255, 255);">
+	<defs>
+		<style type="text/css">
+			@media (prefers-color-scheme: dark) {svg.__sntry__ { background-color: #584674 !important; }path.__sntry__ { fill: #ffffff !important; }}
+		</style>
+	</defs>
+	<path d="M29,2.26a4.67,4.67,0,0,0-8,0L14.42,13.53A32.21,32.21,0,0,1,32.17,40.19H27.55A27.68,27.68,0,0,0,12.09,17.47L6,28a15.92,15.92,0,0,1,9.23,12.17H4.62A.76.76,0,0,1,4,39.06l2.94-5a10.74,10.74,0,0,0-3.36-1.9l-2.91,5a4.54,4.54,0,0,0,1.69,6.24A4.66,4.66,0,0,0,4.62,44H19.15a19.4,19.4,0,0,0-8-17.31l2.31-4A23.87,23.87,0,0,1,23.76,44H36.07a35.88,35.88,0,0,0-16.41-31.8l4.67-8a.77.77,0,0,1,1.05-.27c.53.29,20.29,34.77,20.66,35.17a.76.76,0,0,1-.68,1.13H40.6q.09,1.91,0,3.81h4.78A4.59,4.59,0,0,0,50,39.43a4.49,4.49,0,0,0-.62-2.28Z M124.32,28.28,109.56,9.22h-3.68V34.77h3.73V15.19l15.18,19.58h3.26V9.22h-3.73ZM87.15,23.54h13.23V20.22H87.14V12.53h14.93V9.21H83.34V34.77h18.92V31.45H87.14ZM71.59,20.3h0C66.44,19.06,65,18.08,65,15.7c0-2.14,1.89-3.59,4.71-3.59a12.06,12.06,0,0,1,7.07,2.55l2-2.83a14.1,14.1,0,0,0-9-3c-5.06,0-8.59,3-8.59,7.27,0,4.6,3,6.19,8.46,7.52C74.51,24.74,76,25.78,76,28.11s-2,3.77-5.09,3.77a12.34,12.34,0,0,1-8.3-3.26l-2.25,2.69a15.94,15.94,0,0,0,10.42,3.85c5.48,0,9-2.95,9-7.51C79.75,23.79,77.47,21.72,71.59,20.3ZM195.7,9.22l-7.69,12-7.64-12h-4.46L186,24.67V34.78h3.84V24.55L200,9.22Zm-64.63,3.46h8.37v22.1h3.84V12.68h8.37V9.22H131.08ZM169.41,24.8c3.86-1.07,6-3.77,6-7.63,0-4.91-3.59-8-9.38-8H154.67V34.76h3.8V25.58h6.45l6.48,9.2h4.44l-7-9.82Zm-10.95-2.5V12.6h7.17c3.74,0,5.88,1.77,5.88,4.84s-2.29,4.86-5.84,4.86Z" transform="translate(11, 11)" fill="#362d59" class="__sntry__">
+	</path>
+</svg>

+ 3 - 0
.github/assets/vercel.svg

@@ -0,0 +1,3 @@
+<svg height="50" viewBox="0 0 164 50" xmlns="http://www.w3.org/2000/svg" style="background:#fff" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2">
+	<path d="M78.21 15.587c-5.672 0-9.762 3.864-9.762 9.661s4.604 9.66 10.276 9.66c3.427 0 6.448-1.416 8.319-3.805l-3.931-2.372c-1.038 1.186-2.615 1.879-4.388 1.879-2.461 0-4.552-1.342-5.328-3.489h14.397c.113-.601.18-1.223.18-1.879 0-5.79-4.09-9.655-9.763-9.655zm-4.86 7.783c.642-2.142 2.399-3.489 4.855-3.489 2.461 0 4.219 1.347 4.855 3.489h-9.71zm60.187-7.783c-5.673 0-9.763 3.864-9.763 9.661s4.604 9.66 10.276 9.66c3.427 0 6.449-1.416 8.319-3.805l-3.931-2.372c-1.038 1.186-2.615 1.879-4.388 1.879-2.461 0-4.552-1.342-5.328-3.489h14.397c.113-.601.18-1.223.18-1.879 0-5.79-4.09-9.655-9.762-9.655zm-4.856 7.783c.642-2.142 2.4-3.489 4.856-3.489 2.46 0 4.218 1.347 4.855 3.489h-9.711zm-20.054 1.878c0 3.22 2.015 5.367 5.139 5.367 2.116 0 3.704-1.003 4.52-2.64l3.947 2.378c-1.634 2.843-4.696 4.556-8.467 4.556-5.678 0-9.763-3.864-9.763-9.661s4.09-9.66 9.763-9.66c3.77 0 6.828 1.712 8.467 4.556l-3.946 2.377c-.817-1.637-2.405-2.64-4.521-2.64-3.12 0-5.139 2.147-5.139 5.367zm42.378-15.565v24.69h-4.624V9.682h4.624zM24.73 7l18.985 34.35H5.744L24.73 7zm47.465 2.683L57.956 35.446 43.72 9.683h5.338l8.9 16.102 8.898-16.102h5.339zm30.268 6.44v5.202a5.634 5.634 0 00-1.644-.263c-2.985 0-5.138 2.147-5.138 5.367v7.943h-4.624V16.124h4.624v4.938c0-2.727 3.036-4.938 6.782-4.938z" fill-rule="nonzero" />
+</svg>

+ 37 - 0
.github/dependabot.yml

@@ -0,0 +1,37 @@
+version: 2
+updates:
+  - package-ecosystem: npm
+    directory: /
+    schedule:
+      interval: weekly
+      day: sunday
+      time: "01:00"
+    reviewers:
+      - lipis
+    assignees:
+      - lipis
+    open-pull-requests-limit: 20
+
+  - package-ecosystem: npm
+    directory: /src/packages/excalidraw/
+    schedule:
+      interval: weekly
+      day: sunday
+      time: "01:00"
+    reviewers:
+      - ad1992
+    assignees:
+      - ad1992
+    open-pull-requests-limit: 20
+
+  - package-ecosystem: npm
+    directory: /src/packages/utils/
+    schedule:
+      interval: weekly
+      day: sunday
+      time: "01:00"
+    reviewers:
+      - ad1992
+    assignees:
+      - ad1992
+    open-pull-requests-limit: 20

+ 27 - 0
.github/workflows/autorelease-excalidraw.yml

@@ -0,0 +1,27 @@
+name: Auto release @excalidraw/excalidraw-next
+on:
+  push:
+    branches:
+      - master
+
+jobs:
+  Auto-release-excalidraw-next:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v2
+        with:
+          fetch-depth: 2
+      - name: Setup Node.js 14.x
+        uses: actions/setup-node@v2
+        with:
+          node-version: 14.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
+        run: |
+          yarn add @actions/core
+          yarn autorelease

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

@@ -0,0 +1,55 @@
+name: Auto release preview @excalidraw/excalidraw-preview
+on:
+  issue_comment:
+    types: [created, edited]
+
+jobs:
+  Auto-release-excalidraw-preview:
+    name: Auto release preview
+    if: github.event.comment.body == '@excalibot release package' && 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 14.x
+        uses: actions/setup-node@v2
+        with:
+          node-version: 14.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
+          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 }}"

+ 13 - 0
.github/workflows/build-docker.yml

@@ -0,0 +1,13 @@
+name: Build Docker image
+
+on:
+  push:
+    branches:
+      - master
+
+jobs:
+  build-docker:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - run: docker build -t excalidraw .

+ 29 - 0
.github/workflows/build-packages.yml

@@ -0,0 +1,29 @@
+name: Build packages
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+
+jobs:
+  packages:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v2
+      - name: Setup Node.js 14.x
+        uses: actions/setup-node@v2
+        with:
+          node-version: 14.x
+      - name: Install dependencies
+        run: |
+          yarn --frozen-lockfile
+          yarn --cwd src/packages/excalidraw
+          yarn --cwd src/packages/utils
+      - name: Build @excalidraw/excalidraw
+        run: |
+          yarn --cwd src/packages/excalidraw run pack
+      - name: Build @excalidraw/utils
+        run: |
+          yarn --cwd src/packages/utils run pack

+ 17 - 0
.github/workflows/cancel.yml

@@ -0,0 +1,17 @@
+name: Cancel previous runs
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+
+jobs:
+  cancel:
+    runs-on: ubuntu-latest
+    timeout-minutes: 3
+    steps:
+      - uses: styfle/cancel-workflow-action@0.6.0
+        with:
+          workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
+          access_token: ${{ secrets.GITHUB_TOKEN }}

+ 22 - 0
.github/workflows/lint.yml

@@ -0,0 +1,22 @@
+name: Lint
+
+on: pull_request
+
+jobs:
+  lint:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v2
+
+      - name: Setup Node.js 14.x
+        uses: actions/setup-node@v2
+        with:
+          node-version: 14.x
+
+      - name: Install and lint
+        run: |
+          yarn --frozen-lockfile
+          yarn test:other
+          yarn test:code
+          yarn test:typecheck

+ 47 - 0
.github/workflows/locales-coverage.yml

@@ -0,0 +1,47 @@
+name: Build locales coverage
+
+on:
+  push:
+    branches:
+      - l10n_master
+
+jobs:
+  locales:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v2
+        with:
+          token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
+
+      - name: Setup Node.js 14.x
+        uses: actions/setup-node@v2
+        with:
+          node-version: 14.x
+
+      - name: Create report file
+        run: |
+          yarn locales-coverage
+          FILE_CHANGED=$(git diff src/locales/percentages.json)
+          if [ ! -z "${FILE_CHANGED}" ]; then
+            git config --global user.name 'Excalidraw Bot'
+            git config --global user.email 'bot@excalidraw.com'
+            git add src/locales/percentages.json
+            git commit -am "Auto commit: Calculate translation coverage"
+            git push
+          fi
+      - name: Construct comment body
+        id: getCommentBody
+        run: |
+          body=$(npm run locales-coverage:description | grep '^[^>]')
+          body="${body//'%'/'%25'}"
+          body="${body//$'\n'/'%0A'}"
+          body="${body//$'\r'/'%0D'}"
+          echo ::set-output name=body::$body
+
+      - name: Update description with coverage
+        uses: kt3k/update-pr-description@v1.0.1
+        with:
+          pr_body: ${{ steps.getCommentBody.outputs.body }}
+          pr_title: "chore: Update translations from Crowdin"
+          github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}

+ 20 - 0
.github/workflows/publish-docker.yml

@@ -0,0 +1,20 @@
+name: Publish Docker
+
+on:
+  push:
+    branches:
+      - master
+
+jobs:
+  publish-docker:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v2
+      - uses: docker/build-push-action@v2
+        with:
+          username: ${{ secrets.DOCKER_USERNAME }}
+          password: ${{ secrets.DOCKER_PASSWORD }}
+          repository: excalidraw/excalidraw
+          tag_with_ref: true
+          tag_with_sha: true

+ 16 - 0
.github/workflows/semantic-pr-title.yml

@@ -0,0 +1,16 @@
+name: Semantic PR title
+
+on:
+  pull_request_target:
+    types:
+      - opened
+      - edited
+      - synchronize
+
+jobs:
+  semantic:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: amannn/action-semantic-pull-request@v3.0.0
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 37 - 0
.github/workflows/sentry-production.yml

@@ -0,0 +1,37 @@
+name: New Sentry production release
+
+on:
+  push:
+    branches:
+      - master
+
+jobs:
+  sentry:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - name: Setup Node.js 14.x
+        uses: actions/setup-node@v2
+        with:
+          node-version: 14.x
+      - name: Install and build
+        run: |
+          yarn --frozen-lockfile
+          yarn build:app
+        env:
+          CI: true
+      - name: Install Sentry
+        run: |
+          curl -sL https://sentry.io/get-cli/ | bash
+      - name: Create new Sentry release
+        run: |
+          export SENTRY_RELEASE=$(sentry-cli releases propose-version)
+          sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT
+          sentry-cli releases set-commits --auto $SENTRY_RELEASE
+          sentry-cli releases files $SENTRY_RELEASE upload-sourcemaps --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
+          sentry-cli releases finalize $SENTRY_RELEASE
+          sentry-cli releases deploys $SENTRY_RELEASE new -e production
+        env:
+          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
+          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}

+ 17 - 0
.github/workflows/test.yml

@@ -0,0 +1,17 @@
+name: Tests
+
+on: pull_request
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - name: Setup Node.js 14.x
+        uses: actions/setup-node@v2
+        with:
+          node-version: 14.x
+      - name: Install and test
+        run: |
+          yarn --frozen-lockfile
+          yarn test:app

+ 29 - 0
.gitignore

@@ -0,0 +1,29 @@
+.DS_Store
+.env.development.local
+.env.local
+.env.production.local
+.env.test.local
+.envrc
+.eslintcache
+.history
+.idea
+.vercel
+.vscode
+.yarn
+*.log
+*.tgz
+build
+dist
+firebase
+logs
+node_modules
+npm-debug.log*
+package-lock.json
+static
+yarn-debug.log*
+yarn-error.log*
+src/packages/excalidraw/types
+src/packages/excalidraw/example/public/bundle.js
+src/packages/excalidraw/example/public/excalidraw-assets-dev
+src/packages/excalidraw/example/public/excalidraw.development.js
+

+ 2 - 0
.husky/pre-commit

@@ -0,0 +1,2 @@
+#!/bin/sh
+yarn lint-staged

+ 14 - 0
.lintstagedrc.js

@@ -0,0 +1,14 @@
+const { CLIEngine } = require("eslint");
+
+// see https://github.com/okonet/lint-staged#how-can-i-ignore-files-from-eslintignore-
+// for explanation
+const cli = new CLIEngine({});
+
+module.exports = {
+  "*.{js,ts,tsx}": files => {
+    return (
+      "eslint --max-warnings=0 --fix " + files.filter(file => !cli.isPathIgnored(file)).join(" ")
+    );
+  },
+  "*.{css,scss,json,md,html,yml}": ["prettier --write"],
+};

+ 1 - 0
.npmrc

@@ -0,0 +1 @@
+save-exact=true

+ 1 - 0
.nvmrc

@@ -0,0 +1 @@
+14

+ 0 - 0
.prettierignore


+ 1 - 0
.watchmanconfig

@@ -0,0 +1 @@
+{}

+ 3 - 0
CHANGELOG.md

@@ -0,0 +1,3 @@
+## 2020-10-13
+
+- Added ability to embed scene source into exported PNG/SVG files so you can import the scene from them (open via `Load` button or drag & drop). #2219

+ 63 - 0
CONTRIBUTING.md

@@ -0,0 +1,63 @@
+# Contributing
+
+## Setup
+
+### Option 1 - Manual
+
+1. Fork and clone the repo
+1. Run `yarn` to install dependencies
+1. Create a branch for your PR with `git checkout -b your-branch-name`
+
+> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork. To do this, run:
+>
+> ```sh
+> git remote add upstream https://github.com/excalidraw/excalidraw.git
+> git fetch upstream
+> git branch --set-upstream-to=upstream/master master
+> ```
+
+### Option 2 - CodeSandbox
+
+1. Go to https://codesandbox.io/s/github/excalidraw/excalidraw
+1. Connect your GitHub account
+1. Go to Git tab on left side
+1. Tap on `Fork Sandbox`
+1. Write your code
+1. Commit and PR automatically
+
+## Pull Request Guidelines
+
+Don't worry if you get any of the below wrong, or if you don't know how. We'll gladly help out.
+
+### Title
+
+Make sure the title starts with a semantic prefix:
+
+- **feat**: A new feature
+- **fix**: A bug fix
+- **docs**: Documentation only changes
+- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
+- **refactor**: A code change that neither fixes a bug nor adds a feature
+- **perf**: A code change that improves performance
+- **test**: Adding missing tests or correcting existing tests
+- **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
+- **ci**: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
+- **chore**: Other changes that don't modify src or test files
+- **revert**: Reverts a previous commit
+
+### Changelog
+
+Add a brief description of your pull request to the changelog located here: [`src/packages/excalidraw/CHANGELOG.md`](src/packages/excalidraw/CHANGELOG.md)
+
+Notes:
+
+- Make sure to prepend to the section corresponding with the semantic prefix you selected in the title
+- Link to your pull request - this will require updating the CHANGELOG _after_ creating the pull request
+
+### Testing
+
+Once you submit your pull request it will automatically be tested. Be sure to check the results of the test and fix any issues that arise.
+
+It's also a good idea to consider if your change should include additional tests. This is highly recommended for new features or bug-fixes. For example, it's good practice to create a test for each bug you fix which ensures that we don't regress the code in the future.
+
+Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well.

+ 17 - 0
Dockerfile

@@ -0,0 +1,17 @@
+FROM node:14-alpine AS build
+
+WORKDIR /opt/node_app
+
+COPY package.json yarn.lock ./
+RUN yarn --ignore-optional
+
+ARG NODE_ENV=production
+
+COPY . .
+RUN yarn build:app:docker
+
+FROM nginx:1.21-alpine
+
+COPY --from=build /opt/node_app/build /usr/share/nginx/html
+
+HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Excalidraw
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 170 - 0
README.md

@@ -0,0 +1,170 @@
+<div align="center" style="display:flex;flex-direction:column;">
+  <a href="https://excalidraw.com">
+    <img width="540" src="./public/og-image-sm.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
+  </a>
+  <h3>Virtual whiteboard for sketching hand-drawn like diagrams.<br>Collaborative and end-to-end encrypted.</h3>
+  <p>
+    <a href="https://twitter.com/Excalidraw">
+      <img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
+    </a>
+    <a target="_blank" href="https://crowdin.com/project/excalidraw">
+      <img src="https://badges.crowdin.net/excalidraw/localized.svg">
+    </a>
+  </p>
+  <p>Ask questions or hang out on our <a target="_blank" href="https://discord.gg/UexuTaE">discord.gg/UexuTaE</a>.</p>
+</div>
+
+## Try it now
+
+Go to [excalidraw.com](https://excalidraw.com) to start sketching.
+
+Read the latest news and updates on our [blog](https://blog.excalidraw.com). A good start is to see all the updates of [One Year of Excalidraw](https://blog.excalidraw.com/one-year-of-excalidraw/).
+
+## Supporting Excalidraw
+
+If you like the project, you can become a sponsor at [Open Collective](https://opencollective.com/excalidraw).
+
+[<img src="https://opencollective.com/excalidraw/tiers/sponsors/0/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/0/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/1/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/1/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/2/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/2/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/3/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/3/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/4/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/4/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/5/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/5/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/6/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/6/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/7/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/7/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/8/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/8/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/9/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/9/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/10/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/10/website)
+
+<a href="https://opencollective.com/excalidraw#category-CONTRIBUTE" target="_blank"><img src="https://opencollective.com/excalidraw/tiers/backers.svg?avatarHeight=32"/></a>
+
+Last but not least, we're thankful to these companies for offering their services for free:
+
+[![Vercel](./.github/assets/vercel.svg)](https://vercel.com) [![Sentry](./.github/assets/sentry.svg)](https://sentry.io) [![Crowdin](./.github/assets/crowdin.svg)](https://crowdin.com)
+
+## Documentation
+
+### Shortcuts
+
+You can almost do anything with shortcuts. Click on the help icon on the bottom right corner to see them all.
+
+### Curved lines and arrows
+
+Choose line or arrow and click click click instead of drag.
+
+### Charts
+
+You can easily create charts by copy pasting data from Excel or just plain comma separated text.
+
+### Translating
+
+To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
+
+Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
+
+### Create a collaboration session manually
+
+In order to create a session manually, you just need to generate a link of this form:
+
+```
+https://excalidraw.com/#room=[0-9a-f]{20},[a-zA-Z0-9_-]{22}
+```
+
+#### Example
+
+```
+https://excalidraw.com/#room=91bd46ae3aa84dff9d20,pfLqgEoY1c2ioq8LmGwsFA
+```
+
+The first set of digits is the room. This is visible from the server that’s going to dispatch messages to everyone that knows this number.
+
+The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
+
+> Note: Please ensure that the encryption key is 22 characters long.
+
+## Shape libraries
+
+Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
+
+## Embedding Excalidraw in your App?
+
+Try out [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/excalidraw). This package allows you to easily embed Excalidraw as a React component into your apps.
+
+## Development
+
+### Code Sandbox
+
+- Go to https://codesandbox.io/s/github/excalidraw/excalidraw
+  - You may need to sign in with GitHub and reload the page
+- You can start coding instantly, and even send PRs from there!
+
+### Local Installation
+
+These instructions will get you a copy of the project up and running on your local machine for development and testing purposes.
+
+#### Requirements
+
+- [Node.js](https://nodejs.org/en/)
+- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
+- [Git](https://git-scm.com/downloads)
+
+#### Clone the repo
+
+```bash
+git clone https://github.com/excalidraw/excalidraw.git
+```
+
+#### Install the dependencies
+
+```bash
+yarn
+```
+
+#### Start the server
+
+```bash
+yarn start
+```
+
+Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
+
+#### Collaboration
+
+For collaboration, you will need to set up [collab server](https://github.com/excalidraw/excalidraw-room) in local.
+
+#### Commands
+
+| Command            | Description                       |
+| ------------------ | --------------------------------- |
+| `yarn`             | Install the dependencies          |
+| `yarn start`       | Run the project                   |
+| `yarn fix`         | Reformat all files with Prettier  |
+| `yarn test`        | Run tests                         |
+| `yarn test:update` | Update test snapshots             |
+| `yarn test:code`   | Test for formatting with Prettier |
+
+#### Docker Compose
+
+You can use docker-compose to work on Excalidraw locally if you don't want to setup a Node.js env.
+
+```sh
+docker-compose up --build -d
+```
+
+### Self-hosting
+
+We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self-host your own client under your own domain, on Kubernetes, AWS ECS, etc.
+
+```sh
+docker build -t excalidraw/excalidraw .
+docker run --rm -dit --name excalidraw -p 5000:80 excalidraw/excalidraw:latest
+```
+
+The Docker image is free of analytics and other tracking libraries.
+
+**At the moment, self-hosting your own instance doesn't support sharing or collaboration features.**
+
+We are working towards providing a full-fledged solution for self-hosting your own Excalidraw.
+
+## Contributing
+
+Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change.
+
+## Notable used tools
+
+- [Create React App](https://github.com/facebook/create-react-app)
+- [Rough.js](https://roughjs.com)
+- [TypeScript](https://www.typescriptlang.org)
+- [Vercel](https://vercel.com)
+
+And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.

+ 3 - 0
crowdin.yml

@@ -0,0 +1,3 @@
+files:
+  - source: /src/locales/en.json
+    translation: /src/locales/%locale%.json

+ 25 - 0
docker-compose.yml

@@ -0,0 +1,25 @@
+version: "3.8"
+
+services:
+  excalidraw:
+    build:
+      context: .
+      args:
+        - NODE_ENV=development
+    container_name: excalidraw
+    ports:
+      - "3000:80"
+    restart: on-failure
+    stdin_open: true
+    healthcheck:
+      disable: true
+    environment:
+      - NODE_ENV=development
+    volumes:
+      - ./:/opt/node_app/app:delegated
+      - ./package.json:/opt/node_app/package.json
+      - ./yarn.lock:/opt/node_app/yarn.lock
+      - notused:/opt/node_app/app/node_modules
+
+volumes:
+  notused:

+ 5 - 0
firebase-project/.firebaserc

@@ -0,0 +1,5 @@
+{
+  "projects": {
+    "default": "excalidraw-room-persistence"
+  }
+}

+ 66 - 0
firebase-project/.gitignore

@@ -0,0 +1,66 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+firebase-debug.log*
+firebase-debug.*.log*
+
+# Firebase cache
+.firebase/
+
+# Firebase config
+
+# Uncomment this if you'd like others to create their own Firebase project.
+# For a team working on the same Firebase project(s), it is recommended to leave
+# it commented so all members can deploy to the same project(s) in .firebaserc.
+# .firebaserc
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env

+ 9 - 0
firebase-project/firebase.json

@@ -0,0 +1,9 @@
+{
+  "firestore": {
+    "rules": "firestore.rules",
+    "indexes": "firestore.indexes.json"
+  },
+  "storage": {
+    "rules": "storage.rules"
+  }
+}

+ 4 - 0
firebase-project/firestore.indexes.json

@@ -0,0 +1,4 @@
+{
+  "indexes": [],
+  "fieldOverrides": []
+}

+ 10 - 0
firebase-project/firestore.rules

@@ -0,0 +1,10 @@
+rules_version = '2';
+service cloud.firestore {
+  match /databases/{database}/documents {
+    match /{document=**} {
+      allow get, write: if true;
+      // never set this to true, otherwise anyone can delete anyone else's drawing.
+      allow list: if false;
+    }
+  }
+}

+ 12 - 0
firebase-project/storage.rules

@@ -0,0 +1,12 @@
+rules_version = '2';
+service firebase.storage {
+  match /b/{bucket}/o {
+    match /{migrations} {
+      match /{scenes}/{scene} {
+      	allow get, write: if true;
+        // redundant, but let's be explicit'
+        allow list: if false;
+      }
+    }
+  }
+}

+ 117 - 0
package.json

@@ -0,0 +1,117 @@
+{
+  "browserslist": {
+    "production": [
+      ">0.2%",
+      "not dead",
+      "not ie <= 11",
+      "not op_mini all",
+      "not safari < 12",
+      "not kaios <= 2.5",
+      "not edge < 79",
+      "not chrome < 70",
+      "not and_uc < 13",
+      "not samsung < 10"
+    ],
+    "development": [
+      "last 1 chrome version",
+      "last 1 firefox version",
+      "last 1 safari version"
+    ]
+  },
+  "dependencies": {
+    "@sentry/browser": "6.2.5",
+    "@sentry/integrations": "6.2.5",
+    "@testing-library/jest-dom": "5.16.2",
+    "@testing-library/react": "12.1.2",
+    "@tldraw/vec": "1.4.3",
+    "@types/jest": "27.4.0",
+    "@types/pica": "5.1.3",
+    "@types/react": "17.0.39",
+    "@types/react-dom": "17.0.11",
+    "@types/socket.io-client": "1.4.36",
+    "browser-fs-access": "0.23.0",
+    "clsx": "1.1.1",
+    "fake-indexeddb": "3.1.7",
+    "firebase": "8.3.3",
+    "i18next-browser-languagedetector": "6.1.2",
+    "idb-keyval": "6.0.3",
+    "image-blob-reduce": "3.0.1",
+    "lodash.throttle": "4.1.1",
+    "nanoid": "3.1.32",
+    "open-color": "1.9.1",
+    "pako": "1.0.11",
+    "perfect-freehand": "1.0.16",
+    "png-chunk-text": "1.0.0",
+    "png-chunks-encode": "1.0.0",
+    "png-chunks-extract": "1.0.0",
+    "points-on-curve": "0.2.0",
+    "pwacompat": "2.0.17",
+    "react": "17.0.2",
+    "react-dom": "17.0.2",
+    "react-scripts": "4.0.3",
+    "roughjs": "4.5.2",
+    "sass": "1.49.7",
+    "socket.io-client": "2.3.1",
+    "typescript": "4.5.5"
+  },
+  "devDependencies": {
+    "@excalidraw/eslint-config": "1.0.0",
+    "@excalidraw/prettier-config": "1.0.2",
+    "@types/chai": "4.3.0",
+    "@types/lodash.throttle": "4.1.6",
+    "@types/pako": "1.0.3",
+    "@types/resize-observer-browser": "0.1.6",
+    "chai": "4.3.6",
+    "dotenv": "10.0.0",
+    "eslint-config-prettier": "8.3.0",
+    "eslint-plugin-prettier": "3.3.1",
+    "firebase-tools": "9.23.0",
+    "husky": "7.0.4",
+    "jest-canvas-mock": "2.3.1",
+    "lint-staged": "12.3.3",
+    "pepjs": "0.5.3",
+    "prettier": "2.5.1",
+    "rewire": "5.0.0"
+  },
+  "resolutions": {
+    "@typescript-eslint/typescript-estree": "5.10.2"
+  },
+  "engines": {
+    "node": ">=14.0.0"
+  },
+  "homepage": ".",
+  "jest": {
+    "transformIgnorePatterns": [
+      "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
+    ],
+    "resetMocks": false
+  },
+  "name": "excalidraw",
+  "prettier": "@excalidraw/prettier-config",
+  "private": true,
+  "scripts": {
+    "build-node": "node ./scripts/build-node.js",
+    "build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
+    "build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
+    "build:version": "node ./scripts/build-version.js",
+    "build": "yarn build:app && yarn build:version",
+    "eject": "react-scripts eject",
+    "fix:code": "yarn test:code --fix",
+    "fix:other": "yarn prettier --write",
+    "fix": "yarn fix:other && yarn fix:code",
+    "locales-coverage": "node scripts/build-locales-coverage.js",
+    "locales-coverage:description": "node scripts/locales-coverage-description.js",
+    "prepare": "husky install",
+    "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
+    "start": "react-scripts start",
+    "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
+    "test:app": "react-scripts test --passWithNoTests",
+    "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
+    "test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
+    "test:other": "yarn prettier --list-different",
+    "test:typecheck": "tsc",
+    "test:update": "yarn test:app --updateSnapshot --watchAll=false",
+    "test": "yarn test:app",
+    "autorelease": "node scripts/autorelease.js"
+  }
+}

BIN
public/Cascadia.ttf


BIN
public/Cascadia.woff2


BIN
public/FG_Virgil.ttf


BIN
public/FG_Virgil.woff2


BIN
public/Virgil.woff2


+ 2 - 0
public/_headers

@@ -0,0 +1,2 @@
+/*
+  Access-Control-Allow-Origin: *

BIN
public/apple-touch-icon.png


BIN
public/favicon.ico


+ 13 - 0
public/fonts.css

@@ -0,0 +1,13 @@
+/* http://www.eaglefonts.com/fg-virgil-ttf-131249.htm */
+@font-face {
+  font-family: "Virgil";
+  src: url("Virgil.woff2");
+  font-display: swap;
+}
+
+/* https://github.com/microsoft/cascadia-code */
+@font-face {
+  font-family: "Cascadia";
+  src: url("Cascadia.woff2");
+  font-display: swap;
+}

+ 185 - 0
public/index.html

@@ -0,0 +1,185 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
+    <meta
+      name="viewport"
+      content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
+    />
+    <meta name="referrer" content="origin" />
+
+    <meta name="mobile-web-app-capable" content="yes" />
+
+    <meta name="theme-color" content="#000" />
+
+    <!-- General tags -->
+    <meta
+      name="description"
+      content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
+    />
+    <meta name="image" content="og-image.png" />
+
+    <!-- OpenGraph tags -->
+    <meta property="og:url" content="https://excalidraw.com" />
+    <meta property="og:site_name" content="Excalidraw" />
+    <meta property="og:type" content="website" />
+    <meta property="og:title" content="Excalidraw" />
+    <meta
+      property="og:description"
+      content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
+    />
+    <!-- OG tags require an absolute url for images -->
+    <meta
+      property="og:image"
+      name="twitter:image"
+      content="https://excalidraw.com/og-image.png"
+    />
+    <meta
+      property="og:image:secure_url"
+      name="twitter:image"
+      content="https://excalidraw.com/og-image.png"
+    />
+    <meta property="og:image:width" content="1280" />
+    <meta property="og:image:height" content="669" />
+    <meta property="og:image:alt" content="Excalidraw logo with byline." />
+
+    <!-- Twitter Card tags -->
+    <meta name="twitter:card" content="summary_large_image" />
+    <meta name="twitter:title" content="Excalidraw" />
+    <meta
+      name="twitter:description"
+      content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
+    />
+
+    <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
+
+    <!-- Excalidraw version -->
+    <meta name="version" content="{version}" />
+
+    <link
+      rel="preload"
+      href="Virgil.woff2"
+      as="font"
+      type="font/woff2"
+      crossorigin="anonymous"
+    />
+    <link
+      rel="preload"
+      href="Cascadia.woff2"
+      as="font"
+      type="font/woff2"
+      crossorigin="anonymous"
+    />
+
+    <link
+      href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
+      rel="preconnect"
+      crossorigin="anonymous"
+    />
+
+    <link
+      rel="manifest"
+      href="manifest.json"
+      style="--pwacompat-splash-font: 24px Virgil"
+    />
+
+    <link rel="stylesheet" href="fonts.css" type="text/css" />
+    <script>
+      window.EXCALIDRAW_ASSET_PATH = "/";
+      // setting this so that libraries installation reuses this window tab.
+      window.name = "_excalidraw";
+    </script>
+    <% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
+    <script
+      async
+      src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
+    ></script>
+    <script>
+      window.dataLayer = window.dataLayer || [];
+      function gtag() {
+        dataLayer.push(arguments);
+      }
+      gtag("js", new Date());
+      gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
+    </script>
+    <% } %>
+
+    <!-- FIXME: remove this when we update CRA (fix SW caching) -->
+    <style>
+      body,
+      html {
+        margin: 0;
+        --ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
+          Roboto, Helvetica, Arial, sans-serif;
+        font-family: var(--ui-font);
+        -webkit-text-size-adjust: 100%;
+
+        width: 100%;
+        height: 100%;
+        overflow: hidden;
+      }
+
+      .visually-hidden {
+        position: absolute !important;
+        height: 1px;
+        width: 1px;
+        overflow: hidden;
+        clip: rect(1px, 1px, 1px, 1px);
+        white-space: nowrap; /* added line */
+        user-select: none;
+      }
+
+      .LoadingMessage {
+        position: absolute;
+        top: 0;
+        right: 0;
+        bottom: 0;
+        left: 0;
+        z-index: 999;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        pointer-events: none;
+      }
+
+      .LoadingMessage span {
+        background-color: var(--button-gray-1);
+        border-radius: 5px;
+        padding: 0.8em 1.2em;
+        color: var(--popup-text-color);
+        font-size: 1.3em;
+      }
+      #root {
+        height: 100%;
+        -webkit-touch-callout: none;
+        -webkit-user-select: none;
+        -khtml-user-select: none;
+        -moz-user-select: none;
+        -ms-user-select: none;
+        user-select: none;
+
+        @media screen and (min-width: 1200px) {
+          -webkit-touch-callout: default;
+          -webkit-user-select: auto;
+          -khtml-user-select: auto;
+          -moz-user-select: auto;
+          -ms-user-select: auto;
+          user-select: auto;
+        }
+      }
+    </style>
+  </head>
+
+  <body>
+    <noscript> You need to enable JavaScript to run this app. </noscript>
+    <header>
+      <h1 class="visually-hidden">Excalidraw</h1>
+    </header>
+    <div id="root">
+      <div class="LoadingMessage">
+        <span>Loading scene...</span>
+      </div>
+    </div>
+  </body>
+</html>

BIN
public/logo-180x180.png


+ 74 - 0
public/manifest.json

@@ -0,0 +1,74 @@
+{
+  "short_name": "Excalidraw",
+  "name": "Excalidraw",
+  "description": "Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
+  "icons": [
+    {
+      "src": "logo-180x180.png",
+      "sizes": "180x180",
+      "type": "image/png"
+    },
+    {
+      "src": "apple-touch-icon.png",
+      "type": "image/png",
+      "sizes": "256x256"
+    }
+  ],
+  "start_url": "/",
+  "display": "standalone",
+  "theme_color": "#000000",
+  "background_color": "#ffffff",
+  "file_handlers": [
+    {
+      "action": "/",
+      "accept": {
+        "application/vnd.excalidraw+json": [".excalidraw"]
+      }
+    }
+  ],
+  "share_target": {
+    "action": "/web-share-target",
+    "method": "POST",
+    "enctype": "multipart/form-data",
+    "params": {
+      "files": [
+        {
+          "name": "file",
+          "accept": ["application/vnd.excalidraw+json", "application/json", ".excalidraw"]
+        }
+      ]
+    }
+  },
+  "screenshots": [
+    {
+      "src": "/screenshots/virtual-whiteboard.png",
+      "type": "image/png",
+      "sizes": "462x945"
+    },
+    {
+      "src": "/screenshots/wireframe.png",
+      "type": "image/png",
+      "sizes": "462x945"
+    },
+    {
+      "src": "/screenshots/illustration.png",
+      "type": "image/png",
+      "sizes": "462x945"
+    },
+    {
+      "src": "/screenshots/shapes.png",
+      "type": "image/png",
+      "sizes": "462x945"
+    },
+    {
+      "src": "/screenshots/collaboration.png",
+      "type": "image/png",
+      "sizes": "462x945"
+    },
+    {
+      "src": "/screenshots/export.png",
+      "type": "image/png",
+      "sizes": "462x945"
+    }
+  ]
+}

BIN
public/og-image-sm.png


BIN
public/og-image.png


+ 3 - 0
public/robots.txt

@@ -0,0 +1,3 @@
+user-agent: *
+Allow: /$
+Disallow: /

BIN
public/screenshots/collaboration.png


BIN
public/screenshots/export.png


BIN
public/screenshots/illustration.png


BIN
public/screenshots/shapes.png


BIN
public/screenshots/virtual-whiteboard.png


BIN
public/screenshots/wireframe.png


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/workbox/workbox-background-sync.prod.js


+ 2 - 0
public/workbox/workbox-broadcast-update.prod.js

@@ -0,0 +1,2 @@
+this.workbox=this.workbox||{},this.workbox.broadcastUpdate=function(e,t){"use strict";try{self["workbox:broadcast-update:4.3.1"]&&_()}catch(e){}const s=(e,t,s)=>{return!s.some(s=>e.headers.has(s)&&t.headers.has(s))||s.every(s=>{const n=e.headers.has(s)===t.headers.has(s),a=e.headers.get(s)===t.headers.get(s);return n&&a})},n="workbox",a=1e4,i=["content-length","etag","last-modified"],o=async({channel:e,cacheName:t,url:s})=>{const n={type:"CACHE_UPDATED",meta:"workbox-broadcast-update",payload:{cacheName:t,updatedURL:s}};if(e)e.postMessage(n);else{const e=await clients.matchAll({type:"window"});for(const t of e)t.postMessage(n)}};class c{constructor({headersToCheck:e,channelName:t,deferNoticationTimeout:s}={}){this.t=e||i,this.s=t||n,this.i=s||a,this.o()}notifyIfUpdated({oldResponse:e,newResponse:t,url:n,cacheName:a,event:i}){if(!s(e,t,this.t)){const e=(async()=>{i&&i.request&&"navigate"===i.request.mode&&await this.h(i),await this.l({channel:this.u(),cacheName:a,url:n})})();if(i)try{i.waitUntil(e)}catch(e){}return e}}async l(e){await o(e)}u(){return"BroadcastChannel"in self&&!this.p&&(this.p=new BroadcastChannel(this.s)),this.p}h(e){if(!this.m.has(e)){const s=new t.Deferred;this.m.set(e,s);const n=setTimeout(()=>{s.resolve()},this.i);s.promise.then(()=>clearTimeout(n))}return this.m.get(e).promise}o(){this.m=new Map,self.addEventListener("message",e=>{if("WINDOW_READY"===e.data.type&&"workbox-window"===e.data.meta&&this.m.size>0){for(const e of this.m.values())e.resolve();this.m.clear()}})}}return e.BroadcastCacheUpdate=c,e.Plugin=class{constructor(e){this.l=new c(e)}cacheDidUpdate({cacheName:e,oldResponse:t,newResponse:s,request:n,event:a}){t&&this.l.notifyIfUpdated({cacheName:e,oldResponse:t,newResponse:s,event:a,url:n.url})}},e.broadcastUpdate=o,e.responsesAreSame=s,e}({},workbox.core._private);
+//# sourceMappingURL=workbox-broadcast-update.prod.js.map

+ 2 - 0
public/workbox/workbox-cacheable-response.prod.js

@@ -0,0 +1,2 @@
+this.workbox=this.workbox||{},this.workbox.cacheableResponse=function(t){"use strict";try{self["workbox:cacheable-response:4.3.1"]&&_()}catch(t){}class s{constructor(t={}){this.t=t.statuses,this.s=t.headers}isResponseCacheable(t){let s=!0;return this.t&&(s=this.t.includes(t.status)),this.s&&s&&(s=Object.keys(this.s).some(s=>t.headers.get(s)===this.s[s])),s}}return t.CacheableResponse=s,t.Plugin=class{constructor(t){this.i=new s(t)}cacheWillUpdate({response:t}){return this.i.isResponseCacheable(t)?t:null}},t}({});
+//# sourceMappingURL=workbox-cacheable-response.prod.js.map

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/workbox/workbox-core.prod.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/workbox/workbox-expiration.prod.js


+ 2 - 0
public/workbox/workbox-navigation-preload.prod.js

@@ -0,0 +1,2 @@
+this.workbox=this.workbox||{},this.workbox.navigationPreload=function(t){"use strict";try{self["workbox:navigation-preload:4.3.1"]&&_()}catch(t){}function e(){return Boolean(self.registration&&self.registration.navigationPreload)}return t.disable=function(){e()&&self.addEventListener("activate",t=>{t.waitUntil(self.registration.navigationPreload.disable().then(()=>{}))})},t.enable=function(t){e()&&self.addEventListener("activate",e=>{e.waitUntil(self.registration.navigationPreload.enable().then(()=>{t&&self.registration.navigationPreload.setHeaderValue(t)}))})},t.isSupported=e,t}({});
+//# sourceMappingURL=workbox-navigation-preload.prod.js.map

+ 2 - 0
public/workbox/workbox-offline-ga.prod.js

@@ -0,0 +1,2 @@
+this.workbox=this.workbox||{},this.workbox.googleAnalytics=function(e,t,o,n,a,c,w){"use strict";try{self["workbox:google-analytics:4.3.1"]&&_()}catch(e){}const r=/^\/(\w+\/)?collect/,s=e=>async({queue:t})=>{let o;for(;o=await t.shiftRequest();){const{request:n,timestamp:a}=o,c=new URL(n.url);try{const w="POST"===n.method?new URLSearchParams(await n.clone().text()):c.searchParams,r=a-(Number(w.get("qt"))||0),s=Date.now()-r;if(w.set("qt",s),e.parameterOverrides)for(const t of Object.keys(e.parameterOverrides)){const o=e.parameterOverrides[t];w.set(t,o)}"function"==typeof e.hitFilter&&e.hitFilter.call(null,w),await fetch(new Request(c.origin+c.pathname,{body:w.toString(),method:"POST",mode:"cors",credentials:"omit",headers:{"Content-Type":"text/plain"}}))}catch(e){throw await t.unshiftRequest(o),e}}},i=e=>{const t=({url:e})=>"www.google-analytics.com"===e.hostname&&r.test(e.pathname),o=new w.NetworkOnly({plugins:[e]});return[new n.Route(t,o,"GET"),new n.Route(t,o,"POST")]},l=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.google-analytics.com"===e.hostname&&"/analytics.js"===e.pathname,t,"GET")},m=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtag/js"===e.pathname,t,"GET")},u=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtm.js"===e.pathname,t,"GET")};return e.initialize=((e={})=>{const n=o.cacheNames.getGoogleAnalyticsName(e.cacheName),c=new t.Plugin("workbox-google-analytics",{maxRetentionTime:2880,onSync:s(e)}),w=[u(n),l(n),m(n),...i(c)],r=new a.Router;for(const e of w)r.registerRoute(e);r.addFetchListener()}),e}({},workbox.backgroundSync,workbox.core._private,workbox.routing,workbox.routing,workbox.strategies,workbox.strategies);
+//# sourceMappingURL=workbox-offline-ga.prod.js.map

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/workbox/workbox-precaching.prod.js


+ 2 - 0
public/workbox/workbox-range-requests.prod.js

@@ -0,0 +1,2 @@
+this.workbox=this.workbox||{},this.workbox.rangeRequests=function(e,n){"use strict";try{self["workbox:range-requests:4.3.1"]&&_()}catch(e){}async function t(e,t){try{if(206===t.status)return t;const s=e.headers.get("range");if(!s)throw new n.WorkboxError("no-range-header");const a=function(e){const t=e.trim().toLowerCase();if(!t.startsWith("bytes="))throw new n.WorkboxError("unit-must-be-bytes",{normalizedRangeHeader:t});if(t.includes(","))throw new n.WorkboxError("single-range-only",{normalizedRangeHeader:t});const s=/(\d*)-(\d*)/.exec(t);if(null===s||!s[1]&&!s[2])throw new n.WorkboxError("invalid-range-values",{normalizedRangeHeader:t});return{start:""===s[1]?null:Number(s[1]),end:""===s[2]?null:Number(s[2])}}(s),r=await t.blob(),i=function(e,t,s){const a=e.size;if(s>a||t<0)throw new n.WorkboxError("range-not-satisfiable",{size:a,end:s,start:t});let r,i;return null===t?(r=a-s,i=a):null===s?(r=t,i=a):(r=t,i=s+1),{start:r,end:i}}(r,a.start,a.end),o=r.slice(i.start,i.end),u=o.size,l=new Response(o,{status:206,statusText:"Partial Content",headers:t.headers});return l.headers.set("Content-Length",u),l.headers.set("Content-Range",`bytes ${i.start}-${i.end-1}/`+r.size),l}catch(e){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}}return e.createPartialResponse=t,e.Plugin=class{async cachedResponseWillBeUsed({request:e,cachedResponse:n}){return n&&e.headers.has("range")?await t(e,n):n}},e}({},workbox.core._private);
+//# sourceMappingURL=workbox-range-requests.prod.js.map

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/workbox/workbox-routing.prod.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/workbox/workbox-strategies.prod.js


+ 2 - 0
public/workbox/workbox-streams.prod.js

@@ -0,0 +1,2 @@
+this.workbox=this.workbox||{},this.workbox.streams=function(e){"use strict";try{self["workbox:streams:4.3.1"]&&_()}catch(e){}function n(e){const n=e.map(e=>Promise.resolve(e).then(e=>(function(e){return e.body&&e.body.getReader?e.body.getReader():e.getReader?e.getReader():new Response(e).body.getReader()})(e)));let t,r;const s=new Promise((e,n)=>{t=e,r=n});let o=0;return{done:s,stream:new ReadableStream({pull(e){return n[o].then(e=>e.read()).then(r=>{if(r.done)return++o>=n.length?(e.close(),void t()):this.pull(e);e.enqueue(r.value)}).catch(e=>{throw r(e),e})},cancel(){t()}})}}function t(e={}){const n=new Headers(e);return n.has("content-type")||n.set("content-type","text/html"),n}function r(e,r){const{done:s,stream:o}=n(e),a=t(r);return{done:s,response:new Response(o,{headers:a})}}let s=void 0;function o(){if(void 0===s)try{new ReadableStream({start(){}}),s=!0}catch(e){s=!1}return s}return e.concatenate=n,e.concatenateToResponse=r,e.isSupported=o,e.strategy=function(e,n){return async({event:s,url:a,params:c})=>{if(o()){const{done:t,response:o}=r(e.map(e=>e({event:s,url:a,params:c})),n);return s.waitUntil(t),o}const i=await Promise.all(e.map(e=>e({event:s,url:a,params:c})).map(async e=>{const n=await e;return n instanceof Response?n.blob():n})),u=t(n);return new Response(new Blob(i),{headers:u})}},e}({});
+//# sourceMappingURL=workbox-streams.prod.js.map

+ 2 - 0
public/workbox/workbox-sw.js

@@ -0,0 +1,2 @@
+!function(){"use strict";try{self["workbox:sw:4.3.1"]&&_()}catch(t){}const t="https://storage.googleapis.com/workbox-cdn/releases/4.3.1",e={backgroundSync:"background-sync",broadcastUpdate:"broadcast-update",cacheableResponse:"cacheable-response",core:"core",expiration:"expiration",googleAnalytics:"offline-ga",navigationPreload:"navigation-preload",precaching:"precaching",rangeRequests:"range-requests",routing:"routing",strategies:"strategies",streams:"streams"};self.workbox=new class{constructor(){return this.v={},this.t={debug:"localhost"===self.location.hostname,modulePathPrefix:null,modulePathCb:null},this.s=this.t.debug?"dev":"prod",this.o=!1,new Proxy(this,{get(t,s){if(t[s])return t[s];const o=e[s];return o&&t.loadModule(`workbox-${o}`),t[s]}})}setConfig(t={}){if(this.o)throw new Error("Config must be set before accessing workbox.* modules");Object.assign(this.t,t),this.s=this.t.debug?"dev":"prod"}loadModule(t){const e=this.i(t);try{importScripts(e),this.o=!0}catch(s){throw console.error(`Unable to import module '${t}' from '${e}'.`),s}}i(e){if(this.t.modulePathCb)return this.t.modulePathCb(e,this.t.debug);let s=[t];const o=`${e}.${this.s}.js`,r=this.t.modulePathPrefix;return r&&""===(s=r.split("/"))[s.length-1]&&s.splice(s.length-1,1),s.push(o),s.join("/")}}}();
+//# sourceMappingURL=workbox-sw.js.map

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/workbox/workbox-window.prod.es5.mjs


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/workbox/workbox-window.prod.mjs


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/workbox/workbox-window.prod.umd.js


+ 77 - 0
scripts/autorelease.js

@@ -0,0 +1,77 @@
+const fs = require("fs");
+const { exec, execSync } = require("child_process");
+const core = require("@actions/core");
+
+const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
+const excalidrawPackage = `${excalidrawDir}/package.json`;
+const pkg = require(excalidrawPackage);
+
+const getShortCommitHash = () => {
+  return execSync("git rev-parse --short HEAD").toString().trim();
+};
+
+const publish = () => {
+  try {
+    execSync(`yarn  --frozen-lockfile`);
+    execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
+    execSync(`yarn run build:umd`, { cwd: excalidrawDir });
+    execSync(`yarn --cwd ${excalidrawDir} publish`);
+    console.info("Published 🎉");
+    core.setOutput(
+      "result",
+      `**Preview version has been shipped** :rocket:
+    You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/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 filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
+
+  const excalidrawPackageFiles = changedFiles.filter((file) => {
+    return (
+      (file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
+      !filesToIgnoreRegex.test(file)
+    );
+  });
+  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
+  pkg.name = "@excalidraw/excalidraw-next";
+  let version = `${pkg.version}-${getShortCommitHash()}`;
+
+  // update readme
+  let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
+
+  const isPreview = process.argv.slice(2)[0] === "preview";
+  if (isPreview) {
+    // use pullNumber-commithash as the version for preview
+    const pullRequestNumber = process.argv.slice(3)[0];
+    version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
+    // replace "excalidraw-next" with "excalidraw-preview"
+    pkg.name = "@excalidraw/excalidraw-preview";
+    data = data.replace(/excalidraw-next/g, "excalidraw-preview");
+    data = data.trim();
+  }
+  pkg.version = version;
+
+  fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
+
+  fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
+  console.info("Publish in progress...");
+  publish();
+});

+ 35 - 0
scripts/build-locales-coverage.js

@@ -0,0 +1,35 @@
+const { readdirSync, writeFileSync } = require("fs");
+const files = readdirSync(`${__dirname}/../src/locales`);
+
+const flatten = (object = {}, result = {}, extraKey = "") => {
+  for (const key in object) {
+    if (typeof object[key] !== "object") {
+      result[extraKey + key] = object[key];
+    } else {
+      flatten(object[key], result, `${extraKey}${key}.`);
+    }
+  }
+  return result;
+};
+
+const locales = files.filter(
+  (file) => file !== "README.md" && file !== "percentages.json",
+);
+
+const percentages = {};
+
+for (let index = 0; index < locales.length; index++) {
+  const currentLocale = locales[index];
+  const data = flatten(require(`${__dirname}/../src/locales/${currentLocale}`));
+
+  const allKeys = Object.keys(data);
+  const translatedKeys = allKeys.filter((item) => data[item] !== "");
+  const percentage = Math.floor((100 * translatedKeys.length) / allKeys.length);
+  percentages[currentLocale.replace(".json", "")] = percentage;
+}
+
+writeFileSync(
+  `${__dirname}/../src/locales/percentages.json`,
+  `${JSON.stringify(percentages, null, 2)}\n`,
+  "utf8",
+);

+ 40 - 0
scripts/build-node.js

@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+
+// In order to use this, you need to install Cairo on your machine. See
+// instructions here: https://github.com/Automattic/node-canvas#compiling
+
+// In order to run:
+//   npm install canvas # please do not check it in
+//   yarn build-node
+//   node build/static/js/build-node.js
+//   open test.png
+
+const rewire = require("rewire");
+const defaults = rewire("react-scripts/scripts/build.js");
+const config = defaults.__get__("config");
+
+// Disable multiple chunks
+config.optimization.runtimeChunk = false;
+config.optimization.splitChunks = {
+  cacheGroups: {
+    default: false,
+  },
+};
+// Set the filename to be deterministic
+config.output.filename = "static/js/build-node.js";
+// Don't choke on node-specific requires
+config.target = "node";
+// Set the node entrypoint
+config.entry = "./src/index-node";
+// By default, webpack is going to replace the require of the canvas.node file
+// to just a string with the path of the canvas.node file. We need to tell
+// webpack to avoid rewriting that dependency.
+config.externals = (context, request, callback) => {
+  if (/\.node$/.test(request)) {
+    return callback(
+      null,
+      "commonjs ../../../node_modules/canvas/build/Release/canvas.node",
+    );
+  }
+  callback();
+};

+ 61 - 0
scripts/build-version.js

@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+
+const fs = require("fs");
+const path = require("path");
+const versionFile = path.join("build", "version.json");
+const indexFile = path.join("build", "index.html");
+
+const versionDate = (date) => date.toISOString().replace(".000", "");
+
+const commitHash = () => {
+  try {
+    return require("child_process")
+      .execSync("git rev-parse --short HEAD")
+      .toString()
+      .trim();
+  } catch {
+    return "none";
+  }
+};
+
+const commitDate = (hash) => {
+  try {
+    const unix = require("child_process")
+      .execSync(`git show -s --format=%ct ${hash}`)
+      .toString()
+      .trim();
+    const date = new Date(parseInt(unix) * 1000);
+    return versionDate(date);
+  } catch {
+    return versionDate(new Date());
+  }
+};
+
+const getFullVersion = () => {
+  const hash = commitHash();
+  return `${commitDate(hash)}-${hash}`;
+};
+
+const data = JSON.stringify(
+  {
+    version: getFullVersion(),
+  },
+  undefined,
+  2,
+);
+
+fs.writeFileSync(versionFile, data);
+
+// https://stackoverflow.com/a/14181136/8418
+fs.readFile(indexFile, "utf8", (error, data) => {
+  if (error) {
+    return console.error(error);
+  }
+  const result = data.replace(/{version}/g, getFullVersion());
+
+  fs.writeFile(indexFile, result, "utf8", (error) => {
+    if (error) {
+      return console.error(error);
+    }
+  });
+});

+ 193 - 0
scripts/locales-coverage-description.js

@@ -0,0 +1,193 @@
+const fs = require("fs");
+
+const THRESSHOLD = 85;
+
+const crowdinMap = {
+  "ar-SA": "en-ar",
+  "bg-BG": "en-bg",
+  "bn-BD": "en-bn",
+  "ca-ES": "en-ca",
+  "da-DK": "en-da",
+  "de-DE": "en-de",
+  "el-GR": "en-el",
+  "es-ES": "en-es",
+  "eu-ES": "en-eu",
+  "fa-IR": "en-fa",
+  "fi-FI": "en-fi",
+  "fr-FR": "en-fr",
+  "he-IL": "en-he",
+  "hi-IN": "en-hi",
+  "hu-HU": "en-hu",
+  "id-ID": "en-id",
+  "it-IT": "en-it",
+  "ja-JP": "en-ja",
+  "kab-KAB": "en-kab",
+  "ko-KR": "en-ko",
+  "my-MM": "en-my",
+  "nb-NO": "en-nb",
+  "nl-NL": "en-nl",
+  "nn-NO": "en-nnno",
+  "oc-FR": "en-oc",
+  "pa-IN": "en-pain",
+  "pl-PL": "en-pl",
+  "pt-BR": "en-ptbr",
+  "pt-PT": "en-pt",
+  "ro-RO": "en-ro",
+  "ru-RU": "en-ru",
+  "si-LK": "en-silk",
+  "sk-SK": "en-sk",
+  "sv-SE": "en-sv",
+  "ta-IN": "en-ta",
+  "tr-TR": "en-tr",
+  "uk-UA": "en-uk",
+  "zh-CN": "en-zhcn",
+  "zh-HK": "en-zhhk",
+  "zh-TW": "en-zhtw",
+  "lt-LT": "en-lt",
+  "lv-LV": "en-lv",
+  "cs-CZ": "en-cs",
+  "kk-KZ": "en-kk",
+};
+
+const flags = {
+  "ar-SA": "🇸🇦",
+  "bg-BG": "🇧🇬",
+  "bn-BD": "🇧🇩",
+  "ca-ES": "🏳",
+  "cs-CZ": "🇨🇿",
+  "da-DK": "🇩🇰",
+  "de-DE": "🇩🇪",
+  "el-GR": "🇬🇷",
+  "es-ES": "🇪🇸",
+  "fa-IR": "🇮🇷",
+  "fi-FI": "🇫🇮",
+  "fr-FR": "🇫🇷",
+  "he-IL": "🇮🇱",
+  "hi-IN": "🇮🇳",
+  "hu-HU": "🇭🇺",
+  "id-ID": "🇮🇩",
+  "it-IT": "🇮🇹",
+  "ja-JP": "🇯🇵",
+  "kab-KAB": "🏳",
+  "kk-KZ": "🇰🇿",
+  "ko-KR": "🇰🇷",
+  "lt-LT": "🇱🇹",
+  "lv-LV": "🇱🇻",
+  "my-MM": "🇲🇲",
+  "nb-NO": "🇳🇴",
+  "nl-NL": "🇳🇱",
+  "nn-NO": "🇳🇴",
+  "oc-FR": "🏳",
+  "pa-IN": "🇮🇳",
+  "pl-PL": "🇵🇱",
+  "pt-BR": "🇧🇷",
+  "pt-PT": "🇵🇹",
+  "ro-RO": "🇷🇴",
+  "ru-RU": "🇷🇺",
+  "si-LK": "🇱🇰",
+  "sk-SK": "🇸🇰",
+  "sv-SE": "🇸🇪",
+  "ta-IN": "🇮🇳",
+  "tr-TR": "🇹🇷",
+  "uk-UA": "🇺🇦",
+  "zh-CN": "🇨🇳",
+  "zh-HK": "🇭🇰",
+  "zh-TW": "🇹🇼",
+};
+
+const languages = {
+  "ar-SA": "العربية",
+  "bg-BG": "Български",
+  "bn-BD": "Bengali",
+  "ca-ES": "Català",
+  "cs-CZ": "Česky",
+  "da-DK": "Dansk",
+  "de-DE": "Deutsch",
+  "el-GR": "Ελληνικά",
+  "es-ES": "Español",
+  "eu-ES": "Euskara",
+  "fa-IR": "فارسی",
+  "fi-FI": "Suomi",
+  "fr-FR": "Français",
+  "he-IL": "עברית",
+  "hi-IN": "हिन्दी",
+  "hu-HU": "Magyar",
+  "id-ID": "Bahasa Indonesia",
+  "it-IT": "Italiano",
+  "ja-JP": "日本語",
+  "kab-KAB": "Taqbaylit",
+  "kk-KZ": "Қазақ тілі",
+  "ko-KR": "한국어",
+  "lt-LT": "Lietuvių",
+  "lv-LV": "Latviešu",
+  "my-MM": "Burmese",
+  "nb-NO": "Norsk bokmål",
+  "nl-NL": "Nederlands",
+  "nn-NO": "Norsk nynorsk",
+  "oc-FR": "Occitan",
+  "pa-IN": "ਪੰਜਾਬੀ",
+  "pl-PL": "Polski",
+  "pt-BR": "Português Brasileiro",
+  "pt-PT": "Português",
+  "ro-RO": "Română",
+  "ru-RU": "Русский",
+  "si-LK": "සිංහල",
+  "sk-SK": "Slovenčina",
+  "sv-SE": "Svenska",
+  "ta-IN": "Tamil",
+  "tr-TR": "Türkçe",
+  "uk-UA": "Українська",
+  "zh-CN": "简体中文",
+  "zh-HK": "繁體中文 (香港)",
+  "zh-TW": "繁體中文",
+};
+
+const percentages = fs.readFileSync(
+  `${__dirname}/../src/locales/percentages.json`,
+);
+const rowData = JSON.parse(percentages);
+
+const coverages = Object.entries(rowData)
+  .sort(([, a], [, b]) => b - a)
+  .reduce((r, [k, v]) => ({ ...r, [k]: v }), {});
+
+const boldIf = (text, condition) => (condition ? `**${text}**` : text);
+
+const printHeader = () => {
+  let result = "| | Flag | Locale | % |\n";
+  result += "| :--: | :--: | -- | :--: |";
+  return result;
+};
+
+const printRow = (id, locale, coverage) => {
+  const isOver = coverage >= THRESSHOLD;
+  let result = `| ${isOver ? id : "..."} | `;
+  result += `${locale in flags ? flags[locale] : ""} | `;
+  const language = locale in languages ? languages[locale] : locale;
+  if (locale in crowdinMap && crowdinMap[locale]) {
+    result += `[${boldIf(
+      language,
+      isOver,
+    )}](https://crowdin.com/translate/excalidraw/10/${crowdinMap[locale]}) | `;
+  } else {
+    result += `${boldIf(language, isOver)} | `;
+  }
+  result += `${coverage === 100 ? "💯" : boldIf(coverage, isOver)} |`;
+  return result;
+};
+
+console.info(
+  `Each language must be at least **${THRESSHOLD}%** translated in order to appear on Excalidraw. Join us on [Crowdin](https://crowdin.com/project/excalidraw) and help us translate your own language. **Can't find yours yet?** Open an [issue](https://github.com/excalidraw/excalidraw/issues/new) and we'll add it to the list.`,
+);
+console.info("\n\r");
+console.info(printHeader());
+let index = 1;
+for (const coverage in coverages) {
+  if (coverage === "en") {
+    continue;
+  }
+  console.info(printRow(index, coverage, coverages[coverage]));
+  index++;
+}
+console.info("\n\r");
+console.info("\\* Languages in **bold** are going to appear on production.");

+ 39 - 0
scripts/release.js

@@ -0,0 +1,39 @@
+const fs = require("fs");
+const util = require("util");
+const exec = util.promisify(require("child_process").exec);
+const updateReadme = require("./updateReadme");
+const updateChangelog = require("./updateChangelog");
+
+const excalidrawDir = `${__dirname}/../src/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 release = async (nextVersion) => {
+  try {
+    updateReadme();
+    await updateChangelog(nextVersion);
+    updatePackageVersion(nextVersion);
+    await exec(`git add -u`);
+    await exec(
+      `git commit -m "docs: release @excalidraw/excalidraw@${nextVersion}  🎉"`,
+    );
+    /* eslint-disable no-console */
+    console.log("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);
+}
+release(nextVersion);

+ 104 - 0
scripts/updateChangelog.js

@@ -0,0 +1,104 @@
+const fs = require("fs");
+const util = require("util");
+const exec = util.promisify(require("child_process").exec);
+
+const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
+const excalidrawPackage = `${excalidrawDir}/package.json`;
+const pkg = require(excalidrawPackage);
+const lastVersion = pkg.version;
+const existingChangeLog = fs.readFileSync(
+  `${excalidrawDir}/CHANGELOG.md`,
+  "utf8",
+);
+
+const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
+const headerForType = {
+  feat: "Features",
+  fix: "Fixes",
+  style: "Styles",
+  refactor: " Refactor",
+  perf: "Performance",
+  build: "Build",
+};
+const badCommits = [];
+const getCommitHashForLastVersion = async () => {
+  try {
+    const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
+    const { stdout } = await exec(
+      `git log --format=format:"%H" --grep=${commitMessage}`,
+    );
+    return stdout;
+  } catch (error) {
+    console.error(error);
+  }
+};
+
+const getLibraryCommitsSinceLastRelease = async () => {
+  const commitHash = await getCommitHashForLastVersion();
+  const { stdout } = await exec(
+    `git log --pretty=format:%s ${commitHash}...master`,
+  );
+  const commitsSinceLastRelease = stdout.split("\n");
+  const commitList = {};
+  supportedTypes.forEach((type) => {
+    commitList[type] = [];
+  });
+
+  commitsSinceLastRelease.forEach((commit) => {
+    const indexOfColon = commit.indexOf(":");
+    const type = commit.slice(0, indexOfColon);
+    if (!supportedTypes.includes(type)) {
+      return;
+    }
+    const messageWithoutType = commit.slice(indexOfColon + 1).trim();
+    const messageWithCapitalizeFirst =
+      messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
+    const prMatch = commit.match(/\(#([0-9]*)\)/);
+    if (prMatch) {
+      const prNumber = prMatch[1];
+
+      // return if the changelog already contains the pr number which would happen for package updates
+      if (existingChangeLog.includes(prNumber)) {
+        return;
+      }
+      const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
+      const messageWithPRLink = messageWithCapitalizeFirst.replace(
+        /\(#[0-9]*\)/,
+        prMarkdown,
+      );
+      commitList[type].push(messageWithPRLink);
+    } else {
+      badCommits.push(commit);
+      commitList[type].push(messageWithCapitalizeFirst);
+    }
+  });
+  console.info("Bad commits:", badCommits);
+  return commitList;
+};
+
+const updateChangelog = async (nextVersion) => {
+  const commitList = await getLibraryCommitsSinceLastRelease();
+  let changelogForLibrary =
+    "## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
+  supportedTypes.forEach((type) => {
+    if (commitList[type].length) {
+      changelogForLibrary += `### ${headerForType[type]}\n\n`;
+      const commits = commitList[type];
+      commits.forEach((commit) => {
+        changelogForLibrary += `- ${commit}\n\n`;
+      });
+    }
+  });
+  changelogForLibrary += "---\n";
+  const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
+  let updatedContent =
+    existingChangeLog.slice(0, lastVersionIndex) +
+    changelogForLibrary +
+    existingChangeLog.slice(lastVersionIndex);
+  const currentDate = new Date().toISOString().slice(0, 10);
+  const newVersion = `## ${nextVersion} (${currentDate})`;
+  updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
+  fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
+};
+
+module.exports = updateChangelog;

+ 27 - 0
scripts/updateReadme.js

@@ -0,0 +1,27 @@
+const fs = require("fs");
+
+const updateReadme = () => {
+  const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
+  let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
+
+  // remove note for unstable release
+  data = data.replace(
+    /<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
+    "",
+  );
+
+  // replace "excalidraw-next" with "excalidraw"
+  data = data.replace(/excalidraw-next/g, "excalidraw");
+  data = data.trim();
+
+  const demoIndex = data.indexOf("### Demo");
+  const excalidrawNextNote =
+    "#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
+  // Add excalidraw next note to try out for unreleased changes
+  data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
+
+  // update readme
+  fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
+};
+
+module.exports = updateReadme;

+ 59 - 0
src/actions/actionAddToLibrary.ts

@@ -0,0 +1,59 @@
+import { register } from "./register";
+import { getSelectedElements } from "../scene";
+import { getNonDeletedElements } from "../element";
+import { deepCopyElement } from "../element/newElement";
+import { randomId } from "../random";
+import { t } from "../i18n";
+
+export const actionAddToLibrary = register({
+  name: "addToLibrary",
+  perform: (elements, appState, _, app) => {
+    const selectedElements = getSelectedElements(
+      getNonDeletedElements(elements),
+      appState,
+      true,
+    );
+    if (selectedElements.some((element) => element.type === "image")) {
+      return {
+        commitToHistory: false,
+        appState: {
+          ...appState,
+          errorMessage: "Support for adding images to the library coming soon!",
+        },
+      };
+    }
+
+    return app.library
+      .loadLibrary()
+      .then((items) => {
+        return app.library.saveLibrary([
+          {
+            id: randomId(),
+            status: "unpublished",
+            elements: selectedElements.map(deepCopyElement),
+            created: Date.now(),
+          },
+          ...items,
+        ]);
+      })
+      .then(() => {
+        return {
+          commitToHistory: false,
+          appState: {
+            ...appState,
+            toastMessage: t("toast.addedToLibrary"),
+          },
+        };
+      })
+      .catch((error) => {
+        return {
+          commitToHistory: false,
+          appState: {
+            ...appState,
+            errorMessage: error.message,
+          },
+        };
+      });
+  },
+  contextItemLabel: "labels.addToLibrary",
+});

+ 208 - 0
src/actions/actionAlign.tsx

@@ -0,0 +1,208 @@
+import { alignElements, Alignment } from "../align";
+import {
+  AlignBottomIcon,
+  AlignLeftIcon,
+  AlignRightIcon,
+  AlignTopIcon,
+  CenterHorizontallyIcon,
+  CenterVerticallyIcon,
+} from "../components/icons";
+import { ToolButton } from "../components/ToolButton";
+import { getNonDeletedElements } from "../element";
+import { ExcalidrawElement } from "../element/types";
+import { t } from "../i18n";
+import { KEYS } from "../keys";
+import { getSelectedElements, isSomeElementSelected } from "../scene";
+import { AppState } from "../types";
+import { arrayToMap, getShortcutKey } from "../utils";
+import { register } from "./register";
+
+const enableActionGroup = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
+
+const alignSelectedElements = (
+  elements: readonly ExcalidrawElement[],
+  appState: Readonly<AppState>,
+  alignment: Alignment,
+) => {
+  const selectedElements = getSelectedElements(
+    getNonDeletedElements(elements),
+    appState,
+  );
+
+  const updatedElements = alignElements(selectedElements, alignment);
+
+  const updatedElementsMap = arrayToMap(updatedElements);
+
+  return elements.map(
+    (element) => updatedElementsMap.get(element.id) || element,
+  );
+};
+
+export const actionAlignTop = register({
+  name: "alignTop",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: alignSelectedElements(elements, appState, {
+        position: "start",
+        axis: "y",
+      }),
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) =>
+    event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<AlignTopIcon theme={appState.theme} />}
+      onClick={() => updateData(null)}
+      title={`${t("labels.alignTop")} — ${getShortcutKey(
+        "CtrlOrCmd+Shift+Up",
+      )}`}
+      aria-label={t("labels.alignTop")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});
+
+export const actionAlignBottom = register({
+  name: "alignBottom",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: alignSelectedElements(elements, appState, {
+        position: "end",
+        axis: "y",
+      }),
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) =>
+    event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<AlignBottomIcon theme={appState.theme} />}
+      onClick={() => updateData(null)}
+      title={`${t("labels.alignBottom")} — ${getShortcutKey(
+        "CtrlOrCmd+Shift+Down",
+      )}`}
+      aria-label={t("labels.alignBottom")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});
+
+export const actionAlignLeft = register({
+  name: "alignLeft",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: alignSelectedElements(elements, appState, {
+        position: "start",
+        axis: "x",
+      }),
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) =>
+    event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<AlignLeftIcon theme={appState.theme} />}
+      onClick={() => updateData(null)}
+      title={`${t("labels.alignLeft")} — ${getShortcutKey(
+        "CtrlOrCmd+Shift+Left",
+      )}`}
+      aria-label={t("labels.alignLeft")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});
+
+export const actionAlignRight = register({
+  name: "alignRight",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: alignSelectedElements(elements, appState, {
+        position: "end",
+        axis: "x",
+      }),
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) =>
+    event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<AlignRightIcon theme={appState.theme} />}
+      onClick={() => updateData(null)}
+      title={`${t("labels.alignRight")} — ${getShortcutKey(
+        "CtrlOrCmd+Shift+Right",
+      )}`}
+      aria-label={t("labels.alignRight")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});
+
+export const actionAlignVerticallyCentered = register({
+  name: "alignVerticallyCentered",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: alignSelectedElements(elements, appState, {
+        position: "center",
+        axis: "y",
+      }),
+      commitToHistory: true,
+    };
+  },
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<CenterVerticallyIcon theme={appState.theme} />}
+      onClick={() => updateData(null)}
+      title={t("labels.centerVertically")}
+      aria-label={t("labels.centerVertically")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});
+
+export const actionAlignHorizontallyCentered = register({
+  name: "alignHorizontallyCentered",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: alignSelectedElements(elements, appState, {
+        position: "center",
+        axis: "x",
+      }),
+      commitToHistory: true,
+    };
+  },
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<CenterHorizontallyIcon theme={appState.theme} />}
+      onClick={() => updateData(null)}
+      title={t("labels.centerHorizontally")}
+      aria-label={t("labels.centerHorizontally")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});

+ 289 - 0
src/actions/actionCanvas.tsx

@@ -0,0 +1,289 @@
+import { ColorPicker } from "../components/ColorPicker";
+import { zoomIn, zoomOut } from "../components/icons";
+import { ToolButton } from "../components/ToolButton";
+import { DarkModeToggle } from "../components/DarkModeToggle";
+import { THEME, ZOOM_STEP } from "../constants";
+import { getCommonBounds, getNonDeletedElements } from "../element";
+import { ExcalidrawElement } from "../element/types";
+import { t } from "../i18n";
+import { CODES, KEYS } from "../keys";
+import { getNormalizedZoom, getSelectedElements } from "../scene";
+import { centerScrollOn } from "../scene/scroll";
+import { getStateForZoom } from "../scene/zoom";
+import { AppState, NormalizedZoomValue } from "../types";
+import { getShortcutKey } from "../utils";
+import { register } from "./register";
+import { Tooltip } from "../components/Tooltip";
+import { newElementWith } from "../element/mutateElement";
+import { getDefaultAppState } from "../appState";
+import ClearCanvas from "../components/ClearCanvas";
+
+export const actionChangeViewBackgroundColor = register({
+  name: "changeViewBackgroundColor",
+  perform: (_, appState, value) => {
+    return {
+      appState: { ...appState, ...value },
+      commitToHistory: !!value.viewBackgroundColor,
+    };
+  },
+  PanelComponent: ({ appState, updateData }) => {
+    return (
+      <div style={{ position: "relative" }}>
+        <ColorPicker
+          label={t("labels.canvasBackground")}
+          type="canvasBackground"
+          color={appState.viewBackgroundColor}
+          onChange={(color) => updateData({ viewBackgroundColor: color })}
+          isActive={appState.openPopup === "canvasColorPicker"}
+          setActive={(active) =>
+            updateData({ openPopup: active ? "canvasColorPicker" : null })
+          }
+          data-testid="canvas-background-picker"
+        />
+      </div>
+    );
+  },
+});
+
+export const actionClearCanvas = register({
+  name: "clearCanvas",
+  perform: (elements, appState, _, app) => {
+    app.imageCache.clear();
+    return {
+      elements: elements.map((element) =>
+        newElementWith(element, { isDeleted: true }),
+      ),
+      appState: {
+        ...getDefaultAppState(),
+        files: {},
+        theme: appState.theme,
+        elementLocked: appState.elementLocked,
+        penMode: appState.penMode,
+        penDetected: appState.penDetected,
+        exportBackground: appState.exportBackground,
+        exportEmbedScene: appState.exportEmbedScene,
+        gridSize: appState.gridSize,
+        showStats: appState.showStats,
+        pasteDialog: appState.pasteDialog,
+        elementType:
+          appState.elementType === "image" ? "selection" : appState.elementType,
+      },
+      commitToHistory: true,
+    };
+  },
+
+  PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
+});
+
+export const actionZoomIn = register({
+  name: "zoomIn",
+  perform: (_elements, appState, _, app) => {
+    return {
+      appState: {
+        ...appState,
+        ...getStateForZoom(
+          {
+            viewportX: appState.width / 2 + appState.offsetLeft,
+            viewportY: appState.height / 2 + appState.offsetTop,
+            nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
+          },
+          appState,
+        ),
+      },
+      commitToHistory: false,
+    };
+  },
+  PanelComponent: ({ updateData }) => (
+    <ToolButton
+      type="button"
+      icon={zoomIn}
+      title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
+      aria-label={t("buttons.zoomIn")}
+      onClick={() => {
+        updateData(null);
+      }}
+      size="small"
+    />
+  ),
+  keyTest: (event) =>
+    (event.code === CODES.EQUAL || event.code === CODES.NUM_ADD) &&
+    (event[KEYS.CTRL_OR_CMD] || event.shiftKey),
+});
+
+export const actionZoomOut = register({
+  name: "zoomOut",
+  perform: (_elements, appState, _, app) => {
+    return {
+      appState: {
+        ...appState,
+        ...getStateForZoom(
+          {
+            viewportX: appState.width / 2 + appState.offsetLeft,
+            viewportY: appState.height / 2 + appState.offsetTop,
+            nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
+          },
+          appState,
+        ),
+      },
+      commitToHistory: false,
+    };
+  },
+  PanelComponent: ({ updateData }) => (
+    <ToolButton
+      type="button"
+      icon={zoomOut}
+      title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
+      aria-label={t("buttons.zoomOut")}
+      onClick={() => {
+        updateData(null);
+      }}
+      size="small"
+    />
+  ),
+  keyTest: (event) =>
+    (event.code === CODES.MINUS || event.code === CODES.NUM_SUBTRACT) &&
+    (event[KEYS.CTRL_OR_CMD] || event.shiftKey),
+});
+
+export const actionResetZoom = register({
+  name: "resetZoom",
+  perform: (_elements, appState, _, app) => {
+    return {
+      appState: {
+        ...appState,
+        ...getStateForZoom(
+          {
+            viewportX: appState.width / 2 + appState.offsetLeft,
+            viewportY: appState.height / 2 + appState.offsetTop,
+            nextZoom: getNormalizedZoom(1),
+          },
+          appState,
+        ),
+      },
+      commitToHistory: false,
+    };
+  },
+  PanelComponent: ({ updateData, appState }) => (
+    <Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
+      <ToolButton
+        type="button"
+        className="reset-zoom-button"
+        title={t("buttons.resetZoom")}
+        aria-label={t("buttons.resetZoom")}
+        onClick={() => {
+          updateData(null);
+        }}
+        size="small"
+      >
+        {(appState.zoom.value * 100).toFixed(0)}%
+      </ToolButton>
+    </Tooltip>
+  ),
+  keyTest: (event) =>
+    (event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
+    (event[KEYS.CTRL_OR_CMD] || event.shiftKey),
+});
+
+const zoomValueToFitBoundsOnViewport = (
+  bounds: [number, number, number, number],
+  viewportDimensions: { width: number; height: number },
+) => {
+  const [x1, y1, x2, y2] = bounds;
+  const commonBoundsWidth = x2 - x1;
+  const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth;
+  const commonBoundsHeight = y2 - y1;
+  const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
+  const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
+  const zoomAdjustedToSteps =
+    Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
+  const clampedZoomValueToFitElements = Math.min(
+    Math.max(zoomAdjustedToSteps, ZOOM_STEP),
+    1,
+  );
+  return clampedZoomValueToFitElements as NormalizedZoomValue;
+};
+
+const zoomToFitElements = (
+  elements: readonly ExcalidrawElement[],
+  appState: Readonly<AppState>,
+  zoomToSelection: boolean,
+) => {
+  const nonDeletedElements = getNonDeletedElements(elements);
+  const selectedElements = getSelectedElements(nonDeletedElements, appState);
+
+  const commonBounds =
+    zoomToSelection && selectedElements.length > 0
+      ? getCommonBounds(selectedElements)
+      : getCommonBounds(nonDeletedElements);
+
+  const newZoom = {
+    value: zoomValueToFitBoundsOnViewport(commonBounds, {
+      width: appState.width,
+      height: appState.height,
+    }),
+  };
+
+  const [x1, y1, x2, y2] = commonBounds;
+  const centerX = (x1 + x2) / 2;
+  const centerY = (y1 + y2) / 2;
+  return {
+    appState: {
+      ...appState,
+      ...centerScrollOn({
+        scenePoint: { x: centerX, y: centerY },
+        viewportDimensions: {
+          width: appState.width,
+          height: appState.height,
+        },
+        zoom: newZoom,
+      }),
+      zoom: newZoom,
+    },
+    commitToHistory: false,
+  };
+};
+
+export const actionZoomToSelected = register({
+  name: "zoomToSelection",
+  perform: (elements, appState) => zoomToFitElements(elements, appState, true),
+  keyTest: (event) =>
+    event.code === CODES.TWO &&
+    event.shiftKey &&
+    !event.altKey &&
+    !event[KEYS.CTRL_OR_CMD],
+});
+
+export const actionZoomToFit = register({
+  name: "zoomToFit",
+  perform: (elements, appState) => zoomToFitElements(elements, appState, false),
+  keyTest: (event) =>
+    event.code === CODES.ONE &&
+    event.shiftKey &&
+    !event.altKey &&
+    !event[KEYS.CTRL_OR_CMD],
+});
+
+export const actionToggleTheme = register({
+  name: "toggleTheme",
+  perform: (_, appState, value) => {
+    return {
+      appState: {
+        ...appState,
+        theme:
+          value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
+      },
+      commitToHistory: false,
+    };
+  },
+  PanelComponent: ({ appState, updateData }) => (
+    <div style={{ marginInlineStart: "0.25rem" }}>
+      <DarkModeToggle
+        value={appState.theme}
+        onChange={(theme) => {
+          updateData(theme);
+        }}
+      />
+    </div>
+  ),
+  keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
+});

+ 124 - 0
src/actions/actionClipboard.tsx

@@ -0,0 +1,124 @@
+import { CODES, KEYS } from "../keys";
+import { register } from "./register";
+import { copyToClipboard } from "../clipboard";
+import { actionDeleteSelected } from "./actionDeleteSelected";
+import { getSelectedElements } from "../scene/selection";
+import { exportCanvas } from "../data/index";
+import { getNonDeletedElements } from "../element";
+import { t } from "../i18n";
+
+export const actionCopy = register({
+  name: "copy",
+  perform: (elements, appState, _, app) => {
+    copyToClipboard(getNonDeletedElements(elements), appState, app.files);
+
+    return {
+      commitToHistory: false,
+    };
+  },
+  contextItemLabel: "labels.copy",
+  // don't supply a shortcut since we handle this conditionally via onCopy event
+  keyTest: undefined,
+});
+
+export const actionCut = register({
+  name: "cut",
+  perform: (elements, appState, data, app) => {
+    actionCopy.perform(elements, appState, data, app);
+    return actionDeleteSelected.perform(elements, appState);
+  },
+  contextItemLabel: "labels.cut",
+  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
+});
+
+export const actionCopyAsSvg = register({
+  name: "copyAsSvg",
+  perform: async (elements, appState, _data, app) => {
+    if (!app.canvas) {
+      return {
+        commitToHistory: false,
+      };
+    }
+    const selectedElements = getSelectedElements(
+      getNonDeletedElements(elements),
+      appState,
+      true,
+    );
+    try {
+      await exportCanvas(
+        "clipboard-svg",
+        selectedElements.length
+          ? selectedElements
+          : getNonDeletedElements(elements),
+        appState,
+        app.files,
+        appState,
+      );
+      return {
+        commitToHistory: false,
+      };
+    } catch (error: any) {
+      console.error(error);
+      return {
+        appState: {
+          ...appState,
+          errorMessage: error.message,
+        },
+        commitToHistory: false,
+      };
+    }
+  },
+  contextItemLabel: "labels.copyAsSvg",
+});
+
+export const actionCopyAsPng = register({
+  name: "copyAsPng",
+  perform: async (elements, appState, _data, app) => {
+    if (!app.canvas) {
+      return {
+        commitToHistory: false,
+      };
+    }
+    const selectedElements = getSelectedElements(
+      getNonDeletedElements(elements),
+      appState,
+      true,
+    );
+    try {
+      await exportCanvas(
+        "clipboard",
+        selectedElements.length
+          ? selectedElements
+          : getNonDeletedElements(elements),
+        appState,
+        app.files,
+        appState,
+      );
+      return {
+        appState: {
+          ...appState,
+          toastMessage: t("toast.copyToClipboardAsPng", {
+            exportSelection: selectedElements.length
+              ? t("toast.selection")
+              : t("toast.canvas"),
+            exportColorScheme: appState.exportWithDarkMode
+              ? t("buttons.darkMode")
+              : t("buttons.lightMode"),
+          }),
+        },
+        commitToHistory: false,
+      };
+    } catch (error: any) {
+      console.error(error);
+      return {
+        appState: {
+          ...appState,
+          errorMessage: error.message,
+        },
+        commitToHistory: false,
+      };
+    }
+  },
+  contextItemLabel: "labels.copyAsPng",
+  keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
+});

+ 157 - 0
src/actions/actionDeleteSelected.tsx

@@ -0,0 +1,157 @@
+import { isSomeElementSelected } from "../scene";
+import { KEYS } from "../keys";
+import { ToolButton } from "../components/ToolButton";
+import { trash } from "../components/icons";
+import { t } from "../i18n";
+import { register } from "./register";
+import { getNonDeletedElements } from "../element";
+import { ExcalidrawElement } from "../element/types";
+import { AppState } from "../types";
+import { newElementWith } from "../element/mutateElement";
+import { getElementsInGroup } from "../groups";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import { fixBindingsAfterDeletion } from "../element/binding";
+import { isBoundToContainer } from "../element/typeChecks";
+
+const deleteSelectedElements = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+) => {
+  return {
+    elements: elements.map((el) => {
+      if (appState.selectedElementIds[el.id]) {
+        return newElementWith(el, { isDeleted: true });
+      }
+      if (
+        isBoundToContainer(el) &&
+        appState.selectedElementIds[el.containerId]
+      ) {
+        return newElementWith(el, { isDeleted: true });
+      }
+      return el;
+    }),
+    appState: {
+      ...appState,
+      selectedElementIds: {},
+    },
+  };
+};
+
+const handleGroupEditingState = (
+  appState: AppState,
+  elements: readonly ExcalidrawElement[],
+): AppState => {
+  if (appState.editingGroupId) {
+    const siblingElements = getElementsInGroup(
+      getNonDeletedElements(elements),
+      appState.editingGroupId!,
+    );
+    if (siblingElements.length) {
+      return {
+        ...appState,
+        selectedElementIds: { [siblingElements[0].id]: true },
+      };
+    }
+  }
+  return appState;
+};
+
+export const actionDeleteSelected = register({
+  name: "deleteSelectedElements",
+  perform: (elements, appState) => {
+    if (appState.editingLinearElement) {
+      const {
+        elementId,
+        selectedPointsIndices,
+        startBindingElement,
+        endBindingElement,
+      } = appState.editingLinearElement;
+      const element = LinearElementEditor.getElement(elementId);
+      if (!element) {
+        return false;
+      }
+      if (
+        // case: no point selected → delete whole element
+        selectedPointsIndices == null ||
+        // case: deleting last remaining point
+        element.points.length < 2
+      ) {
+        const nextElements = elements.filter((el) => el.id !== element.id);
+        const nextAppState = handleGroupEditingState(appState, nextElements);
+
+        return {
+          elements: nextElements,
+          appState: {
+            ...nextAppState,
+            editingLinearElement: null,
+          },
+          commitToHistory: false,
+        };
+      }
+
+      // We cannot do this inside `movePoint` because it is also called
+      // when deleting the uncommitted point (which hasn't caused any binding)
+      const binding = {
+        startBindingElement: selectedPointsIndices?.includes(0)
+          ? null
+          : startBindingElement,
+        endBindingElement: selectedPointsIndices?.includes(
+          element.points.length - 1,
+        )
+          ? null
+          : endBindingElement,
+      };
+
+      LinearElementEditor.deletePoints(element, selectedPointsIndices);
+
+      return {
+        elements,
+        appState: {
+          ...appState,
+          editingLinearElement: {
+            ...appState.editingLinearElement,
+            ...binding,
+            selectedPointsIndices:
+              selectedPointsIndices?.[0] > 0
+                ? [selectedPointsIndices[0] - 1]
+                : [0],
+          },
+        },
+        commitToHistory: true,
+      };
+    }
+    let { elements: nextElements, appState: nextAppState } =
+      deleteSelectedElements(elements, appState);
+    fixBindingsAfterDeletion(
+      nextElements,
+      elements.filter(({ id }) => appState.selectedElementIds[id]),
+    );
+
+    nextAppState = handleGroupEditingState(nextAppState, nextElements);
+
+    return {
+      elements: nextElements,
+      appState: {
+        ...nextAppState,
+        elementType: "selection",
+        multiElement: null,
+      },
+      commitToHistory: isSomeElementSelected(
+        getNonDeletedElements(elements),
+        appState,
+      ),
+    };
+  },
+  contextItemLabel: "labels.delete",
+  keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      type="button"
+      icon={trash}
+      title={t("labels.delete")}
+      aria-label={t("labels.delete")}
+      onClick={() => updateData(null)}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});

+ 92 - 0
src/actions/actionDistribute.tsx

@@ -0,0 +1,92 @@
+import {
+  DistributeHorizontallyIcon,
+  DistributeVerticallyIcon,
+} from "../components/icons";
+import { ToolButton } from "../components/ToolButton";
+import { distributeElements, Distribution } from "../disitrubte";
+import { getNonDeletedElements } from "../element";
+import { ExcalidrawElement } from "../element/types";
+import { t } from "../i18n";
+import { CODES } from "../keys";
+import { getSelectedElements, isSomeElementSelected } from "../scene";
+import { AppState } from "../types";
+import { arrayToMap, getShortcutKey } from "../utils";
+import { register } from "./register";
+
+const enableActionGroup = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
+
+const distributeSelectedElements = (
+  elements: readonly ExcalidrawElement[],
+  appState: Readonly<AppState>,
+  distribution: Distribution,
+) => {
+  const selectedElements = getSelectedElements(
+    getNonDeletedElements(elements),
+    appState,
+  );
+
+  const updatedElements = distributeElements(selectedElements, distribution);
+
+  const updatedElementsMap = arrayToMap(updatedElements);
+
+  return elements.map(
+    (element) => updatedElementsMap.get(element.id) || element,
+  );
+};
+
+export const distributeHorizontally = register({
+  name: "distributeHorizontally",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: distributeSelectedElements(elements, appState, {
+        space: "between",
+        axis: "x",
+      }),
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => event.altKey && event.code === CODES.H,
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<DistributeHorizontallyIcon theme={appState.theme} />}
+      onClick={() => updateData(null)}
+      title={`${t("labels.distributeHorizontally")} — ${getShortcutKey(
+        "Alt+H",
+      )}`}
+      aria-label={t("labels.distributeHorizontally")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});
+
+export const distributeVertically = register({
+  name: "distributeVertically",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: distributeSelectedElements(elements, appState, {
+        space: "between",
+        axis: "y",
+      }),
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => event.altKey && event.code === CODES.V,
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<DistributeVerticallyIcon theme={appState.theme} />}
+      onClick={() => updateData(null)}
+      title={`${t("labels.distributeVertically")} — ${getShortcutKey("Alt+V")}`}
+      aria-label={t("labels.distributeVertically")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});

+ 140 - 0
src/actions/actionDuplicateSelection.tsx

@@ -0,0 +1,140 @@
+import { KEYS } from "../keys";
+import { register } from "./register";
+import { ExcalidrawElement } from "../element/types";
+import { duplicateElement, getNonDeletedElements } from "../element";
+import { getSelectedElements, isSomeElementSelected } from "../scene";
+import { ToolButton } from "../components/ToolButton";
+import { clone } from "../components/icons";
+import { t } from "../i18n";
+import { arrayToMap, getShortcutKey } from "../utils";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import {
+  selectGroupsForSelectedElements,
+  getSelectedGroupForElement,
+  getElementsInGroup,
+} from "../groups";
+import { AppState } from "../types";
+import { fixBindingsAfterDuplication } from "../element/binding";
+import { ActionResult } from "./types";
+import { GRID_SIZE } from "../constants";
+import { bindTextToShapeAfterDuplication } from "../element/textElement";
+import { isBoundToContainer } from "../element/typeChecks";
+
+export const actionDuplicateSelection = register({
+  name: "duplicateSelection",
+  perform: (elements, appState) => {
+    // duplicate selected point(s) if editing a line
+    if (appState.editingLinearElement) {
+      const ret = LinearElementEditor.duplicateSelectedPoints(appState);
+
+      if (!ret) {
+        return false;
+      }
+
+      return {
+        elements,
+        appState: ret.appState,
+        commitToHistory: true,
+      };
+    }
+
+    return {
+      ...duplicateElements(elements, appState),
+      commitToHistory: true,
+    };
+  },
+  contextItemLabel: "labels.duplicateSelection",
+  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      type="button"
+      icon={clone}
+      title={`${t("labels.duplicateSelection")} — ${getShortcutKey(
+        "CtrlOrCmd+D",
+      )}`}
+      aria-label={t("labels.duplicateSelection")}
+      onClick={() => updateData(null)}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});
+
+const duplicateElements = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+): Partial<ActionResult> => {
+  const groupIdMap = new Map();
+  const newElements: ExcalidrawElement[] = [];
+  const oldElements: ExcalidrawElement[] = [];
+  const oldIdToDuplicatedId = new Map();
+
+  const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
+    const newElement = duplicateElement(
+      appState.editingGroupId,
+      groupIdMap,
+      element,
+      {
+        x: element.x + GRID_SIZE / 2,
+        y: element.y + GRID_SIZE / 2,
+      },
+    );
+    oldIdToDuplicatedId.set(element.id, newElement.id);
+    oldElements.push(element);
+    newElements.push(newElement);
+    return newElement;
+  };
+
+  const finalElements: ExcalidrawElement[] = [];
+
+  let index = 0;
+  const selectedElementIds = arrayToMap(
+    getSelectedElements(elements, appState, true),
+  );
+  while (index < elements.length) {
+    const element = elements[index];
+    if (selectedElementIds.get(element.id)) {
+      if (element.groupIds.length) {
+        const groupId = getSelectedGroupForElement(appState, element);
+        // if group selected, duplicate it atomically
+        if (groupId) {
+          const groupElements = getElementsInGroup(elements, groupId);
+          finalElements.push(
+            ...groupElements,
+            ...groupElements.map((element) =>
+              duplicateAndOffsetElement(element),
+            ),
+          );
+          index = index + groupElements.length;
+          continue;
+        }
+      }
+      finalElements.push(element, duplicateAndOffsetElement(element));
+    } else {
+      finalElements.push(element);
+    }
+    index++;
+  }
+  bindTextToShapeAfterDuplication(
+    finalElements,
+    oldElements,
+    oldIdToDuplicatedId,
+  );
+  fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
+
+  return {
+    elements: finalElements,
+    appState: selectGroupsForSelectedElements(
+      {
+        ...appState,
+        selectedGroupIds: {},
+        selectedElementIds: newElements.reduce((acc, element) => {
+          if (!isBoundToContainer(element)) {
+            acc[element.id] = true;
+          }
+          return acc;
+        }, {} as any),
+      },
+      getNonDeletedElements(finalElements),
+    ),
+  };
+};

+ 279 - 0
src/actions/actionExport.tsx

@@ -0,0 +1,279 @@
+import { trackEvent } from "../analytics";
+import { load, questionCircle, saveAs } from "../components/icons";
+import { ProjectName } from "../components/ProjectName";
+import { ToolButton } from "../components/ToolButton";
+import "../components/ToolIcon.scss";
+import { Tooltip } from "../components/Tooltip";
+import { DarkModeToggle } from "../components/DarkModeToggle";
+import { loadFromJSON, saveAsJSON } from "../data";
+import { resaveAsImageWithScene } from "../data/resave";
+import { t } from "../i18n";
+import { useIsMobile } from "../components/App";
+import { KEYS } from "../keys";
+import { register } from "./register";
+import { CheckboxItem } from "../components/CheckboxItem";
+import { getExportSize } from "../scene/export";
+import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
+import { getSelectedElements, isSomeElementSelected } from "../scene";
+import { getNonDeletedElements } from "../element";
+import { ActiveFile } from "../components/ActiveFile";
+import { isImageFileHandle } from "../data/blob";
+import { nativeFileSystemSupported } from "../data/filesystem";
+import { Theme } from "../element/types";
+
+export const actionChangeProjectName = register({
+  name: "changeProjectName",
+  perform: (_elements, appState, value) => {
+    trackEvent("change", "title");
+    return { appState: { ...appState, name: value }, commitToHistory: false };
+  },
+  PanelComponent: ({ appState, updateData, appProps }) => (
+    <ProjectName
+      label={t("labels.fileTitle")}
+      value={appState.name || "Unnamed"}
+      onChange={(name: string) => updateData(name)}
+      isNameEditable={
+        typeof appProps.name === "undefined" && !appState.viewModeEnabled
+      }
+    />
+  ),
+});
+
+export const actionChangeExportScale = register({
+  name: "changeExportScale",
+  perform: (_elements, appState, value) => {
+    return {
+      appState: { ...appState, exportScale: value },
+      commitToHistory: false,
+    };
+  },
+  PanelComponent: ({ elements: allElements, appState, updateData }) => {
+    const elements = getNonDeletedElements(allElements);
+    const exportSelected = isSomeElementSelected(elements, appState);
+    const exportedElements = exportSelected
+      ? getSelectedElements(elements, appState)
+      : elements;
+
+    return (
+      <>
+        {EXPORT_SCALES.map((s) => {
+          const [width, height] = getExportSize(
+            exportedElements,
+            DEFAULT_EXPORT_PADDING,
+            s,
+          );
+
+          const scaleButtonTitle = `${t(
+            "buttons.scale",
+          )} ${s}x (${width}x${height})`;
+
+          return (
+            <ToolButton
+              key={s}
+              size="small"
+              type="radio"
+              icon={`${s}x`}
+              name="export-canvas-scale"
+              title={scaleButtonTitle}
+              aria-label={scaleButtonTitle}
+              id="export-canvas-scale"
+              checked={s === appState.exportScale}
+              onChange={() => updateData(s)}
+            />
+          );
+        })}
+      </>
+    );
+  },
+});
+
+export const actionChangeExportBackground = register({
+  name: "changeExportBackground",
+  perform: (_elements, appState, value) => {
+    return {
+      appState: { ...appState, exportBackground: value },
+      commitToHistory: false,
+    };
+  },
+  PanelComponent: ({ appState, updateData }) => (
+    <CheckboxItem
+      checked={appState.exportBackground}
+      onChange={(checked) => updateData(checked)}
+    >
+      {t("labels.withBackground")}
+    </CheckboxItem>
+  ),
+});
+
+export const actionChangeExportEmbedScene = register({
+  name: "changeExportEmbedScene",
+  perform: (_elements, appState, value) => {
+    return {
+      appState: { ...appState, exportEmbedScene: value },
+      commitToHistory: false,
+    };
+  },
+  PanelComponent: ({ appState, updateData }) => (
+    <CheckboxItem
+      checked={appState.exportEmbedScene}
+      onChange={(checked) => updateData(checked)}
+    >
+      {t("labels.exportEmbedScene")}
+      <Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
+        <div className="excalidraw-tooltip-icon">{questionCircle}</div>
+      </Tooltip>
+    </CheckboxItem>
+  ),
+});
+
+export const actionSaveToActiveFile = register({
+  name: "saveToActiveFile",
+  perform: async (elements, appState, value, app) => {
+    const fileHandleExists = !!appState.fileHandle;
+
+    try {
+      const { fileHandle } = isImageFileHandle(appState.fileHandle)
+        ? await resaveAsImageWithScene(elements, appState, app.files)
+        : await saveAsJSON(elements, appState, app.files);
+
+      return {
+        commitToHistory: false,
+        appState: {
+          ...appState,
+          fileHandle,
+          toastMessage: fileHandleExists
+            ? fileHandle?.name
+              ? t("toast.fileSavedToFilename").replace(
+                  "{filename}",
+                  `"${fileHandle.name}"`,
+                )
+              : t("toast.fileSaved")
+            : null,
+        },
+      };
+    } catch (error: any) {
+      if (error?.name !== "AbortError") {
+        console.error(error);
+      } else {
+        console.warn(error);
+      }
+      return { commitToHistory: false };
+    }
+  },
+  keyTest: (event) =>
+    event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
+  PanelComponent: ({ updateData, appState }) => (
+    <ActiveFile
+      onSave={() => updateData(null)}
+      fileName={appState.fileHandle?.name}
+    />
+  ),
+});
+
+export const actionSaveFileToDisk = register({
+  name: "saveFileToDisk",
+  perform: async (elements, appState, value, app) => {
+    try {
+      const { fileHandle } = await saveAsJSON(
+        elements,
+        {
+          ...appState,
+          fileHandle: null,
+        },
+        app.files,
+      );
+      return { commitToHistory: false, appState: { ...appState, fileHandle } };
+    } catch (error: any) {
+      if (error?.name !== "AbortError") {
+        console.error(error);
+      } else {
+        console.warn(error);
+      }
+      return { commitToHistory: false };
+    }
+  },
+  keyTest: (event) =>
+    event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
+  PanelComponent: ({ updateData }) => (
+    <ToolButton
+      type="button"
+      icon={saveAs}
+      title={t("buttons.saveAs")}
+      aria-label={t("buttons.saveAs")}
+      showAriaLabel={useIsMobile()}
+      hidden={!nativeFileSystemSupported}
+      onClick={() => updateData(null)}
+      data-testid="save-as-button"
+    />
+  ),
+});
+
+export const actionLoadScene = register({
+  name: "loadScene",
+  perform: async (elements, appState, _, app) => {
+    try {
+      const {
+        elements: loadedElements,
+        appState: loadedAppState,
+        files,
+      } = await loadFromJSON(appState, elements);
+      return {
+        elements: loadedElements,
+        appState: loadedAppState,
+        files,
+        commitToHistory: true,
+      };
+    } catch (error: any) {
+      if (error?.name === "AbortError") {
+        console.warn(error);
+        return false;
+      }
+      return {
+        elements,
+        appState: { ...appState, errorMessage: error.message },
+        files: app.files,
+        commitToHistory: false,
+      };
+    }
+  },
+  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
+  PanelComponent: ({ updateData, appState }) => (
+    <ToolButton
+      type="button"
+      icon={load}
+      title={t("buttons.load")}
+      aria-label={t("buttons.load")}
+      showAriaLabel={useIsMobile()}
+      onClick={updateData}
+      data-testid="load-button"
+    />
+  ),
+});
+
+export const actionExportWithDarkMode = register({
+  name: "exportWithDarkMode",
+  perform: (_elements, appState, value) => {
+    return {
+      appState: { ...appState, exportWithDarkMode: value },
+      commitToHistory: false,
+    };
+  },
+  PanelComponent: ({ appState, updateData }) => (
+    <div
+      style={{
+        display: "flex",
+        justifyContent: "flex-end",
+        marginTop: "-45px",
+        marginBottom: "10px",
+      }}
+    >
+      <DarkModeToggle
+        value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
+        onChange={(theme: Theme) => {
+          updateData(theme === THEME.DARK);
+        }}
+        title={t("labels.toggleExportColorScheme")}
+      />
+    </div>
+  ),
+});

+ 178 - 0
src/actions/actionFinalize.tsx

@@ -0,0 +1,178 @@
+import { KEYS } from "../keys";
+import { isInvisiblySmallElement } from "../element";
+import { resetCursor } from "../utils";
+import { ToolButton } from "../components/ToolButton";
+import { done } from "../components/icons";
+import { t } from "../i18n";
+import { register } from "./register";
+import { mutateElement } from "../element/mutateElement";
+import { isPathALoop } from "../math";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import Scene from "../scene/Scene";
+import {
+  maybeBindLinearElement,
+  bindOrUnbindLinearElement,
+} from "../element/binding";
+import { isBindingElement } from "../element/typeChecks";
+
+export const actionFinalize = register({
+  name: "finalize",
+  perform: (elements, appState, _, { canvas, focusContainer }) => {
+    if (appState.editingLinearElement) {
+      const { elementId, startBindingElement, endBindingElement } =
+        appState.editingLinearElement;
+      const element = LinearElementEditor.getElement(elementId);
+
+      if (element) {
+        if (isBindingElement(element)) {
+          bindOrUnbindLinearElement(
+            element,
+            startBindingElement,
+            endBindingElement,
+          );
+        }
+        return {
+          elements:
+            element.points.length < 2 || isInvisiblySmallElement(element)
+              ? elements.filter((el) => el.id !== element.id)
+              : undefined,
+          appState: {
+            ...appState,
+            editingLinearElement: null,
+          },
+          commitToHistory: true,
+        };
+      }
+    }
+
+    let newElements = elements;
+
+    if (appState.pendingImageElement) {
+      mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
+    }
+
+    if (window.document.activeElement instanceof HTMLElement) {
+      focusContainer();
+    }
+
+    const multiPointElement = appState.multiElement
+      ? appState.multiElement
+      : appState.editingElement?.type === "freedraw"
+      ? appState.editingElement
+      : null;
+
+    if (multiPointElement) {
+      // pen and mouse have hover
+      if (
+        multiPointElement.type !== "freedraw" &&
+        appState.lastPointerDownWith !== "touch"
+      ) {
+        const { points, lastCommittedPoint } = multiPointElement;
+        if (
+          !lastCommittedPoint ||
+          points[points.length - 1] !== lastCommittedPoint
+        ) {
+          mutateElement(multiPointElement, {
+            points: multiPointElement.points.slice(0, -1),
+          });
+        }
+      }
+      if (isInvisiblySmallElement(multiPointElement)) {
+        newElements = newElements.slice(0, -1);
+      }
+
+      // If the multi point line closes the loop,
+      // set the last point to first point.
+      // This ensures that loop remains closed at different scales.
+      const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
+      if (
+        multiPointElement.type === "line" ||
+        multiPointElement.type === "freedraw"
+      ) {
+        if (isLoop) {
+          const linePoints = multiPointElement.points;
+          const firstPoint = linePoints[0];
+          mutateElement(multiPointElement, {
+            points: linePoints.map((point, index) =>
+              index === linePoints.length - 1
+                ? ([firstPoint[0], firstPoint[1]] as const)
+                : point,
+            ),
+          });
+        }
+      }
+
+      if (
+        isBindingElement(multiPointElement) &&
+        !isLoop &&
+        multiPointElement.points.length > 1
+      ) {
+        const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+          multiPointElement,
+          -1,
+        );
+        maybeBindLinearElement(
+          multiPointElement,
+          appState,
+          Scene.getScene(multiPointElement)!,
+          { x, y },
+        );
+      }
+
+      if (!appState.elementLocked && appState.elementType !== "freedraw") {
+        appState.selectedElementIds[multiPointElement.id] = true;
+      }
+    }
+
+    if (
+      (!appState.elementLocked && appState.elementType !== "freedraw") ||
+      !multiPointElement
+    ) {
+      resetCursor(canvas);
+    }
+
+    return {
+      elements: newElements,
+      appState: {
+        ...appState,
+        elementType:
+          (appState.elementLocked || appState.elementType === "freedraw") &&
+          multiPointElement
+            ? appState.elementType
+            : "selection",
+        draggingElement: null,
+        multiElement: null,
+        editingElement: null,
+        startBoundElement: null,
+        suggestedBindings: [],
+        selectedElementIds:
+          multiPointElement &&
+          !appState.elementLocked &&
+          appState.elementType !== "freedraw"
+            ? {
+                ...appState.selectedElementIds,
+                [multiPointElement.id]: true,
+              }
+            : appState.selectedElementIds,
+        pendingImageElement: null,
+      },
+      commitToHistory: appState.elementType === "freedraw",
+    };
+  },
+  keyTest: (event, appState) =>
+    (event.key === KEYS.ESCAPE &&
+      (appState.editingLinearElement !== null ||
+        (!appState.draggingElement && appState.multiElement === null))) ||
+    ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
+      appState.multiElement !== null),
+  PanelComponent: ({ appState, updateData }) => (
+    <ToolButton
+      type="button"
+      icon={done}
+      title={t("buttons.done")}
+      aria-label={t("buttons.done")}
+      onClick={updateData}
+      visible={appState.multiElement != null}
+    />
+  ),
+});

+ 209 - 0
src/actions/actionFlip.ts

@@ -0,0 +1,209 @@
+import { register } from "./register";
+import { getSelectedElements } from "../scene";
+import { getNonDeletedElements } from "../element";
+import { mutateElement } from "../element/mutateElement";
+import { ExcalidrawElement, NonDeleted } from "../element/types";
+import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
+import { AppState } from "../types";
+import { getTransformHandles } from "../element/transformHandles";
+import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
+import { updateBoundElements } from "../element/binding";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import { arrayToMap } from "../utils";
+
+const enableActionFlipHorizontal = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+) => {
+  const eligibleElements = getSelectedElements(
+    getNonDeletedElements(elements),
+    appState,
+  );
+  return eligibleElements.length === 1 && eligibleElements[0].type !== "text";
+};
+
+const enableActionFlipVertical = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+) => {
+  const eligibleElements = getSelectedElements(
+    getNonDeletedElements(elements),
+    appState,
+  );
+  return eligibleElements.length === 1;
+};
+
+export const actionFlipHorizontal = register({
+  name: "flipHorizontal",
+  perform: (elements, appState) => {
+    return {
+      elements: flipSelectedElements(elements, appState, "horizontal"),
+      appState,
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => event.shiftKey && event.code === "KeyH",
+  contextItemLabel: "labels.flipHorizontal",
+  contextItemPredicate: (elements, appState) =>
+    enableActionFlipHorizontal(elements, appState),
+});
+
+export const actionFlipVertical = register({
+  name: "flipVertical",
+  perform: (elements, appState) => {
+    return {
+      elements: flipSelectedElements(elements, appState, "vertical"),
+      appState,
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => event.shiftKey && event.code === "KeyV",
+  contextItemLabel: "labels.flipVertical",
+  contextItemPredicate: (elements, appState) =>
+    enableActionFlipVertical(elements, appState),
+});
+
+const flipSelectedElements = (
+  elements: readonly ExcalidrawElement[],
+  appState: Readonly<AppState>,
+  flipDirection: "horizontal" | "vertical",
+) => {
+  const selectedElements = getSelectedElements(
+    getNonDeletedElements(elements),
+    appState,
+  );
+
+  // remove once we allow for groups of elements to be flipped
+  if (selectedElements.length > 1) {
+    return elements;
+  }
+
+  const updatedElements = flipElements(
+    selectedElements,
+    appState,
+    flipDirection,
+  );
+
+  const updatedElementsMap = arrayToMap(updatedElements);
+
+  return elements.map(
+    (element) => updatedElementsMap.get(element.id) || element,
+  );
+};
+
+const flipElements = (
+  elements: NonDeleted<ExcalidrawElement>[],
+  appState: AppState,
+  flipDirection: "horizontal" | "vertical",
+): ExcalidrawElement[] => {
+  elements.forEach((element) => {
+    flipElement(element, appState);
+    // If vertical flip, rotate an extra 180
+    if (flipDirection === "vertical") {
+      rotateElement(element, Math.PI);
+    }
+  });
+  return elements;
+};
+
+const flipElement = (
+  element: NonDeleted<ExcalidrawElement>,
+  appState: AppState,
+) => {
+  const originalX = element.x;
+  const originalY = element.y;
+  const width = element.width;
+  const height = element.height;
+  const originalAngle = normalizeAngle(element.angle);
+
+  let finalOffsetX = 0;
+  if (isLinearElement(element) || isFreeDrawElement(element)) {
+    finalOffsetX =
+      element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
+      element.width;
+  }
+
+  // Rotate back to zero, if necessary
+  mutateElement(element, {
+    angle: normalizeAngle(0),
+  });
+  // Flip unrotated by pulling TransformHandle to opposite side
+  const transformHandles = getTransformHandles(element, appState.zoom);
+  let usingNWHandle = true;
+  let newNCoordsX = 0;
+  let nHandle = transformHandles.nw;
+  if (!nHandle) {
+    // Use ne handle instead
+    usingNWHandle = false;
+    nHandle = transformHandles.ne;
+    if (!nHandle) {
+      mutateElement(element, {
+        angle: originalAngle,
+      });
+      return;
+    }
+  }
+
+  if (isLinearElement(element)) {
+    for (let index = 1; index < element.points.length; index++) {
+      LinearElementEditor.movePoints(element, [
+        { index, point: [-element.points[index][0], element.points[index][1]] },
+      ]);
+    }
+    LinearElementEditor.normalizePoints(element);
+  } else {
+    // calculate new x-coord for transformation
+    newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
+    resizeSingleElement(
+      element,
+      true,
+      element,
+      usingNWHandle ? "nw" : "ne",
+      false,
+      newNCoordsX,
+      nHandle[1],
+    );
+    // fix the size to account for handle sizes
+    mutateElement(element, {
+      width,
+      height,
+    });
+  }
+
+  // Rotate by (360 degrees - original angle)
+  let angle = normalizeAngle(2 * Math.PI - originalAngle);
+  if (angle < 0) {
+    // check, probably unnecessary
+    angle = normalizeAngle(angle + 2 * Math.PI);
+  }
+  mutateElement(element, {
+    angle,
+  });
+
+  // Move back to original spot to appear "flipped in place"
+  mutateElement(element, {
+    x: originalX + finalOffsetX,
+    y: originalY,
+  });
+
+  updateBoundElements(element);
+};
+
+const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
+  const originalX = element.x;
+  const originalY = element.y;
+  let angle = normalizeAngle(element.angle + rotationAngle);
+  if (angle < 0) {
+    // check, probably unnecessary
+    angle = normalizeAngle(2 * Math.PI + angle);
+  }
+  mutateElement(element, {
+    angle,
+  });
+
+  // Move back to original spot
+  mutateElement(element, {
+    x: originalX,
+    y: originalY,
+  });
+};

Някои файлове не бяха показани, защото твърде много файлове са промени