Compare commits
1 Commits
updatescen
...
upload-ima
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0cb67a0bc9 |
@@ -1,10 +1,7 @@
|
|||||||
*
|
*
|
||||||
!.env
|
|
||||||
!.eslintrc.json
|
|
||||||
!.npmrc
|
|
||||||
!.prettierrc
|
|
||||||
!package.json
|
|
||||||
!public/
|
!public/
|
||||||
!src/
|
!src/
|
||||||
|
!.npmrc
|
||||||
|
!package-lock.json
|
||||||
|
!package.json
|
||||||
!tsconfig.json
|
!tsconfig.json
|
||||||
!yarn.lock
|
|
||||||
|
5
.env
@@ -1,5 +0,0 @@
|
|||||||
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
|
|
||||||
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_SOCKET_SERVER_URL=https://portal.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"}'
|
|
@@ -1 +0,0 @@
|
|||||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
|
@@ -3,5 +3,3 @@ build/
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
.vscode/
|
.vscode/
|
||||||
firebase/
|
firebase/
|
||||||
dist/
|
|
||||||
public/workbox
|
|
||||||
|
@@ -1,6 +1,30 @@
|
|||||||
{
|
{
|
||||||
"extends": ["@excalidraw/eslint-config", "react-app"],
|
"extends": ["prettier", "react-app"],
|
||||||
|
"plugins": ["prettier"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"import/no-anonymous-default-export": "off"
|
"curly": "warn",
|
||||||
|
"no-console": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"allow": ["warn", "error", "info"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-else-return": "warn",
|
||||||
|
"no-useless-return": "warn",
|
||||||
|
"prefer-const": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"destructuring": "all"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prefer-template": "warn",
|
||||||
|
"prettier/prettier": "warn",
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"selector": "JSXText[value=/\\w/]",
|
||||||
|
"message": "Use 't(...)' instead of literal text in JSX"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1
.gitattributes
vendored
@@ -1 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
6
.github/assets/crowdin.svg
vendored
@@ -1,6 +0,0 @@
|
|||||||
<svg height="50" viewBox="0 0 257 50" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2">
|
|
||||||
<path fill="#fff" d="M-7.977-9.253h288.95v78.13H-7.977z" />
|
|
||||||
<path d="M67.626 32.315c-1.34 0-2.207 0-2.207-1.025 0-.236.079-.551.236-.946l4.02-8.907h12.929c1.34 0 2.128-.08 2.128.946 0 .315-.078.63-.236.946l-.788 1.734h5.439l1.104-2.444c.157-.394.157-.71.157-1.025 0-2.207-2.365-3.31-4.257-3.31H65.655l-5.754 12.691c-.158.394-.158.71-.158 1.025 0 2.365 1.97 3.547 4.73 3.547h20.26l1.26-3.232H67.627zm42.727-14.11H95.059l-6.937 17.342h5.518l5.519-14.032h8.435c1.34 0 2.05-.157 2.05.868 0 .315-.08.63-.237.946l-.789 1.734h5.518l1.104-2.444c.158-.394.158-.71.158-1.025 0-1.025-.552-1.892-1.734-2.522-.946-.473-2.208-.868-3.311-.868zm30.35 0h-21.285l-5.754 12.691c-.158.316-.158.63-.158 1.025 0 1.97 1.419 3.547 3.232 3.547h21.52l5.834-13.007c.158-.394.158-.71.158-1.024 0-2.05-1.734-3.233-3.547-3.233zm-6.701 14.19h-12.85c-1.34 0-1.97-.159-1.97-1.183 0-.316.079-.631.236-.946l4.178-8.908h12.929c1.26 0 1.891-.08 1.891.946 0 .315-.078.63-.236 1.025l-4.178 9.065zm13.953 3.152h28.695l7.41-17.264h-5.676l-6.149 14.032h-9.223l6.149-14.11h-5.676l-6.386 14.031h-6.306c-1.34 0-2.05-.157-2.05-1.182 0-.315.08-.63.237-.946l5.282-11.982h-5.519l-5.518 12.455c-1.103 3.39 2.207 4.966 4.73 4.966zm67.874-23.649l-5.913 1.577-1.97 4.73h-14.584c-3.548 0-6.7 1.576-8.278 4.73l-3.941 9.46c-.788 1.576.63 3.152 3.31 3.152h21.128l10.248-23.649zm-27.591 20.496c-1.183 0-1.735-.788-1.577-1.577l3.469-7.567c.788-1.813 2.68-1.892 4.414-1.892h11.825l-4.73 11.036h-13.401zm26.802 3.153l7.49-17.737-6.307 1.183-7.095 16.554h5.912zm8.435-19.944l1.656-3.705-6.228 1.261-1.577 3.705 6.15-1.26zm22.23 2.601h-20.417l-7.094 17.343h5.518l5.518-14.19h13.48c1.34 0 2.05-.078 2.05 1.026 0 .315-.08.63-.237.946l-5.518 12.297h5.518l5.834-13.007c.157-.315.157-.63.157-1.025 0-1.025-.552-1.892-1.734-2.522-.867-.473-1.892-.868-3.074-.868zm-192.82.868c-8.672-1.025-16.476.71-17.58 6.148 0 .237-.157 1.262-.157 1.42l1.419.157v2.207l-1.34-.157c.551 5.597 3.626 7.252 6.858 7.331h.236c1.42.079 2.917-.237 4.178-.788.08 0 .08-.08.08-.08v-.157c0-.079-.08-.079-.08-.157-.078 0-.078-.08-.157-.08-2.996.395-5.755-2.049-5.755-7.015 0-6.228 4.888-8.514 12.298-8.514.236.158.315-.237 0-.315zM36.803 30.344c.788 0 1.498.158 2.207.237.237 1.655 1.025 3.232 2.208 4.336-1.183-.158-2.208-.71-3.075-1.498a6.051 6.051 0 01-1.34-3.075zm2.68-5.439c0 .237-.157.552-.236.946h-1.025c-.552 0-1.025-.079-1.576-.158v-.157c.63-3.39 4.02-4.73 7.252-5.36a7.997 7.997 0 00-2.76 1.812c-.787.868-1.34 1.813-1.655 2.917z" fill="#2e3340" fill-rule="nonzero" />
|
|
||||||
<path d="M56.274 14.105c-6.543-1.813-34.055-4.02-34.055 11.273.946.158 1.577.315 2.05.394-.079 1.183 0 2.444 0 3.626l-2.444-.394c0 8.83 6.464 11.667 11.588 11.667.868 0 1.656-.078 2.523-.157 2.128-.237 4.178-.867 5.991-1.892.079 0 .079-.08.079-.08v-.157c0-.079-.079-.079-.079-.157-.079 0-.079-.08-.157-.08-4.336.868-10.17-.315-10.17-10.563 0-13.637 19.156-12.77 24.753-13.007.08 0 .08-.079.08-.079v-.157c0-.08 0-.08-.08-.158 0-.079 0-.079-.079-.079zM33.414 39.41a9.362 9.362 0 01-6.78-2.286c-1.892-1.656-3.074-3.942-3.31-6.385 1.655.236 3.704.394 5.438.473a9.43 9.43 0 001.577 4.808c.946 1.42 2.207 2.602 3.705 3.39h-.63zM28.92 24.984l-2.601-.237-2.602-.315c0-7.962 12.77-11.036 18.683-10.484-5.912 1.34-13.086 4.099-13.48 11.036z" fill="#2e3340" fill-rule="nonzero" />
|
|
||||||
<path d="M59.664 9.533c-7.962-2.68-17.027-4.02-25.462-3.941-12.22 0-27.67 3.626-28.064 16.081l3.31.788c-.393 1.577-.393 4.81-.393 4.81s-1.892-.553-2.917-.79c0 14.821 8.671 18.526 17.027 18.526 3.39 0 6.701-.552 9.854-1.734.08 0 .08-.08.08-.08v-.157c0-.079-.08-.079-.08-.157h-.157c-2.602 0-4.651.867-8.75-2.05-7.963-5.597-7.017-20.102 2.128-26.408 9.46-6.701 29.798-4.573 33.267-4.415h.157s.079 0 .079-.079v-.236l-.079-.158zm-36.42 34.292c-9.932 0-14.978-5.36-15.45-15.609 2.68.71 5.202 1.34 7.961 1.734-.157 4.02 1.262 7.962 4.02 11.037a12.488 12.488 0 005.046 2.916l-1.577-.078zM45.632 7.956c-12.06 0-26.014 1.42-28.773 14.584 0 0-7.41-1.182-9.066-1.576C9.843 4.409 38.38 5.67 49.89 7.956h-4.257z" fill="#2e3340" fill-rule="nonzero" />
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 4.1 KiB |
BIN
.github/assets/logo.png
vendored
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 39 KiB |
9
.github/assets/sentry.svg
vendored
@@ -1,9 +0,0 @@
|
|||||||
<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>
|
|
Before Width: | Height: | Size: 1.7 KiB |
3
.github/assets/vercel.svg
vendored
@@ -1,3 +0,0 @@
|
|||||||
<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>
|
|
Before Width: | Height: | Size: 1.4 KiB |
34
.github/dependabot.yml
vendored
@@ -1,34 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
reviewers:
|
|
||||||
- lipis
|
|
||||||
assignees:
|
|
||||||
- lipis
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /src/packages/excalidraw/
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
reviewers:
|
|
||||||
- ad1992
|
|
||||||
assignees:
|
|
||||||
- ad1992
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /src/packages/utils/
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
reviewers:
|
|
||||||
- ad1992
|
|
||||||
assignees:
|
|
||||||
- ad1992
|
|
13
.github/workflows/build-docker.yml
vendored
@@ -1,13 +0,0 @@
|
|||||||
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
.github/workflows/build-packages.yml
vendored
@@ -1,29 +0,0 @@
|
|||||||
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
.github/workflows/cancel.yml
vendored
@@ -1,17 +0,0 @@
|
|||||||
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 }}
|
|
24
.github/workflows/lint.yml
vendored
@@ -1,22 +1,28 @@
|
|||||||
name: Lint
|
name: Lint
|
||||||
|
|
||||||
on: pull_request
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v1
|
||||||
|
|
||||||
- name: Setup Node.js 14.x
|
- name: Setup Node.js 12.x
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 14.x
|
node-version: 12.x
|
||||||
|
|
||||||
- name: Install and lint
|
- name: Install and lint
|
||||||
run: |
|
run: |
|
||||||
yarn --frozen-lockfile
|
npm ci
|
||||||
yarn test:other
|
npm run test:other
|
||||||
yarn test:code
|
npm run test:code
|
||||||
yarn test:typecheck
|
npm run test:typecheck
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
47
.github/workflows/locales-coverage.yml
vendored
@@ -1,47 +0,0 @@
|
|||||||
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
.github/workflows/publish-docker.yml
vendored
@@ -1,20 +0,0 @@
|
|||||||
name: Publish Docker
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish-docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- uses: docker/build-push-action@v1
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
repository: excalidraw/excalidraw
|
|
||||||
tag_with_ref: true
|
|
||||||
tag_with_sha: true
|
|
16
.github/workflows/semantic-pr-title.yml
vendored
@@ -1,16 +0,0 @@
|
|||||||
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 }}
|
|
20
.github/workflows/sentry-production.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: New Sentry production release
|
name: New Sentry Production Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,23 +6,27 @@ on:
|
|||||||
- master
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sentry:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v1.0.0
|
||||||
- name: Setup Node.js 14.x
|
|
||||||
uses: actions/setup-node@v2
|
- name: Setup Node.js 12.x
|
||||||
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 14.x
|
node-version: 12.x
|
||||||
|
|
||||||
- name: Install and build
|
- name: Install and build
|
||||||
run: |
|
run: |
|
||||||
yarn --frozen-lockfile
|
npm ci
|
||||||
yarn build:app
|
npm run build:app
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
|
|
||||||
- name: Install Sentry
|
- name: Install Sentry
|
||||||
run: |
|
run: |
|
||||||
curl -sL https://sentry.io/get-cli/ | bash
|
curl -sL https://sentry.io/get-cli/ | bash
|
||||||
|
|
||||||
- name: Create new Sentry release
|
- name: Create new Sentry release
|
||||||
run: |
|
run: |
|
||||||
export SENTRY_RELEASE=$(sentry-cli releases propose-version)
|
export SENTRY_RELEASE=$(sentry-cli releases propose-version)
|
||||||
|
23
.github/workflows/test.yml
vendored
@@ -1,17 +1,26 @@
|
|||||||
name: Tests
|
name: Tests
|
||||||
|
|
||||||
on: pull_request
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v1
|
||||||
- name: Setup Node.js 14.x
|
|
||||||
uses: actions/setup-node@v2
|
- name: Setup Node.js 12.x
|
||||||
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 14.x
|
node-version: 12.x
|
||||||
|
|
||||||
- name: Install and test
|
- name: Install and test
|
||||||
run: |
|
run: |
|
||||||
yarn --frozen-lockfile
|
npm ci
|
||||||
yarn test:app
|
npm run test:app
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
21
.gitignore
vendored
@@ -1,22 +1,15 @@
|
|||||||
.DS_Store
|
|
||||||
.env.development.local
|
|
||||||
.env.local
|
|
||||||
.env.production.local
|
|
||||||
.env.test.local
|
|
||||||
.envrc
|
|
||||||
.eslintcache
|
|
||||||
.idea
|
|
||||||
.vercel
|
|
||||||
.vscode
|
|
||||||
*.log
|
*.log
|
||||||
*.tgz
|
.DS_Store
|
||||||
|
.envrc
|
||||||
|
.now
|
||||||
|
.vscode
|
||||||
build
|
build
|
||||||
dist
|
firebase/
|
||||||
firebase
|
|
||||||
logs
|
logs
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
package-lock.json
|
|
||||||
static
|
static
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
yarn.lock
|
||||||
|
.idea
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
const { CLIEngine } = require("eslint");
|
const { CLIEngine } = require("eslint");
|
||||||
|
|
||||||
// see https://github.com/okonet/lint-staged#how-can-i-ignore-files-from-eslintignore-
|
// see https://github.com/okonet/lint-staged#how-can-i-ignore-files-from-eslintignore-
|
||||||
// for explanation
|
// for explanation
|
||||||
const cli = new CLIEngine({});
|
const cli = new CLIEngine({});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
3
.prettierrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
@@ -1,3 +0,0 @@
|
|||||||
## 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
|
|
@@ -5,10 +5,11 @@
|
|||||||
### Option 1 - Manual
|
### Option 1 - Manual
|
||||||
|
|
||||||
1. Fork and clone the repo
|
1. Fork and clone the repo
|
||||||
1. Run `yarn` to install dependencies
|
1. Run `npm install` to install dependencies
|
||||||
1. Create a branch for your PR with `git checkout -b your-branch-name`
|
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:
|
> To keep `master` branch pointing to remote repository and make
|
||||||
|
> pull requests from branches on your fork. To do this, run:
|
||||||
>
|
>
|
||||||
> ```sh
|
> ```sh
|
||||||
> git remote add upstream https://github.com/excalidraw/excalidraw.git
|
> git remote add upstream https://github.com/excalidraw/excalidraw.git
|
||||||
@@ -19,45 +20,8 @@
|
|||||||
### Option 2 - CodeSandbox
|
### Option 2 - CodeSandbox
|
||||||
|
|
||||||
1. Go to https://codesandbox.io/s/github/excalidraw/excalidraw
|
1. Go to https://codesandbox.io/s/github/excalidraw/excalidraw
|
||||||
1. Connect your GitHub account
|
1. Connect your Github account
|
||||||
1. Go to Git tab on left side
|
1. Go to Git tab on left side
|
||||||
1. Tap on `Fork Sandbox`
|
1. Tap on `Fork Sandbox`
|
||||||
1. Write your code
|
1. Write your code
|
||||||
1. Commit and PR automatically
|
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.
|
|
||||||
|
13
Dockerfile
@@ -1,17 +1,16 @@
|
|||||||
FROM node:14-alpine AS build
|
FROM node:14-alpine AS build
|
||||||
|
|
||||||
WORKDIR /opt/node_app
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json package-lock.json ./
|
||||||
RUN yarn --ignore-optional
|
RUN npm install
|
||||||
|
|
||||||
ARG NODE_ENV=production
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN yarn build:app:docker
|
RUN npm run build:app
|
||||||
|
|
||||||
FROM nginx:1.17-alpine
|
FROM nginx:1.17-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
COPY --from=build /usr/src/app/build /usr/share/nginx/html
|
||||||
|
|
||||||
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1
|
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1
|
||||||
|
161
README.md
@@ -1,101 +1,36 @@
|
|||||||
<div align="center" style="display:flex;flex-direction:column;">
|
<div align="center" style="display:flex;flex-direction:column;">
|
||||||
<a href="https://excalidraw.com">
|
<a href="https://excalidraw.com">
|
||||||
<img width="540" src="./public/og-image-sm.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
<img src="./public/og-image.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
||||||
</a>
|
</a>
|
||||||
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.<br>Collaborative and end-to-end encrypted.</h3>
|
<h3>Excalidraw is a whiteboard tool that lets you easily sketch diagrams with a hand-drawn feel.</h3>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://twitter.com/Excalidraw">
|
<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">
|
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
|
||||||
</a>
|
</a>
|
||||||
<a target="_blank" href="https://crowdin.com/project/excalidraw">
|
<a title="Crowdin" target="_blank" href="https://crowdin.com/project/excalidraw">
|
||||||
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p>Ask questions or hang out on our <a target="_blank" href="https://discord.gg/UexuTaE">discord.gg/UexuTaE</a>.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Try it now
|
## Try it now
|
||||||
|
|
||||||
Go to [excalidraw.com](https://excalidraw.com) to start sketching.
|
Go to 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/).
|
Read our [blog](https://blog.excalidraw.com) and follow the [guides](https://howto.excalidraw.com) to learn more about Excalidraw and how to use it effectively.
|
||||||
|
|
||||||
## Supporting Excalidraw
|
## Run the code
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
[](https://vercel.com) [](https://sentry.io) [](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.
|
|
||||||
|
|
||||||
## 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
|
### Code Sandbox
|
||||||
|
|
||||||
- Go to https://codesandbox.io/s/github/excalidraw/excalidraw
|
- Go to https://codesandbox.io/s/github/excalidraw/excalidraw
|
||||||
- You may need to sign in with GitHub and reload the page
|
- You may need to sign in with Github and reload the page
|
||||||
- You can start coding instantly, and even send PRs from there!
|
- You can start coding instantly, and even send PRs from there!
|
||||||
|
|
||||||
### Local Installation
|
### Local Installation
|
||||||
|
|
||||||
These instructions will get you a copy of the project up and running on your local machine for development and testing purposes.
|
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)
|
|
||||||
- [Git](https://git-scm.com/downloads)
|
|
||||||
|
|
||||||
#### Clone the repo
|
#### Clone the repo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -104,47 +39,87 @@ git clone https://github.com/excalidraw/excalidraw.git
|
|||||||
|
|
||||||
#### Commands
|
#### Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------------------ | --------------------------------- |
|
| --------------------- | --------------------------------- |
|
||||||
| `yarn` | Install the dependencies |
|
| `npm install` | Install the dependencies |
|
||||||
| `yarn start` | Run the project |
|
| `npm start` | Run the project |
|
||||||
| `yarn fix` | Reformat all files with Prettier |
|
| `npm run fix` | Reformat all files with Prettier |
|
||||||
| `yarn test` | Run tests |
|
| `npm test` | Run tests |
|
||||||
| `yarn test:update` | Update test snapshots |
|
| `npm run test:update` | Update test snapshots |
|
||||||
| `yarn test:code` | Test for formatting with Prettier |
|
| `npm run test:code` | Test for formatting with Prettier |
|
||||||
|
|
||||||
|
### Docker Installation
|
||||||
|
|
||||||
|
A production-ready version for deploying to e.g. Kubernetes or OpenShift can be built using Docker.
|
||||||
|
|
||||||
#### Docker Compose
|
#### Docker Compose
|
||||||
|
|
||||||
You can use docker-compose to work on Excalidraw locally if you don't want to setup a Node.js env.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker-compose up --build -d
|
docker-compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Self-hosting
|
#### Native Docker
|
||||||
|
|
||||||
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
|
```sh
|
||||||
docker build -t excalidraw/excalidraw .
|
docker build -t excalidraw/excalidraw .
|
||||||
docker run --rm -dit --name excalidraw -p 5000:80 excalidraw/excalidraw:latest
|
docker run --rm -dit --name excalidraw -p 5000:80 excalidraw/excalidraw:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
The Docker image is free of analytics and other tracking libraries.
|
After building the image and running the container, open <http://localhost:5000> to see the application.
|
||||||
|
|
||||||
**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
|
## 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.
|
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
|
## Translating
|
||||||
|
|
||||||
- [Create React App](https://github.com/facebook/create-react-app)
|
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.
|
||||||
|
|
||||||
|
## Excalidraw is built using these awesome tools
|
||||||
|
|
||||||
|
- [React](https://reactjs.org)
|
||||||
- [Rough.js](https://roughjs.com)
|
- [Rough.js](https://roughjs.com)
|
||||||
- [TypeScript](https://www.typescriptlang.org)
|
- [TypeScript](https://typescriptlang.org)
|
||||||
- [Vercel](https://vercel.com)
|
- [Vercel](https://vercel.com)
|
||||||
|
|
||||||
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
|
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
|
||||||
|
|
||||||
|
## Testimonials
|
||||||
|
|
||||||
|
<a href="https://twitter.com/Lissy_Sykes/status/1213813117177729026"><img width="398" src="https://user-images.githubusercontent.com/197597/71783813-dbf8a600-2fa0-11ea-9c0d-bb3cc45969e6.png"></a>
|
||||||
|
<a href="https://twitter.com/dan_abramov/status/1213762494428262400"><img width="398" src="https://user-images.githubusercontent.com/197597/71783990-4d395880-2fa3-11ea-9ad7-186138db5003.png"></a>
|
||||||
|
|
||||||
|
<a href="https://twitter.com/kyehohenberger/status/1214288572037025792"><img width="423" src="https://user-images.githubusercontent.com/197597/71851802-34f13880-308c-11ea-9416-191099e6349c.png"></a>
|
||||||
|
<a href="https://twitter.com/lucasazzola/status/1215126440330416128"><img width="429" src="https://user-images.githubusercontent.com/197597/72039003-48e99580-3258-11ea-8daa-85dd055f2a82.png">
|
||||||
|
|
||||||
|
<a href="https://twitter.com/jordwalke/status/1214858186789806080"><img width="434" src="https://user-images.githubusercontent.com/197597/72036874-07a1b780-3251-11ea-99e8-6bafd93483a0.png"></a>
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
### Code Contributors
|
||||||
|
|
||||||
|
This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)].
|
||||||
|
<a href="https://github.com/excalidraw/excalidraw/graphs/contributors"><img src="https://opencollective.com/excalidraw/contributors.svg?width=890&button=false" /></a>
|
||||||
|
|
||||||
|
### Financial Contributors
|
||||||
|
|
||||||
|
Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/excalidraw/contribute)]
|
||||||
|
|
||||||
|
#### Individuals
|
||||||
|
|
||||||
|
<a href="https://opencollective.com/excalidraw"><img src="https://opencollective.com/excalidraw/individuals.svg?width=890"></a>
|
||||||
|
|
||||||
|
#### Organizations
|
||||||
|
|
||||||
|
Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/excalidraw/contribute)]
|
||||||
|
|
||||||
|
<a href="https://opencollective.com/excalidraw/organization/0/website"><img src="https://opencollective.com/excalidraw/organization/0/avatar.svg"></a>
|
||||||
|
<a href="https://opencollective.com/excalidraw/organization/1/website"><img src="https://opencollective.com/excalidraw/organization/1/avatar.svg"></a>
|
||||||
|
<a href="https://opencollective.com/excalidraw/organization/2/website"><img src="https://opencollective.com/excalidraw/organization/2/avatar.svg"></a>
|
||||||
|
<a href="https://opencollective.com/excalidraw/organization/3/website"><img src="https://opencollective.com/excalidraw/organization/3/avatar.svg"></a>
|
||||||
|
<a href="https://opencollective.com/excalidraw/organization/4/website"><img src="https://opencollective.com/excalidraw/organization/4/avatar.svg"></a>
|
||||||
|
<a href="https://opencollective.com/excalidraw/organization/5/website"><img src="https://opencollective.com/excalidraw/organization/5/avatar.svg"></a>
|
||||||
|
<a href="https://opencollective.com/excalidraw/organization/6/website"><img src="https://opencollective.com/excalidraw/organization/6/avatar.svg"></a>
|
||||||
|
<a href="https://opencollective.com/excalidraw/organization/7/website"><img src="https://opencollective.com/excalidraw/organization/7/avatar.svg"></a>
|
||||||
|
<a href="https://opencollective.com/excalidraw/organization/8/website"><img src="https://opencollective.com/excalidraw/organization/8/avatar.svg"></a>
|
||||||
|
<a href="https://opencollective.com/excalidraw/organization/9/website"><img src="https://opencollective.com/excalidraw/organization/9/avatar.svg"></a>
|
||||||
|
@@ -1,25 +1,9 @@
|
|||||||
version: "3.8"
|
version: "3"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
excalidraw:
|
excalidraw:
|
||||||
build:
|
build: .
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
- NODE_ENV=development
|
|
||||||
container_name: excalidraw
|
container_name: excalidraw
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "5000:80"
|
||||||
restart: on-failure
|
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:
|
|
||||||
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"projects": {
|
|
||||||
"default": "excalidraw-room-persistence"
|
|
||||||
}
|
|
||||||
}
|
|
66
firebase-project/.gitignore
vendored
@@ -1,66 +0,0 @@
|
|||||||
# 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
|
|
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"firestore": {
|
|
||||||
"rules": "firestore.rules",
|
|
||||||
"indexes": "firestore.indexes.json"
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"indexes": [],
|
|
||||||
"fieldOverrides": []
|
|
||||||
}
|
|
@@ -1,10 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -21,5 +21,12 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"redirects": [
|
||||||
|
{
|
||||||
|
"source": "/([^.]+)",
|
||||||
|
"destination": "/",
|
||||||
|
"statusCode": 301
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
20311
package-lock.json
generated
Normal file
104
package.json
@@ -19,53 +19,45 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/browser": "6.2.2",
|
"@sentry/browser": "5.15.5",
|
||||||
"@sentry/integrations": "6.2.1",
|
"@sentry/integrations": "5.15.5",
|
||||||
"@testing-library/jest-dom": "5.11.9",
|
"@testing-library/jest-dom": "5.7.0",
|
||||||
"@testing-library/react": "11.2.5",
|
"@testing-library/react": "10.0.4",
|
||||||
"@types/jest": "26.0.20",
|
"@types/jest": "25.2.1",
|
||||||
"@types/react": "17.0.2",
|
"@types/nanoid": "2.1.0",
|
||||||
"@types/react-dom": "17.0.1",
|
"@types/react": "16.9.35",
|
||||||
"@types/socket.io-client": "1.4.36",
|
"@types/react-dom": "16.9.8",
|
||||||
"browser-fs-access": "0.14.2",
|
"@types/socket.io-client": "1.4.32",
|
||||||
"clsx": "1.1.1",
|
"browser-nativefs": "0.7.3",
|
||||||
"firebase": "8.2.10",
|
"i18next-browser-languagedetector": "4.1.1",
|
||||||
"i18next-browser-languagedetector": "6.0.1",
|
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "3.1.21",
|
"nanoid": "2.1.11",
|
||||||
"open-color": "1.8.0",
|
"node-sass": "4.14.1",
|
||||||
"pako": "1.0.11",
|
"open-color": "1.7.0",
|
||||||
"png-chunk-text": "1.0.0",
|
|
||||||
"png-chunks-encode": "1.0.0",
|
|
||||||
"png-chunks-extract": "1.0.0",
|
|
||||||
"points-on-curve": "0.2.0",
|
"points-on-curve": "0.2.0",
|
||||||
"pwacompat": "2.0.17",
|
"pwacompat": "2.0.12",
|
||||||
"react": "17.0.1",
|
"react": "16.13.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "16.13.1",
|
||||||
"react-scripts": "4.0.3",
|
"react-scripts": "3.4.1",
|
||||||
"roughjs": "4.3.1",
|
"roughjs": "4.3.1",
|
||||||
"sass": "1.32.8",
|
"socket.io-client": "2.3.0",
|
||||||
"socket.io-client": "2.3.1",
|
"typescript": "3.8.3"
|
||||||
"typescript": "4.2.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@excalidraw/eslint-config": "1.0.0",
|
|
||||||
"@excalidraw/prettier-config": "1.0.2",
|
|
||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.6",
|
||||||
"@types/pako": "1.0.1",
|
"asar": "3.0.3",
|
||||||
"@types/resize-observer-browser": "0.1.5",
|
"eslint": "6.8.0",
|
||||||
"eslint-config-prettier": "8.1.0",
|
"eslint-config-prettier": "6.11.0",
|
||||||
"eslint-plugin-prettier": "3.3.1",
|
"eslint-plugin-prettier": "3.1.3",
|
||||||
"firebase-tools": "9.6.1",
|
"husky": "4.2.5",
|
||||||
"husky": "4.3.8",
|
"jest-canvas-mock": "2.2.0",
|
||||||
"jest-canvas-mock": "2.3.1",
|
"lint-staged": "10.2.2",
|
||||||
"lint-staged": "10.5.4",
|
"pepjs": "0.5.2",
|
||||||
"pepjs": "0.5.3",
|
"prettier": "2.0.5",
|
||||||
"prettier": "2.2.1",
|
|
||||||
"rewire": "5.0.0"
|
"rewire": "5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=12.0.0"
|
||||||
},
|
},
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"husky": {
|
"husky": {
|
||||||
@@ -75,34 +67,28 @@
|
|||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
|
||||||
],
|
]
|
||||||
"resetMocks": false
|
|
||||||
},
|
},
|
||||||
"name": "excalidraw",
|
|
||||||
"prettier": "@excalidraw/prettier-config",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "npm run build:app && npm run build:zip",
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
"build:app": "REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
|
||||||
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
"build:zip": "node ./scripts/build-version.js",
|
||||||
"build:version": "node ./scripts/build-version.js",
|
|
||||||
"build": "yarn build:app && yarn build:version",
|
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"fix:code": "yarn test:code --fix",
|
"fix": "npm run fix:other && npm run fix:code",
|
||||||
"fix:other": "yarn prettier --write",
|
"fix:code": "npm run test:code -- --fix",
|
||||||
"fix": "yarn fix:other && yarn fix:code",
|
"fix:other": "npm run prettier -- --write",
|
||||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
|
||||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
|
||||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
"test": "npm run test:app",
|
||||||
"test:app": "react-scripts test --passWithNoTests",
|
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
|
||||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
"test:update": "npm run test:app -- --updateSnapshot --watchAll=false",
|
||||||
|
"test:app": "react-scripts test --env=jsdom --passWithNoTests",
|
||||||
|
"test:code": "eslint --max-warnings=0 --ignore-path .gitignore --ext .js,.ts,.tsx .",
|
||||||
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
|
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
|
||||||
"test:other": "yarn prettier --list-different",
|
"test:other": "npm run prettier -- --list-different",
|
||||||
"test:typecheck": "tsc",
|
"test:typecheck": "tsc"
|
||||||
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
|
||||||
"test": "yarn test:app"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BIN
public/FG_Virgil.otf
Normal file
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 9.0 KiB |
@@ -1,7 +1,7 @@
|
|||||||
/* http://www.eaglefonts.com/fg-virgil-ttf-131249.htm */
|
/* http://www.eaglefonts.com/fg-virgil-ttf-131249.htm */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Virgil";
|
font-family: "Virgil";
|
||||||
src: url("Virgil.woff2");
|
src: url("FG_Virgil.woff2");
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -13,6 +13,18 @@
|
|||||||
|
|
||||||
<meta name="theme-color" content="#000" />
|
<meta name="theme-color" content="#000" />
|
||||||
|
|
||||||
|
<!-- Origin Trial token for the Native File System API v1 https://developers.chrome.com/origintrials/#/view_trial/3868592079911256065 (Chrome 78–81) -->
|
||||||
|
<meta
|
||||||
|
http-equiv="origin-trial"
|
||||||
|
content="AoGjY+6r8OQZ5c0AXpK+bbca0pJdCTSHWFqSFNulxiW4OwFBB63kHdDHNo433GeuEOir8IvSovR0LOZLfPnEDAUAAABceyJvcmlnaW4iOiJodHRwczovL3d3dy5leGNhbGlkcmF3LmNvbTo0NDMiLCJmZWF0dXJlIjoiTmF0aXZlRmlsZVN5c3RlbSIsImV4cGlyeSI6MTU4OTMyNzk5OX0="
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Origin Trial token for the Native File System API v2 https://developers.chrome.com/origintrials/#/view_trial/4019462667428167681 (Chrome 83–85) -->
|
||||||
|
<meta
|
||||||
|
http-equiv="origin-trial"
|
||||||
|
content="AgMee3sqSZkE0QaZP8f/F9OJj5iSLdnNMRGttIDlOQy552MI4GoL41jyCAHOYsQ8UWM1kPdrb6PVmbSllX/JqwEAAABZeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJOYXRpdmVGaWxlU3lzdGVtMiIsImV4cGlyeSI6MTU5MDU3MzM5MX0="
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- General tags -->
|
<!-- General tags -->
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
@@ -54,13 +66,10 @@
|
|||||||
<!-- OG tags require absolute url for images -->
|
<!-- OG tags require absolute url for images -->
|
||||||
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
|
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
|
||||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||||
|
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||||
<!-- Excalidraw version -->
|
|
||||||
<meta name="version" content="{version}" />
|
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
href="Virgil.woff2"
|
href="FG_Virgil.woff2"
|
||||||
as="font"
|
as="font"
|
||||||
type="font/woff2"
|
type="font/woff2"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
@@ -74,7 +83,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<link
|
<link
|
||||||
href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
|
href="https://excalidraw-socket.herokuapp.com/socket.io"
|
||||||
rel="preconnect"
|
rel="preconnect"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
/>
|
/>
|
||||||
@@ -82,52 +91,12 @@
|
|||||||
<link
|
<link
|
||||||
rel="manifest"
|
rel="manifest"
|
||||||
href="manifest.json"
|
href="manifest.json"
|
||||||
style="--pwacompat-splash-font: 24px Virgil"
|
style="--pwacompat-splash-font: 24px Virgil;"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
|
||||||
<script>
|
|
||||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
|
||||||
</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>
|
<style>
|
||||||
body {
|
|
||||||
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: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visually-hidden {
|
|
||||||
position: absolute !important;
|
|
||||||
height: 1px;
|
|
||||||
width: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(1px, 1px, 1px, 1px);
|
|
||||||
white-space: nowrap; /* added line */
|
|
||||||
}
|
|
||||||
|
|
||||||
.LoadingMessage {
|
.LoadingMessage {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -138,19 +107,40 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.LoadingMessage span {
|
.LoadingMessage span {
|
||||||
background-color: var(--button-gray-1);
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 0.8em 1.2em;
|
padding: 0.8em 1.2em;
|
||||||
color: var(--popup-text-color);
|
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
}
|
}
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute !important;
|
||||||
|
height: 1px;
|
||||||
|
width: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
||||||
|
clip: rect(1px, 1px, 1px, 1px);
|
||||||
|
white-space: nowrap; /* added line */
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script
|
||||||
|
async
|
||||||
|
src="https://www.googletagmanager.com/gtag/js?id=UA-387204-13"
|
||||||
|
></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag() {
|
||||||
|
dataLayer.push(arguments);
|
||||||
|
}
|
||||||
|
gtag("js", new Date());
|
||||||
|
gtag("config", "UA-387204-13");
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
<noscript>
|
||||||
|
You need to enable JavaScript to run this app.
|
||||||
|
</noscript>
|
||||||
<header>
|
<header>
|
||||||
<h1 class="visually-hidden">Excalidraw</h1>
|
<h1 class="visually-hidden">Excalidraw</h1>
|
||||||
</header>
|
</header>
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -14,30 +14,8 @@
|
|||||||
"sizes": "256x256"
|
"sizes": "256x256"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": "/",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"theme_color": "#000000",
|
"theme_color": "#000000",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff"
|
||||||
"file_handlers": [
|
|
||||||
{
|
|
||||||
"action": "/",
|
|
||||||
"accept": {
|
|
||||||
"application/vnd.excalidraw+json": [".excalidraw"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"capture_links": "new_client",
|
|
||||||
"share_target": {
|
|
||||||
"action": "/web-share-target",
|
|
||||||
"method": "POST",
|
|
||||||
"enctype": "multipart/form-data",
|
|
||||||
"params": {
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"name": "file",
|
|
||||||
"accept": ["application/vnd.excalidraw+json", "application/json", ".excalidraw"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 76 KiB |
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 83 KiB |
@@ -1,3 +0,0 @@
|
|||||||
user-agent: *
|
|
||||||
Allow: /$
|
|
||||||
Disallow: /
|
|
@@ -1,2 +0,0 @@
|
|||||||
this.workbox=this.workbox||{},this.workbox.backgroundSync=function(t,e,s){"use strict";try{self["workbox:background-sync:4.3.1"]&&_()}catch(t){}const i=3,n="workbox-background-sync",a="requests",r="queueName";class c{constructor(t){this.t=t,this.s=new s.DBWrapper(n,i,{onupgradeneeded:this.i})}async pushEntry(t){delete t.id,t.queueName=this.t,await this.s.add(a,t)}async unshiftEntry(t){const[e]=await this.s.getAllMatching(a,{count:1});e?t.id=e.id-1:delete t.id,t.queueName=this.t,await this.s.add(a,t)}async popEntry(){return this.h({direction:"prev"})}async shiftEntry(){return this.h({direction:"next"})}async getAll(){return await this.s.getAllMatching(a,{index:r,query:IDBKeyRange.only(this.t)})}async deleteEntry(t){await this.s.delete(a,t)}async h({direction:t}){const[e]=await this.s.getAllMatching(a,{direction:t,index:r,query:IDBKeyRange.only(this.t),count:1});if(e)return await this.deleteEntry(e.id),e}i(t){const e=t.target.result;t.oldVersion>0&&t.oldVersion<i&&e.objectStoreNames.contains(a)&&e.deleteObjectStore(a),e.createObjectStore(a,{autoIncrement:!0,keyPath:"id"}).createIndex(r,r,{unique:!1})}}const h=["method","referrer","referrerPolicy","mode","credentials","cache","redirect","integrity","keepalive"];class o{static async fromRequest(t){const e={url:t.url,headers:{}};"GET"!==t.method&&(e.body=await t.clone().arrayBuffer());for(const[s,i]of t.headers.entries())e.headers[s]=i;for(const s of h)void 0!==t[s]&&(e[s]=t[s]);return new o(e)}constructor(t){"navigate"===t.mode&&(t.mode="same-origin"),this.o=t}toObject(){const t=Object.assign({},this.o);return t.headers=Object.assign({},this.o.headers),t.body&&(t.body=t.body.slice(0)),t}toRequest(){return new Request(this.o.url,this.o)}clone(){return new o(this.toObject())}}const u="workbox-background-sync",y=10080,w=new Set;class d{constructor(t,{onSync:s,maxRetentionTime:i}={}){if(w.has(t))throw new e.WorkboxError("duplicate-queue-name",{name:t});w.add(t),this.u=t,this.l=s||this.replayRequests,this.q=i||y,this.m=new c(this.u),this.p()}get name(){return this.u}async pushRequest(t){await this.g(t,"push")}async unshiftRequest(t){await this.g(t,"unshift")}async popRequest(){return this.R("pop")}async shiftRequest(){return this.R("shift")}async getAll(){const t=await this.m.getAll(),e=Date.now(),s=[];for(const i of t){const t=60*this.q*1e3;e-i.timestamp>t?await this.m.deleteEntry(i.id):s.push(f(i))}return s}async g({request:t,metadata:e,timestamp:s=Date.now()},i){const n={requestData:(await o.fromRequest(t.clone())).toObject(),timestamp:s};e&&(n.metadata=e),await this.m[`${i}Entry`](n),this.k?this.D=!0:await this.registerSync()}async R(t){const e=Date.now(),s=await this.m[`${t}Entry`]();if(s){const i=60*this.q*1e3;return e-s.timestamp>i?this.R(t):f(s)}}async replayRequests(){let t;for(;t=await this.shiftRequest();)try{await fetch(t.request.clone())}catch(s){throw await this.unshiftRequest(t),new e.WorkboxError("queue-replay-failed",{name:this.u})}}async registerSync(){if("sync"in registration)try{await registration.sync.register(`${u}:${this.u}`)}catch(t){}}p(){"sync"in registration?self.addEventListener("sync",t=>{if(t.tag===`${u}:${this.u}`){const e=async()=>{let e;this.k=!0;try{await this.l({queue:this})}catch(t){throw e=t}finally{!this.D||e&&!t.lastChance||await this.registerSync(),this.k=!1,this.D=!1}};t.waitUntil(e())}}):this.l({queue:this})}static get _(){return w}}const f=t=>{const e={request:new o(t.requestData).toRequest(),timestamp:t.timestamp};return t.metadata&&(e.metadata=t.metadata),e};return t.Queue=d,t.Plugin=class{constructor(...t){this.v=new d(...t),this.fetchDidFail=this.fetchDidFail.bind(this)}async fetchDidFail({request:t}){await this.v.pushRequest({request:t})}},t}({},workbox.core._private,workbox.core._private);
|
|
||||||
//# sourceMappingURL=workbox-background-sync.prod.js.map
|
|
@@ -1,2 +0,0 @@
|
|||||||
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
|
|
@@ -1,2 +0,0 @@
|
|||||||
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
|
|
@@ -1,2 +0,0 @@
|
|||||||
this.workbox=this.workbox||{},this.workbox.expiration=function(t,e,s,i,a,n){"use strict";try{self["workbox:expiration:4.3.1"]&&_()}catch(t){}const h="workbox-expiration",c="cache-entries",r=t=>{const e=new URL(t,location);return e.hash="",e.href};class o{constructor(t){this.t=t,this.s=new e.DBWrapper(h,1,{onupgradeneeded:t=>this.i(t)})}i(t){const e=t.target.result.createObjectStore(c,{keyPath:"id"});e.createIndex("cacheName","cacheName",{unique:!1}),e.createIndex("timestamp","timestamp",{unique:!1}),s.deleteDatabase(this.t)}async setTimestamp(t,e){t=r(t),await this.s.put(c,{url:t,timestamp:e,cacheName:this.t,id:this.h(t)})}async getTimestamp(t){return(await this.s.get(c,this.h(t))).timestamp}async expireEntries(t,e){const s=await this.s.transaction(c,"readwrite",(s,i)=>{const a=s.objectStore(c),n=[];let h=0;a.index("timestamp").openCursor(null,"prev").onsuccess=(({target:s})=>{const a=s.result;if(a){const s=a.value;s.cacheName===this.t&&(t&&s.timestamp<t||e&&h>=e?n.push(a.value):h++),a.continue()}else i(n)})}),i=[];for(const t of s)await this.s.delete(c,t.id),i.push(t.url);return i}h(t){return this.t+"|"+r(t)}}class u{constructor(t,e={}){this.o=!1,this.u=!1,this.l=e.maxEntries,this.p=e.maxAgeSeconds,this.t=t,this.m=new o(t)}async expireEntries(){if(this.o)return void(this.u=!0);this.o=!0;const t=this.p?Date.now()-1e3*this.p:void 0,e=await this.m.expireEntries(t,this.l),s=await caches.open(this.t);for(const t of e)await s.delete(t);this.o=!1,this.u&&(this.u=!1,this.expireEntries())}async updateTimestamp(t){await this.m.setTimestamp(t,Date.now())}async isURLExpired(t){return await this.m.getTimestamp(t)<Date.now()-1e3*this.p}async delete(){this.u=!1,await this.m.expireEntries(1/0)}}return t.CacheExpiration=u,t.Plugin=class{constructor(t={}){this.D=t,this.p=t.maxAgeSeconds,this.g=new Map,t.purgeOnQuotaError&&n.registerQuotaErrorCallback(()=>this.deleteCacheAndMetadata())}k(t){if(t===a.cacheNames.getRuntimeName())throw new i.WorkboxError("expire-custom-caches-only");let e=this.g.get(t);return e||(e=new u(t,this.D),this.g.set(t,e)),e}cachedResponseWillBeUsed({event:t,request:e,cacheName:s,cachedResponse:i}){if(!i)return null;let a=this.N(i);const n=this.k(s);n.expireEntries();const h=n.updateTimestamp(e.url);if(t)try{t.waitUntil(h)}catch(t){}return a?i:null}N(t){if(!this.p)return!0;const e=this._(t);return null===e||e>=Date.now()-1e3*this.p}_(t){if(!t.headers.has("date"))return null;const e=t.headers.get("date"),s=new Date(e).getTime();return isNaN(s)?null:s}async cacheDidUpdate({cacheName:t,request:e}){const s=this.k(t);await s.updateTimestamp(e.url),await s.expireEntries()}async deleteCacheAndMetadata(){for(const[t,e]of this.g)await caches.delete(t),await e.delete();this.g=new Map}},t}({},workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private,workbox.core);
|
|
||||||
//# sourceMappingURL=workbox-expiration.prod.js.map
|
|
@@ -1,2 +0,0 @@
|
|||||||
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
|
|
@@ -1,2 +0,0 @@
|
|||||||
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
|
|
@@ -1,2 +0,0 @@
|
|||||||
this.workbox=this.workbox||{},this.workbox.precaching=function(t,e,n,s,c){"use strict";try{self["workbox:precaching:4.3.1"]&&_()}catch(t){}const o=[],i={get:()=>o,add(t){o.push(...t)}};const a="__WB_REVISION__";function r(t){if(!t)throw new c.WorkboxError("add-to-cache-list-unexpected-type",{entry:t});if("string"==typeof t){const e=new URL(t,location);return{cacheKey:e.href,url:e.href}}const{revision:e,url:n}=t;if(!n)throw new c.WorkboxError("add-to-cache-list-unexpected-type",{entry:t});if(!e){const t=new URL(n,location);return{cacheKey:t.href,url:t.href}}const s=new URL(n,location),o=new URL(n,location);return o.searchParams.set(a,e),{cacheKey:o.href,url:s.href}}class l{constructor(t){this.t=e.cacheNames.getPrecacheName(t),this.s=new Map}addToCacheList(t){for(const e of t){const{cacheKey:t,url:n}=r(e);if(this.s.has(n)&&this.s.get(n)!==t)throw new c.WorkboxError("add-to-cache-list-conflicting-entries",{firstEntry:this.s.get(n),secondEntry:t});this.s.set(n,t)}}async install({event:t,plugins:e}={}){const n=[],s=[],c=await caches.open(this.t),o=await c.keys(),i=new Set(o.map(t=>t.url));for(const t of this.s.values())i.has(t)?s.push(t):n.push(t);const a=n.map(n=>this.o({event:t,plugins:e,url:n}));return await Promise.all(a),{updatedURLs:n,notUpdatedURLs:s}}async activate(){const t=await caches.open(this.t),e=await t.keys(),n=new Set(this.s.values()),s=[];for(const c of e)n.has(c.url)||(await t.delete(c),s.push(c.url));return{deletedURLs:s}}async o({url:t,event:e,plugins:o}){const i=new Request(t,{credentials:"same-origin"});let a,r=await s.fetchWrapper.fetch({event:e,plugins:o,request:i});for(const t of o||[])"cacheWillUpdate"in t&&(a=t.cacheWillUpdate.bind(t));if(!(a?a({event:e,request:i,response:r}):r.status<400))throw new c.WorkboxError("bad-precaching-response",{url:t,status:r.status});r.redirected&&(r=await async function(t){const e=t.clone(),n="body"in e?Promise.resolve(e.body):e.blob(),s=await n;return new Response(s,{headers:e.headers,status:e.status,statusText:e.statusText})}(r)),await n.cacheWrapper.put({event:e,plugins:o,request:i,response:r,cacheName:this.t,matchOptions:{ignoreSearch:!0}})}getURLsToCacheKeys(){return this.s}getCachedURLs(){return[...this.s.keys()]}getCacheKeyForURL(t){const e=new URL(t,location);return this.s.get(e.href)}}let u;const h=()=>(u||(u=new l),u);const d=(t,e)=>{const n=h().getURLsToCacheKeys();for(const s of function*(t,{ignoreURLParametersMatching:e,directoryIndex:n,cleanURLs:s,urlManipulation:c}={}){const o=new URL(t,location);o.hash="",yield o.href;const i=function(t,e){for(const n of[...t.searchParams.keys()])e.some(t=>t.test(n))&&t.searchParams.delete(n);return t}(o,e);if(yield i.href,n&&i.pathname.endsWith("/")){const t=new URL(i);t.pathname+=n,yield t.href}if(s){const t=new URL(i);t.pathname+=".html",yield t.href}if(c){const t=c({url:o});for(const e of t)yield e.href}}(t,e)){const t=n.get(s);if(t)return t}};let w=!1;const f=t=>{w||((({ignoreURLParametersMatching:t=[/^utm_/],directoryIndex:n="index.html",cleanURLs:s=!0,urlManipulation:c=null}={})=>{const o=e.cacheNames.getPrecacheName();addEventListener("fetch",e=>{const i=d(e.request.url,{cleanURLs:s,directoryIndex:n,ignoreURLParametersMatching:t,urlManipulation:c});if(!i)return;let a=caches.open(o).then(t=>t.match(i)).then(t=>t||fetch(i));e.respondWith(a)})})(t),w=!0)},y=t=>{const e=h(),n=i.get();t.waitUntil(e.install({event:t,plugins:n}).catch(t=>{throw t}))},p=t=>{const e=h(),n=i.get();t.waitUntil(e.activate({event:t,plugins:n}))},L=t=>{h().addToCacheList(t),t.length>0&&(addEventListener("install",y),addEventListener("activate",p))};return t.addPlugins=(t=>{i.add(t)}),t.addRoute=f,t.cleanupOutdatedCaches=(()=>{addEventListener("activate",t=>{const n=e.cacheNames.getPrecacheName();t.waitUntil((async(t,e="-precache-")=>{const n=(await caches.keys()).filter(n=>n.includes(e)&&n.includes(self.registration.scope)&&n!==t);return await Promise.all(n.map(t=>caches.delete(t))),n})(n).then(t=>{}))})}),t.getCacheKeyForURL=(t=>{return h().getCacheKeyForURL(t)}),t.precache=L,t.precacheAndRoute=((t,e)=>{L(t),f(e)}),t.PrecacheController=l,t}({},workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private);
|
|
||||||
//# sourceMappingURL=workbox-precaching.prod.js.map
|
|
@@ -1,2 +0,0 @@
|
|||||||
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
|
|
@@ -1,2 +0,0 @@
|
|||||||
this.workbox=this.workbox||{},this.workbox.routing=function(t,e,r){"use strict";try{self["workbox:routing:4.3.1"]&&_()}catch(t){}const s="GET",n=t=>t&&"object"==typeof t?t:{handle:t};class o{constructor(t,e,r){this.handler=n(e),this.match=t,this.method=r||s}}class i extends o{constructor(t,{whitelist:e=[/./],blacklist:r=[]}={}){super(t=>this.t(t),t),this.s=e,this.o=r}t({url:t,request:e}){if("navigate"!==e.mode)return!1;const r=t.pathname+t.search;for(const t of this.o)if(t.test(r))return!1;return!!this.s.some(t=>t.test(r))}}class u extends o{constructor(t,e,r){super(({url:e})=>{const r=t.exec(e.href);return r?e.origin!==location.origin&&0!==r.index?null:r.slice(1):null},e,r)}}class c{constructor(){this.i=new Map}get routes(){return this.i}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,r=this.handleRequest({request:e,event:t});r&&t.respondWith(r)})}addCacheListener(){self.addEventListener("message",async t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,r=Promise.all(e.urlsToCache.map(t=>{"string"==typeof t&&(t=[t]);const e=new Request(...t);return this.handleRequest({request:e})}));t.waitUntil(r),t.ports&&t.ports[0]&&(await r,t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const r=new URL(t.url,location);if(!r.protocol.startsWith("http"))return;let s,{params:n,route:o}=this.findMatchingRoute({url:r,request:t,event:e}),i=o&&o.handler;if(!i&&this.u&&(i=this.u),i){try{s=i.handle({url:r,request:t,event:e,params:n})}catch(t){s=Promise.reject(t)}return s&&this.h&&(s=s.catch(t=>this.h.handle({url:r,event:e,err:t}))),s}}findMatchingRoute({url:t,request:e,event:r}){const s=this.i.get(e.method)||[];for(const n of s){let s,o=n.match({url:t,request:e,event:r});if(o)return Array.isArray(o)&&o.length>0?s=o:o.constructor===Object&&Object.keys(o).length>0&&(s=o),{route:n,params:s}}return{}}setDefaultHandler(t){this.u=n(t)}setCatchHandler(t){this.h=n(t)}registerRoute(t){this.i.has(t.method)||this.i.set(t.method,[]),this.i.get(t.method).push(t)}unregisterRoute(t){if(!this.i.has(t.method))throw new r.WorkboxError("unregister-route-but-not-found-with-method",{method:t.method});const e=this.i.get(t.method).indexOf(t);if(!(e>-1))throw new r.WorkboxError("unregister-route-route-not-registered");this.i.get(t.method).splice(e,1)}}let a;const h=()=>(a||((a=new c).addFetchListener(),a.addCacheListener()),a);return t.NavigationRoute=i,t.RegExpRoute=u,t.registerNavigationRoute=((t,r={})=>{const s=e.cacheNames.getPrecacheName(r.cacheName),n=new i(async()=>{try{const e=await caches.match(t,{cacheName:s});if(e)return e;throw new Error(`The cache ${s} did not have an entry for `+`${t}.`)}catch(e){return fetch(t)}},{whitelist:r.whitelist,blacklist:r.blacklist});return h().registerRoute(n),n}),t.registerRoute=((t,e,s="GET")=>{let n;if("string"==typeof t){const r=new URL(t,location);n=new o(({url:t})=>t.href===r.href,e,s)}else if(t instanceof RegExp)n=new u(t,e,s);else if("function"==typeof t)n=new o(t,e,s);else{if(!(t instanceof o))throw new r.WorkboxError("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});n=t}return h().registerRoute(n),n}),t.Route=o,t.Router=c,t.setCatchHandler=(t=>{h().setCatchHandler(t)}),t.setDefaultHandler=(t=>{h().setDefaultHandler(t)}),t}({},workbox.core._private,workbox.core._private);
|
|
||||||
//# sourceMappingURL=workbox-routing.prod.js.map
|
|
@@ -1,2 +0,0 @@
|
|||||||
this.workbox=this.workbox||{},this.workbox.strategies=function(e,t,s,n,r){"use strict";try{self["workbox:strategies:4.3.1"]&&_()}catch(e){}class i{constructor(e={}){this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],this.i=e.fetchOptions||null,this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){"string"==typeof t&&(t=new Request(t));let n,i=await s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s});if(!i)try{i=await this.u(t,e)}catch(e){n=e}if(!i)throw new r.WorkboxError("no-response",{url:t.url,error:n});return i}async u(e,t){const r=await n.fetchWrapper.fetch({request:e,event:t,fetchOptions:this.i,plugins:this.s}),i=r.clone(),h=s.cacheWrapper.put({cacheName:this.t,request:e,response:i,event:t,plugins:this.s});if(t)try{t.waitUntil(h)}catch(e){}return r}}class h{constructor(e={}){this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){"string"==typeof t&&(t=new Request(t));const n=await s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s});if(!n)throw new r.WorkboxError("no-response",{url:t.url});return n}}const u={cacheWillUpdate:({response:e})=>200===e.status||0===e.status?e:null};class a{constructor(e={}){if(this.t=t.cacheNames.getRuntimeName(e.cacheName),e.plugins){let t=e.plugins.some(e=>!!e.cacheWillUpdate);this.s=t?e.plugins:[u,...e.plugins]}else this.s=[u];this.o=e.networkTimeoutSeconds,this.i=e.fetchOptions||null,this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){const s=[];"string"==typeof t&&(t=new Request(t));const n=[];let i;if(this.o){const{id:r,promise:h}=this.l({request:t,event:e,logs:s});i=r,n.push(h)}const h=this.q({timeoutId:i,request:t,event:e,logs:s});n.push(h);let u=await Promise.race(n);if(u||(u=await h),!u)throw new r.WorkboxError("no-response",{url:t.url});return u}l({request:e,logs:t,event:s}){let n;return{promise:new Promise(t=>{n=setTimeout(async()=>{t(await this.p({request:e,event:s}))},1e3*this.o)}),id:n}}async q({timeoutId:e,request:t,logs:r,event:i}){let h,u;try{u=await n.fetchWrapper.fetch({request:t,event:i,fetchOptions:this.i,plugins:this.s})}catch(e){h=e}if(e&&clearTimeout(e),h||!u)u=await this.p({request:t,event:i});else{const e=u.clone(),n=s.cacheWrapper.put({cacheName:this.t,request:t,response:e,event:i,plugins:this.s});if(i)try{i.waitUntil(n)}catch(e){}}return u}p({event:e,request:t}){return s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s})}}class c{constructor(e={}){this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],this.i=e.fetchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){let s,i;"string"==typeof t&&(t=new Request(t));try{i=await n.fetchWrapper.fetch({request:t,event:e,fetchOptions:this.i,plugins:this.s})}catch(e){s=e}if(!i)throw new r.WorkboxError("no-response",{url:t.url,error:s});return i}}class o{constructor(e={}){if(this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],e.plugins){let t=e.plugins.some(e=>!!e.cacheWillUpdate);this.s=t?e.plugins:[u,...e.plugins]}else this.s=[u];this.i=e.fetchOptions||null,this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){"string"==typeof t&&(t=new Request(t));const n=this.u({request:t,event:e});let i,h=await s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s});if(h){if(e)try{e.waitUntil(n)}catch(i){}}else try{h=await n}catch(e){i=e}if(!h)throw new r.WorkboxError("no-response",{url:t.url,error:i});return h}async u({request:e,event:t}){const r=await n.fetchWrapper.fetch({request:e,event:t,fetchOptions:this.i,plugins:this.s}),i=s.cacheWrapper.put({cacheName:this.t,request:e,response:r.clone(),event:t,plugins:this.s});if(t)try{t.waitUntil(i)}catch(e){}return r}}const l={cacheFirst:i,cacheOnly:h,networkFirst:a,networkOnly:c,staleWhileRevalidate:o},q=e=>{const t=l[e];return e=>new t(e)},w=q("cacheFirst"),p=q("cacheOnly"),v=q("networkFirst"),y=q("networkOnly"),m=q("staleWhileRevalidate");return e.CacheFirst=i,e.CacheOnly=h,e.NetworkFirst=a,e.NetworkOnly=c,e.StaleWhileRevalidate=o,e.cacheFirst=w,e.cacheOnly=p,e.networkFirst=v,e.networkOnly=y,e.staleWhileRevalidate=m,e}({},workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private);
|
|
||||||
//# sourceMappingURL=workbox-strategies.prod.js.map
|
|
@@ -1,2 +0,0 @@
|
|||||||
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
|
|
@@ -1,2 +0,0 @@
|
|||||||
!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
|
|
@@ -1,2 +0,0 @@
|
|||||||
try{self["workbox:window:4.3.1"]&&_()}catch(n){}var n=function(n,t){return new Promise(function(i){var e=new MessageChannel;e.port1.onmessage=function(n){return i(n.data)},n.postMessage(t,[e.port2])})};function t(n,t){for(var i=0;i<t.length;i++){var e=t[i];e.enumerable=e.enumerable||!1,e.configurable=!0,"value"in e&&(e.writable=!0),Object.defineProperty(n,e.key,e)}}function i(n){if(void 0===n)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return n}try{self["workbox:core:4.3.1"]&&_()}catch(n){}var e=function(){var n=this;this.promise=new Promise(function(t,i){n.resolve=t,n.reject=i})},r=function(n,t){return new URL(n,location).href===new URL(t,location).href},o=function(n,t){Object.assign(this,t,{type:n})};function u(n){return function(){for(var t=[],i=0;i<arguments.length;i++)t[i]=arguments[i];try{return Promise.resolve(n.apply(this,t))}catch(n){return Promise.reject(n)}}}function a(n,t,i){return i?t?t(n):n:(n&&n.then||(n=Promise.resolve(n)),t?n.then(t):n)}function s(){}var c=function(c){var f,h;function v(n,t){var r;return void 0===t&&(t={}),(r=c.call(this)||this).t=n,r.i=t,r.o=0,r.u=new e,r.s=new e,r.h=new e,r.v=r.v.bind(i(i(r))),r.l=r.l.bind(i(i(r))),r.g=r.g.bind(i(i(r))),r.m=r.m.bind(i(i(r))),r}h=c,(f=v).prototype=Object.create(h.prototype),f.prototype.constructor=f,f.__proto__=h;var l,w,g,d=v.prototype;return d.register=u(function(n){var t,i,e=this,u=(void 0===n?{}:n).immediate,c=void 0!==u&&u;return t=function(){return e.p=Boolean(navigator.serviceWorker.controller),e.P=e.R(),a(e.k(),function(n){e.B=n,e.P&&(e.O=e.P,e.s.resolve(e.P),e.h.resolve(e.P),e.j(e.P),e.P.addEventListener("statechange",e.l,{once:!0}));var t=e.B.waiting;return t&&r(t.scriptURL,e.t)&&(e.O=t,Promise.resolve().then(function(){e.dispatchEvent(new o("waiting",{sw:t,wasWaitingBeforeRegister:!0}))})),e.O&&e.u.resolve(e.O),e.B.addEventListener("updatefound",e.g),navigator.serviceWorker.addEventListener("controllerchange",e.m,{once:!0}),"BroadcastChannel"in self&&(e.C=new BroadcastChannel("workbox"),e.C.addEventListener("message",e.v)),navigator.serviceWorker.addEventListener("message",e.v),e.B})},(i=function(){if(!c&&"complete"!==document.readyState)return function(n,t){if(!t)return n&&n.then?n.then(s):Promise.resolve()}(new Promise(function(n){return addEventListener("load",n)}))}())&&i.then?i.then(t):t(i)}),d.getSW=u(function(){return this.O||this.u.promise}),d.messageSW=u(function(t){return a(this.getSW(),function(i){return n(i,t)})}),d.R=function(){var n=navigator.serviceWorker.controller;if(n&&r(n.scriptURL,this.t))return n},d.k=u(function(){var n=this;return function(n,t){try{var i=n()}catch(n){return t(n)}return i&&i.then?i.then(void 0,t):i}(function(){return a(navigator.serviceWorker.register(n.t,n.i),function(t){return n.L=performance.now(),t})},function(n){throw n})}),d.j=function(t){n(t,{type:"WINDOW_READY",meta:"workbox-window"})},d.g=function(){var n=this.B.installing;this.o>0||!r(n.scriptURL,this.t)||performance.now()>this.L+6e4?(this.W=n,this.B.removeEventListener("updatefound",this.g)):(this.O=n,this.u.resolve(n)),++this.o,n.addEventListener("statechange",this.l)},d.l=function(n){var t=this,i=n.target,e=i.state,r=i===this.W,u=r?"external":"",a={sw:i,originalEvent:n};!r&&this.p&&(a.isUpdate=!0),this.dispatchEvent(new o(u+e,a)),"installed"===e?this._=setTimeout(function(){"installed"===e&&t.B.waiting===i&&t.dispatchEvent(new o(u+"waiting",a))},200):"activating"===e&&(clearTimeout(this._),r||this.s.resolve(i))},d.m=function(n){var t=this.O;t===navigator.serviceWorker.controller&&(this.dispatchEvent(new o("controlling",{sw:t,originalEvent:n})),this.h.resolve(t))},d.v=function(n){var t=n.data;this.dispatchEvent(new o("message",{data:t,originalEvent:n}))},l=v,(w=[{key:"active",get:function(){return this.s.promise}},{key:"controlling",get:function(){return this.h.promise}}])&&t(l.prototype,w),g&&t(l,g),v}(function(){function n(){this.D={}}var t=n.prototype;return t.addEventListener=function(n,t){this.T(n).add(t)},t.removeEventListener=function(n,t){this.T(n).delete(t)},t.dispatchEvent=function(n){n.target=this,this.T(n.type).forEach(function(t){return t(n)})},t.T=function(n){return this.D[n]=this.D[n]||new Set},n}());export{c as Workbox,n as messageSW};
|
|
||||||
//# sourceMappingURL=workbox-window.prod.es5.mjs.map
|
|
@@ -1,2 +0,0 @@
|
|||||||
try{self["workbox:window:4.3.1"]&&_()}catch(t){}const t=(t,s)=>new Promise(i=>{let e=new MessageChannel;e.port1.onmessage=(t=>i(t.data)),t.postMessage(s,[e.port2])});try{self["workbox:core:4.3.1"]&&_()}catch(t){}class s{constructor(){this.promise=new Promise((t,s)=>{this.resolve=t,this.reject=s})}}class i{constructor(){this.t={}}addEventListener(t,s){this.s(t).add(s)}removeEventListener(t,s){this.s(t).delete(s)}dispatchEvent(t){t.target=this,this.s(t.type).forEach(s=>s(t))}s(t){return this.t[t]=this.t[t]||new Set}}const e=(t,s)=>new URL(t,location).href===new URL(s,location).href;class n{constructor(t,s){Object.assign(this,s,{type:t})}}const h=200,a=6e4;class o extends i{constructor(t,i={}){super(),this.i=t,this.h=i,this.o=0,this.l=new s,this.g=new s,this.u=new s,this.m=this.m.bind(this),this.v=this.v.bind(this),this.p=this.p.bind(this),this._=this._.bind(this)}async register({immediate:t=!1}={}){t||"complete"===document.readyState||await new Promise(t=>addEventListener("load",t)),this.C=Boolean(navigator.serviceWorker.controller),this.W=this.L(),this.S=await this.B(),this.W&&(this.R=this.W,this.g.resolve(this.W),this.u.resolve(this.W),this.P(this.W),this.W.addEventListener("statechange",this.v,{once:!0}));const s=this.S.waiting;return s&&e(s.scriptURL,this.i)&&(this.R=s,Promise.resolve().then(()=>{this.dispatchEvent(new n("waiting",{sw:s,wasWaitingBeforeRegister:!0}))})),this.R&&this.l.resolve(this.R),this.S.addEventListener("updatefound",this.p),navigator.serviceWorker.addEventListener("controllerchange",this._,{once:!0}),"BroadcastChannel"in self&&(this.T=new BroadcastChannel("workbox"),this.T.addEventListener("message",this.m)),navigator.serviceWorker.addEventListener("message",this.m),this.S}get active(){return this.g.promise}get controlling(){return this.u.promise}async getSW(){return this.R||this.l.promise}async messageSW(s){const i=await this.getSW();return t(i,s)}L(){const t=navigator.serviceWorker.controller;if(t&&e(t.scriptURL,this.i))return t}async B(){try{const t=await navigator.serviceWorker.register(this.i,this.h);return this.U=performance.now(),t}catch(t){throw t}}P(s){t(s,{type:"WINDOW_READY",meta:"workbox-window"})}p(){const t=this.S.installing;this.o>0||!e(t.scriptURL,this.i)||performance.now()>this.U+a?(this.k=t,this.S.removeEventListener("updatefound",this.p)):(this.R=t,this.l.resolve(t)),++this.o,t.addEventListener("statechange",this.v)}v(t){const s=t.target,{state:i}=s,e=s===this.k,a=e?"external":"",o={sw:s,originalEvent:t};!e&&this.C&&(o.isUpdate=!0),this.dispatchEvent(new n(a+i,o)),"installed"===i?this.D=setTimeout(()=>{"installed"===i&&this.S.waiting===s&&this.dispatchEvent(new n(a+"waiting",o))},h):"activating"===i&&(clearTimeout(this.D),e||this.g.resolve(s))}_(t){const s=this.R;s===navigator.serviceWorker.controller&&(this.dispatchEvent(new n("controlling",{sw:s,originalEvent:t})),this.u.resolve(s))}m(t){const{data:s}=t;this.dispatchEvent(new n("message",{data:s,originalEvent:t}))}}export{o as Workbox,t as messageSW};
|
|
||||||
//# sourceMappingURL=workbox-window.prod.mjs.map
|
|
@@ -1,2 +0,0 @@
|
|||||||
!function(n,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((n=n||self).workbox={})}(this,function(n){"use strict";try{self["workbox:window:4.3.1"]&&_()}catch(n){}var t=function(n,t){return new Promise(function(i){var e=new MessageChannel;e.port1.onmessage=function(n){return i(n.data)},n.postMessage(t,[e.port2])})};function i(n,t){for(var i=0;i<t.length;i++){var e=t[i];e.enumerable=e.enumerable||!1,e.configurable=!0,"value"in e&&(e.writable=!0),Object.defineProperty(n,e.key,e)}}function e(n){if(void 0===n)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return n}try{self["workbox:core:4.3.1"]&&_()}catch(n){}var r=function(){var n=this;this.promise=new Promise(function(t,i){n.resolve=t,n.reject=i})},o=function(n,t){return new URL(n,location).href===new URL(t,location).href},u=function(n,t){Object.assign(this,t,{type:n})};function s(n){return function(){for(var t=[],i=0;i<arguments.length;i++)t[i]=arguments[i];try{return Promise.resolve(n.apply(this,t))}catch(n){return Promise.reject(n)}}}function a(n,t,i){return i?t?t(n):n:(n&&n.then||(n=Promise.resolve(n)),t?n.then(t):n)}function c(){}var f=function(n){var f,h;function v(t,i){var o;return void 0===i&&(i={}),(o=n.call(this)||this).t=t,o.i=i,o.o=0,o.u=new r,o.s=new r,o.h=new r,o.v=o.v.bind(e(e(o))),o.l=o.l.bind(e(e(o))),o.g=o.g.bind(e(e(o))),o.m=o.m.bind(e(e(o))),o}h=n,(f=v).prototype=Object.create(h.prototype),f.prototype.constructor=f,f.__proto__=h;var l,w,d,g=v.prototype;return g.register=s(function(n){var t,i,e=this,r=(void 0===n?{}:n).immediate,s=void 0!==r&&r;return t=function(){return e.p=Boolean(navigator.serviceWorker.controller),e.P=e.j(),a(e.O(),function(n){e.R=n,e.P&&(e._=e.P,e.s.resolve(e.P),e.h.resolve(e.P),e.k(e.P),e.P.addEventListener("statechange",e.l,{once:!0}));var t=e.R.waiting;return t&&o(t.scriptURL,e.t)&&(e._=t,Promise.resolve().then(function(){e.dispatchEvent(new u("waiting",{sw:t,wasWaitingBeforeRegister:!0}))})),e._&&e.u.resolve(e._),e.R.addEventListener("updatefound",e.g),navigator.serviceWorker.addEventListener("controllerchange",e.m,{once:!0}),"BroadcastChannel"in self&&(e.B=new BroadcastChannel("workbox"),e.B.addEventListener("message",e.v)),navigator.serviceWorker.addEventListener("message",e.v),e.R})},(i=function(){if(!s&&"complete"!==document.readyState)return function(n,t){if(!t)return n&&n.then?n.then(c):Promise.resolve()}(new Promise(function(n){return addEventListener("load",n)}))}())&&i.then?i.then(t):t(i)}),g.getSW=s(function(){return this._||this.u.promise}),g.messageSW=s(function(n){return a(this.getSW(),function(i){return t(i,n)})}),g.j=function(){var n=navigator.serviceWorker.controller;if(n&&o(n.scriptURL,this.t))return n},g.O=s(function(){var n=this;return function(n,t){try{var i=n()}catch(n){return t(n)}return i&&i.then?i.then(void 0,t):i}(function(){return a(navigator.serviceWorker.register(n.t,n.i),function(t){return n.C=performance.now(),t})},function(n){throw n})}),g.k=function(n){t(n,{type:"WINDOW_READY",meta:"workbox-window"})},g.g=function(){var n=this.R.installing;this.o>0||!o(n.scriptURL,this.t)||performance.now()>this.C+6e4?(this.L=n,this.R.removeEventListener("updatefound",this.g)):(this._=n,this.u.resolve(n)),++this.o,n.addEventListener("statechange",this.l)},g.l=function(n){var t=this,i=n.target,e=i.state,r=i===this.L,o=r?"external":"",s={sw:i,originalEvent:n};!r&&this.p&&(s.isUpdate=!0),this.dispatchEvent(new u(o+e,s)),"installed"===e?this.W=setTimeout(function(){"installed"===e&&t.R.waiting===i&&t.dispatchEvent(new u(o+"waiting",s))},200):"activating"===e&&(clearTimeout(this.W),r||this.s.resolve(i))},g.m=function(n){var t=this._;t===navigator.serviceWorker.controller&&(this.dispatchEvent(new u("controlling",{sw:t,originalEvent:n})),this.h.resolve(t))},g.v=function(n){var t=n.data;this.dispatchEvent(new u("message",{data:t,originalEvent:n}))},l=v,(w=[{key:"active",get:function(){return this.s.promise}},{key:"controlling",get:function(){return this.h.promise}}])&&i(l.prototype,w),d&&i(l,d),v}(function(){function n(){this.D={}}var t=n.prototype;return t.addEventListener=function(n,t){this.M(n).add(t)},t.removeEventListener=function(n,t){this.M(n).delete(t)},t.dispatchEvent=function(n){n.target=this,this.M(n.type).forEach(function(t){return t(n)})},t.M=function(n){return this.D[n]=this.D[n]||new Set},n}());n.Workbox=f,n.messageSW=t,Object.defineProperty(n,"__esModule",{value:!0})});
|
|
||||||
//# sourceMappingURL=workbox-window.prod.umd.js.map
|
|
@@ -1,32 +0,0 @@
|
|||||||
const { readdirSync, writeFileSync } = require("fs");
|
|
||||||
const files = readdirSync(`${__dirname}/../src/locales`);
|
|
||||||
|
|
||||||
const flatten = (object) =>
|
|
||||||
Object.keys(object).reduce(
|
|
||||||
(initial, current) => ({ ...initial, ...object[current] }),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
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 = (100 * translatedKeys.length) / allKeys.length;
|
|
||||||
|
|
||||||
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(
|
|
||||||
`${__dirname}/../src/locales/percentages.json`,
|
|
||||||
`${JSON.stringify(percentages, null, 2)}\n`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
@@ -5,13 +5,13 @@
|
|||||||
|
|
||||||
// In order to run:
|
// In order to run:
|
||||||
// npm install canvas # please do not check it in
|
// npm install canvas # please do not check it in
|
||||||
// yarn build-node
|
// npm run build-node
|
||||||
// node build/static/js/build-node.js
|
// node build/static/js/build-node.js
|
||||||
// open test.png
|
// open test.png
|
||||||
|
|
||||||
const rewire = require("rewire");
|
var rewire = require("rewire");
|
||||||
const defaults = rewire("react-scripts/scripts/build.js");
|
var defaults = rewire("react-scripts/scripts/build.js");
|
||||||
const config = defaults.__get__("config");
|
var config = defaults.__get__("config");
|
||||||
|
|
||||||
// Disable multiple chunks
|
// Disable multiple chunks
|
||||||
config.optimization.runtimeChunk = false;
|
config.optimization.runtimeChunk = false;
|
||||||
@@ -29,7 +29,7 @@ config.entry = "./src/index-node";
|
|||||||
// By default, webpack is going to replace the require of the canvas.node file
|
// 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
|
// to just a string with the path of the canvas.node file. We need to tell
|
||||||
// webpack to avoid rewriting that dependency.
|
// webpack to avoid rewriting that dependency.
|
||||||
config.externals = (context, request, callback) => {
|
config.externals = function (context, request, callback) {
|
||||||
if (/\.node$/.test(request)) {
|
if (/\.node$/.test(request)) {
|
||||||
return callback(
|
return callback(
|
||||||
null,
|
null,
|
||||||
|
@@ -2,60 +2,36 @@
|
|||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const versionFile = path.join("build", "version.json");
|
const asar = require("asar");
|
||||||
const indexFile = path.join("build", "index.html");
|
|
||||||
|
|
||||||
const versionDate = (date) => date.toISOString().replace(".000", "");
|
const zero = (digit) => `0${digit}`.slice(-2);
|
||||||
|
|
||||||
const commitHash = () => {
|
const versionDate = (date) => {
|
||||||
try {
|
const date_ = `${date.getFullYear()}-${zero(date.getMonth() + 1)}-${zero(
|
||||||
return require("child_process")
|
date.getDate(),
|
||||||
.execSync("git rev-parse --short HEAD")
|
)}`;
|
||||||
.toString()
|
const time = `${zero(date.getHours())}-${zero(date.getMinutes())}-${zero(
|
||||||
.trim();
|
date.getSeconds(),
|
||||||
} catch {
|
)}`;
|
||||||
return "none";
|
return `${date_}-${time}`;
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitDate = (hash) => {
|
const now = new Date();
|
||||||
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(
|
const data = JSON.stringify(
|
||||||
{
|
{
|
||||||
version: getFullVersion(),
|
asar: `excalidraw.asar`,
|
||||||
|
version: versionDate(now),
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
|
|
||||||
fs.writeFileSync(versionFile, data);
|
fs.writeFileSync(path.join("build", "version.json"), data);
|
||||||
|
|
||||||
// https://stackoverflow.com/a/14181136/8418
|
(async () => {
|
||||||
fs.readFile(indexFile, "utf8", (error, data) => {
|
const src = "build/";
|
||||||
if (error) {
|
const dest = path.join("build", `excalidraw.asar`);
|
||||||
return console.error(error);
|
|
||||||
}
|
|
||||||
const result = data.replace(/{version}/g, getFullVersion());
|
|
||||||
|
|
||||||
fs.writeFile(indexFile, result, "utf8", (error) => {
|
await asar.createPackage(src, dest);
|
||||||
if (error) {
|
})();
|
||||||
return console.error(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
@@ -1,164 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
const THRESSHOLD = 85;
|
|
||||||
|
|
||||||
const crowdinMap = {
|
|
||||||
"ar-SA": "en-ar",
|
|
||||||
"bg-BG": "en-bg",
|
|
||||||
"ca-ES": "en-ca",
|
|
||||||
"de-DE": "en-de",
|
|
||||||
"el-GR": "en-el",
|
|
||||||
"es-ES": "en-es",
|
|
||||||
"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",
|
|
||||||
"sk-SK": "en-sk",
|
|
||||||
"sv-SE": "en-sv",
|
|
||||||
"tr-TR": "en-tr",
|
|
||||||
"uk-UA": "en-uk",
|
|
||||||
"zh-CN": "en-zhcn",
|
|
||||||
"zh-TW": "en-zhtw",
|
|
||||||
};
|
|
||||||
|
|
||||||
const flags = {
|
|
||||||
"ar-SA": "🇸🇦",
|
|
||||||
"bg-BG": "🇧🇬",
|
|
||||||
"ca-ES": "🏳",
|
|
||||||
"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": "🏳",
|
|
||||||
"ko-KR": "🇰🇷",
|
|
||||||
"my-MM": "🇲🇲",
|
|
||||||
"nb-NO": "🇳🇴",
|
|
||||||
"nl-NL": "🇳🇱",
|
|
||||||
"nn-NO": "🇳🇴",
|
|
||||||
"oc-FR": "🏳",
|
|
||||||
"pa-IN": "🇮🇳",
|
|
||||||
"pl-PL": "🇵🇱",
|
|
||||||
"pt-BR": "🇧🇷",
|
|
||||||
"pt-PT": "🇵🇹",
|
|
||||||
"ro-RO": "🇷🇴",
|
|
||||||
"ru-RU": "🇷🇺",
|
|
||||||
"sk-SK": "🇸🇰",
|
|
||||||
"sv-SE": "🇸🇪",
|
|
||||||
"tr-TR": "🇹🇷",
|
|
||||||
"uk-UA": "🇺🇦",
|
|
||||||
"zh-CN": "🇨🇳",
|
|
||||||
"zh-TW": "🇹🇼",
|
|
||||||
};
|
|
||||||
|
|
||||||
const languages = {
|
|
||||||
"ar-SA": "العربية",
|
|
||||||
"bg-BG": "Български",
|
|
||||||
"ca-ES": "Català",
|
|
||||||
"de-DE": "Deutsch",
|
|
||||||
"el-GR": "Ελληνικά",
|
|
||||||
"es-ES": "Español",
|
|
||||||
"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",
|
|
||||||
"ko-KR": "한국어",
|
|
||||||
"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": "Русский",
|
|
||||||
"sk-SK": "Slovenčina",
|
|
||||||
"sv-SE": "Svenska",
|
|
||||||
"tr-TR": "Türkçe",
|
|
||||||
"uk-UA": "Українська",
|
|
||||||
"zh-CN": "简体中文",
|
|
||||||
"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.");
|
|
@@ -1,21 +0,0 @@
|
|||||||
import { register } from "./register";
|
|
||||||
import { getSelectedElements } from "../scene";
|
|
||||||
import { getNonDeletedElements } from "../element";
|
|
||||||
import { deepCopyElement } from "../element/newElement";
|
|
||||||
import { Library } from "../data/library";
|
|
||||||
|
|
||||||
export const actionAddToLibrary = register({
|
|
||||||
name: "addToLibrary",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
|
|
||||||
Library.loadLibrary().then((items) => {
|
|
||||||
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
contextItemLabel: "labels.addToLibrary",
|
|
||||||
});
|
|
@@ -1,207 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { alignElements, Alignment } from "../align";
|
|
||||||
import {
|
|
||||||
AlignBottomIcon,
|
|
||||||
AlignLeftIcon,
|
|
||||||
AlignRightIcon,
|
|
||||||
AlignTopIcon,
|
|
||||||
CenterHorizontallyIcon,
|
|
||||||
CenterVerticallyIcon,
|
|
||||||
} from "../components/icons";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
|
||||||
import { getElementMap, 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 { 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 = getElementMap(updatedElements);
|
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[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)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
@@ -1,21 +1,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { getDefaultAppState } from "../appState";
|
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
import { getDefaultAppState } from "../appState";
|
||||||
|
import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { ZOOM_STEP } from "../constants";
|
|
||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import { getNormalizedZoom, calculateScrollCenter } from "../scene";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
|
||||||
import { getNewZoom } from "../scene/zoom";
|
|
||||||
import { AppState, NormalizedZoomValue } from "../types";
|
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
|
import useIsMobile from "../is-mobile";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
import { AppState, FlooredNumber } from "../types";
|
||||||
|
import { getCommonBounds } from "../element";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
@@ -48,14 +44,7 @@ export const actionClearCanvas = register({
|
|||||||
),
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...getDefaultAppState(),
|
...getDefaultAppState(),
|
||||||
theme: appState.theme,
|
username: appState.username,
|
||||||
elementLocked: appState.elementLocked,
|
|
||||||
exportBackground: appState.exportBackground,
|
|
||||||
exportEmbedScene: appState.exportEmbedScene,
|
|
||||||
gridSize: appState.gridSize,
|
|
||||||
shouldAddWatermark: appState.shouldAddWatermark,
|
|
||||||
showStats: appState.showStats,
|
|
||||||
pasteDialog: appState.pasteDialog,
|
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -69,6 +58,10 @@ export const actionClearCanvas = register({
|
|||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(t("alerts.clearReset"))) {
|
if (window.confirm(t("alerts.clearReset"))) {
|
||||||
|
// TODO: Defined globally, since file handles aren't yet serializable.
|
||||||
|
// Once `FileSystemFileHandle` can be serialized, make this
|
||||||
|
// part of `AppState`.
|
||||||
|
(window as any).handle = null;
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -76,19 +69,25 @@ export const actionClearCanvas = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ZOOM_STEP = 0.1;
|
||||||
|
|
||||||
|
const KEY_CODES = {
|
||||||
|
MINUS: "Minus",
|
||||||
|
EQUAL: "Equal",
|
||||||
|
ONE: "Digit1",
|
||||||
|
ZERO: "Digit0",
|
||||||
|
NUM_SUBTRACT: "NumpadSubtract",
|
||||||
|
NUM_ADD: "NumpadAdd",
|
||||||
|
NUM_ZERO: "Numpad0",
|
||||||
|
};
|
||||||
|
|
||||||
export const actionZoomIn = register({
|
export const actionZoomIn = register({
|
||||||
name: "zoomIn",
|
name: "zoomIn",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
const zoom = getNewZoom(
|
|
||||||
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
|
||||||
appState.zoom,
|
|
||||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
|
||||||
{ x: appState.width / 2, y: appState.height / 2 },
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom,
|
zoom: getNormalizedZoom(appState.zoom + ZOOM_STEP),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -105,24 +104,17 @@ export const actionZoomIn = register({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === CODES.EQUAL || event.code === CODES.NUM_ADD) &&
|
(event.code === KEY_CODES.EQUAL || event.code === KEY_CODES.NUM_ADD) &&
|
||||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionZoomOut = register({
|
export const actionZoomOut = register({
|
||||||
name: "zoomOut",
|
name: "zoomOut",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
const zoom = getNewZoom(
|
|
||||||
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
|
||||||
appState.zoom,
|
|
||||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
|
||||||
{ x: appState.width / 2, y: appState.height / 2 },
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom,
|
zoom: getNormalizedZoom(appState.zoom - ZOOM_STEP),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -139,7 +131,7 @@ export const actionZoomOut = register({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === CODES.MINUS || event.code === CODES.NUM_SUBTRACT) &&
|
(event.code === KEY_CODES.MINUS || event.code === KEY_CODES.NUM_SUBTRACT) &&
|
||||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,15 +141,7 @@ export const actionResetZoom = register({
|
|||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom: getNewZoom(
|
zoom: 1,
|
||||||
1 as NormalizedZoomValue,
|
|
||||||
appState.zoom,
|
|
||||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
|
||||||
{
|
|
||||||
x: appState.width / 2,
|
|
||||||
y: appState.height / 2,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -174,86 +158,66 @@ export const actionResetZoom = register({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
(event.code === KEY_CODES.ZERO || event.code === KEY_CODES.NUM_ZERO) &&
|
||||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||||
});
|
});
|
||||||
|
|
||||||
const zoomValueToFitBoundsOnViewport = (
|
const calculateZoom = (
|
||||||
bounds: [number, number, number, number],
|
commonBounds: number[],
|
||||||
viewportDimensions: { width: number; height: number },
|
currentZoom: number,
|
||||||
) => {
|
{
|
||||||
const [x1, y1, x2, y2] = bounds;
|
scrollX,
|
||||||
const commonBoundsWidth = x2 - x1;
|
scrollY,
|
||||||
const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth;
|
}: {
|
||||||
const commonBoundsHeight = y2 - y1;
|
scrollX: FlooredNumber;
|
||||||
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
|
scrollY: FlooredNumber;
|
||||||
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
|
},
|
||||||
const zoomAdjustedToSteps =
|
): number => {
|
||||||
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
const { innerWidth, innerHeight } = window;
|
||||||
const clampedZoomValueToFitElements = Math.min(
|
const [x, y] = commonBounds;
|
||||||
Math.max(zoomAdjustedToSteps, ZOOM_STEP),
|
const zoomX = -innerWidth / (2 * scrollX + 2 * x - innerWidth);
|
||||||
1,
|
const zoomY = -innerHeight / (2 * scrollY + 2 * y - innerHeight);
|
||||||
);
|
const margin = 0.01;
|
||||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
let newZoom;
|
||||||
|
|
||||||
|
if (zoomX < zoomY) {
|
||||||
|
newZoom = zoomX - margin;
|
||||||
|
} else if (zoomY <= zoomX) {
|
||||||
|
newZoom = zoomY - margin;
|
||||||
|
} else {
|
||||||
|
newZoom = currentZoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newZoom <= 0.1) {
|
||||||
|
return 0.1;
|
||||||
|
}
|
||||||
|
if (newZoom >= 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newZoom;
|
||||||
};
|
};
|
||||||
|
|
||||||
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 zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
|
||||||
width: appState.width,
|
|
||||||
height: appState.height,
|
|
||||||
});
|
|
||||||
const newZoom = getNewZoom(zoomValue, appState.zoom, {
|
|
||||||
left: appState.offsetLeft,
|
|
||||||
top: appState.offsetTop,
|
|
||||||
});
|
|
||||||
|
|
||||||
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({
|
export const actionZoomToFit = register({
|
||||||
name: "zoomToFit",
|
name: "zoomToFit",
|
||||||
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
perform: (elements, appState) => {
|
||||||
|
const nonDeletedElements = elements.filter((element) => !element.isDeleted);
|
||||||
|
const scrollCenter = calculateScrollCenter(nonDeletedElements);
|
||||||
|
const commonBounds = getCommonBounds(nonDeletedElements);
|
||||||
|
const zoom = calculateZoom(commonBounds, appState.zoom, scrollCenter);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
scrollX: scrollCenter.scrollX,
|
||||||
|
scrollY: scrollCenter.scrollY,
|
||||||
|
zoom,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.code === CODES.ONE &&
|
event.code === KEY_CODES.ONE &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
!event[KEYS.CTRL_OR_CMD],
|
!event[KEYS.CTRL_OR_CMD],
|
||||||
|
@@ -1,122 +0,0 @@
|
|||||||
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) => {
|
|
||||||
copyToClipboard(getNonDeletedElements(elements), appState);
|
|
||||||
|
|
||||||
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, data, app);
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await exportCanvas(
|
|
||||||
"clipboard-svg",
|
|
||||||
selectedElements.length
|
|
||||||
? selectedElements
|
|
||||||
: getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
app.canvas,
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await exportCanvas(
|
|
||||||
"clipboard",
|
|
||||||
selectedElements.length
|
|
||||||
? selectedElements
|
|
||||||
: getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
app.canvas,
|
|
||||||
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) {
|
|
||||||
console.error(error);
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
errorMessage: error.message,
|
|
||||||
},
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contextItemLabel: "labels.copyAsPng",
|
|
||||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
|
||||||
});
|
|
@@ -1,4 +1,4 @@
|
|||||||
import { isSomeElementSelected } from "../scene";
|
import { deleteSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@@ -6,122 +6,14 @@ import { trash } from "../components/icons";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { getNonDeletedElements } from "../element";
|
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";
|
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
) => {
|
|
||||||
return {
|
|
||||||
elements: elements.map((el) => {
|
|
||||||
if (appState.selectedElementIds[el.id]) {
|
|
||||||
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({
|
export const actionDeleteSelected = register({
|
||||||
name: "deleteSelectedElements",
|
name: "deleteSelectedElements",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
if (appState.editingLinearElement) {
|
const {
|
||||||
const {
|
|
||||||
elementId,
|
|
||||||
activePointIndex,
|
|
||||||
startBindingElement,
|
|
||||||
endBindingElement,
|
|
||||||
} = appState.editingLinearElement;
|
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
|
||||||
if (!element) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
// case: no point selected → delete whole element
|
|
||||||
activePointIndex == null ||
|
|
||||||
activePointIndex === -1 ||
|
|
||||||
// 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:
|
|
||||||
activePointIndex === 0 ? null : startBindingElement,
|
|
||||||
endBindingElement:
|
|
||||||
activePointIndex === element.points.length - 1
|
|
||||||
? null
|
|
||||||
: endBindingElement,
|
|
||||||
};
|
|
||||||
|
|
||||||
LinearElementEditor.movePoint(element, activePointIndex, "delete");
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements,
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
editingLinearElement: {
|
|
||||||
...appState.editingLinearElement,
|
|
||||||
...binding,
|
|
||||||
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: nextAppState,
|
appState: nextAppState,
|
||||||
} = deleteSelectedElements(elements, appState);
|
} = deleteSelectedElements(elements, appState);
|
||||||
fixBindingsAfterDeletion(
|
|
||||||
nextElements,
|
|
||||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
|
||||||
);
|
|
||||||
|
|
||||||
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
@@ -136,6 +28,7 @@ export const actionDeleteSelected = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.delete",
|
contextItemLabel: "labels.delete",
|
||||||
|
contextMenuOrder: 3,
|
||||||
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
|
@@ -1,91 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
DistributeHorizontallyIcon,
|
|
||||||
DistributeVerticallyIcon,
|
|
||||||
} from "../components/icons";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
|
||||||
import { distributeElements, Distribution } from "../disitrubte";
|
|
||||||
import { getElementMap, 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 { 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 = getElementMap(updatedElements);
|
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[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)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
@@ -8,63 +8,32 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { clone } from "../components/icons";
|
import { clone } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
import {
|
|
||||||
selectGroupsForSelectedElements,
|
|
||||||
getSelectedGroupForElement,
|
|
||||||
getElementsInGroup,
|
|
||||||
} from "../groups";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
|
||||||
import { ActionResult } from "./types";
|
|
||||||
import { GRID_SIZE } from "../constants";
|
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
// duplicate point if selected while editing multi-point element
|
|
||||||
if (appState.editingLinearElement) {
|
|
||||||
const { activePointIndex, elementId } = appState.editingLinearElement;
|
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
|
||||||
if (!element || activePointIndex === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const { points } = element;
|
|
||||||
const selectedPoint = points[activePointIndex];
|
|
||||||
const nextPoint = points[activePointIndex + 1];
|
|
||||||
mutateElement(element, {
|
|
||||||
points: [
|
|
||||||
...points.slice(0, activePointIndex + 1),
|
|
||||||
nextPoint
|
|
||||||
? [
|
|
||||||
(selectedPoint[0] + nextPoint[0]) / 2,
|
|
||||||
(selectedPoint[1] + nextPoint[1]) / 2,
|
|
||||||
]
|
|
||||||
: [selectedPoint[0] + 30, selectedPoint[1] + 30],
|
|
||||||
...points.slice(activePointIndex + 1),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
editingLinearElement: {
|
|
||||||
...appState.editingLinearElement,
|
|
||||||
activePointIndex: activePointIndex + 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
elements,
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...duplicateElements(elements, appState),
|
appState,
|
||||||
|
elements: elements.reduce(
|
||||||
|
(acc: Array<ExcalidrawElement>, element: ExcalidrawElement) => {
|
||||||
|
if (appState.selectedElementIds[element.id]) {
|
||||||
|
const newElement = duplicateElement(element, {
|
||||||
|
x: element.x + 10,
|
||||||
|
y: element.y + 10,
|
||||||
|
});
|
||||||
|
appState.selectedElementIds[newElement.id] = true;
|
||||||
|
delete appState.selectedElementIds[element.id];
|
||||||
|
return acc.concat([element, newElement]);
|
||||||
|
}
|
||||||
|
return acc.concat(element);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.duplicateSelection",
|
contextItemLabel: "labels.duplicateSelection",
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === "d",
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -78,74 +47,3 @@ export const actionDuplicateSelection = register({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
|
||||||
while (index < elements.length) {
|
|
||||||
const element = elements[index];
|
|
||||||
if (appState.selectedElementIds[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++;
|
|
||||||
}
|
|
||||||
|
|
||||||
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements: finalElements,
|
|
||||||
appState: selectGroupsForSelectedElements(
|
|
||||||
{
|
|
||||||
...appState,
|
|
||||||
selectedGroupIds: {},
|
|
||||||
selectedElementIds: newElements.reduce((acc, element) => {
|
|
||||||
acc[element.id] = true;
|
|
||||||
return acc;
|
|
||||||
}, {} as any),
|
|
||||||
},
|
|
||||||
getNonDeletedElements(finalElements),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
@@ -1,29 +1,23 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
import { load, questionCircle, save, saveAs } from "../components/icons";
|
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
|
import { saveAsJSON, loadFromJSON } from "../data";
|
||||||
|
import { load, save } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import "../components/ToolIcon.scss";
|
|
||||||
import { Tooltip } from "../components/Tooltip";
|
|
||||||
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
|
|
||||||
import { loadFromJSON, saveAsJSON } from "../data";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { KEYS } from "../keys";
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
export const actionChangeProjectName = register({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
trackEvent("change", "title");
|
|
||||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, appProps }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
<ProjectName
|
<ProjectName
|
||||||
label={t("labels.fileTitle")}
|
label={t("labels.fileTitle")}
|
||||||
value={appState.name || "Unnamed"}
|
value={appState.name || "Unnamed"}
|
||||||
onChange={(name: string) => updateData(name)}
|
onChange={(name: string) => updateData(name)}
|
||||||
isNameEditable={typeof appProps.name === "undefined"}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -48,33 +42,6 @@ export const actionChangeExportBackground = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeExportEmbedScene = register({
|
|
||||||
name: "changeExportEmbedScene",
|
|
||||||
perform: (_elements, appState, value) => {
|
|
||||||
return {
|
|
||||||
appState: { ...appState, exportEmbedScene: value },
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
|
||||||
<label style={{ display: "flex" }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={appState.exportEmbedScene}
|
|
||||||
onChange={(event) => updateData(event.target.checked)}
|
|
||||||
/>{" "}
|
|
||||||
{t("labels.exportEmbedScene")}
|
|
||||||
<Tooltip
|
|
||||||
label={t("labels.exportEmbedScene_details")}
|
|
||||||
position="above"
|
|
||||||
long={true}
|
|
||||||
>
|
|
||||||
<div className="TooltipIcon">{questionCircle}</div>
|
|
||||||
</Tooltip>
|
|
||||||
</label>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionChangeShouldAddWatermark = register({
|
export const actionChangeShouldAddWatermark = register({
|
||||||
name: "changeShouldAddWatermark",
|
name: "changeShouldAddWatermark",
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
@@ -97,34 +64,13 @@ export const actionChangeShouldAddWatermark = register({
|
|||||||
|
|
||||||
export const actionSaveScene = register({
|
export const actionSaveScene = register({
|
||||||
name: "saveScene",
|
name: "saveScene",
|
||||||
perform: async (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
const fileHandleExists = !!appState.fileHandle;
|
saveAsJSON(elements, appState).catch((error) => console.error(error));
|
||||||
try {
|
return { commitToHistory: false };
|
||||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
},
|
||||||
return {
|
keyTest: (event) => {
|
||||||
commitToHistory: false,
|
return event.key === "s" && event[KEYS.CTRL_OR_CMD];
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
fileHandle,
|
|
||||||
toastMessage: fileHandleExists
|
|
||||||
? fileHandle.name
|
|
||||||
? t("toast.fileSavedToFilename").replace(
|
|
||||||
"{filename}",
|
|
||||||
`"${fileHandle.name}"`,
|
|
||||||
)
|
|
||||||
: t("toast.fileSaved")
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name !== "AbortError") {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
return { commitToHistory: false };
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
|
||||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -137,100 +83,42 @@ export const actionSaveScene = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionSaveAsScene = register({
|
|
||||||
name: "saveAsScene",
|
|
||||||
perform: async (elements, appState, value) => {
|
|
||||||
try {
|
|
||||||
const { fileHandle } = await saveAsJSON(elements, {
|
|
||||||
...appState,
|
|
||||||
fileHandle: null,
|
|
||||||
});
|
|
||||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name !== "AbortError") {
|
|
||||||
console.error(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={
|
|
||||||
!("chooseFileSystemEntries" in window || "showOpenFilePicker" in window)
|
|
||||||
}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionLoadScene = register({
|
export const actionLoadScene = register({
|
||||||
name: "loadScene",
|
name: "loadScene",
|
||||||
perform: async (elements, appState) => {
|
perform: (
|
||||||
try {
|
elements,
|
||||||
const {
|
appState,
|
||||||
elements: loadedElements,
|
{ elements: loadedElements, appState: loadedAppState, error },
|
||||||
appState: loadedAppState,
|
) => {
|
||||||
} = await loadFromJSON(appState);
|
return {
|
||||||
return {
|
elements: loadedElements,
|
||||||
elements: loadedElements,
|
appState: {
|
||||||
appState: loadedAppState,
|
...loadedAppState,
|
||||||
commitToHistory: true,
|
errorMessage: error,
|
||||||
};
|
},
|
||||||
} catch (error) {
|
commitToHistory: false,
|
||||||
if (error?.name === "AbortError") {
|
};
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
elements,
|
|
||||||
appState: { ...appState, errorMessage: error.message },
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
PanelComponent: ({ updateData }) => (
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={load}
|
icon={load}
|
||||||
title={t("buttons.load")}
|
title={t("buttons.load")}
|
||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={updateData}
|
onClick={() => {
|
||||||
|
loadFromJSON()
|
||||||
|
.then(({ elements, appState }) => {
|
||||||
|
updateData({ elements: elements, appState: appState });
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// if user cancels, ignore the error
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateData({ error: error.message });
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
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 ? "dark" : "light"}
|
|
||||||
onChange={(theme: Appearence) => {
|
|
||||||
updateData(theme === "dark");
|
|
||||||
}}
|
|
||||||
title={t("labels.toggleExportColorScheme")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
@@ -8,47 +8,10 @@ import { t } from "../i18n";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { isPathALoop } from "../math";
|
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({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
perform: (elements, appState, _, { canvas }) => {
|
perform: (elements, appState) => {
|
||||||
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;
|
let newElements = elements;
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
window.document.activeElement.blur();
|
window.document.activeElement.blur();
|
||||||
@@ -83,17 +46,16 @@ export const actionFinalize = register({
|
|||||||
// If the multi point line closes the loop,
|
// If the multi point line closes the loop,
|
||||||
// set the last point to first point.
|
// set the last point to first point.
|
||||||
// This ensures that loop remains closed at different scales.
|
// This ensures that loop remains closed at different scales.
|
||||||
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
|
||||||
if (
|
if (
|
||||||
multiPointElement.type === "line" ||
|
multiPointElement.type === "line" ||
|
||||||
multiPointElement.type === "draw"
|
multiPointElement.type === "draw"
|
||||||
) {
|
) {
|
||||||
if (isLoop) {
|
if (isPathALoop(multiPointElement.points)) {
|
||||||
const linePoints = multiPointElement.points;
|
const linePoints = multiPointElement.points;
|
||||||
const firstPoint = linePoints[0];
|
const firstPoint = linePoints[0];
|
||||||
mutateElement(multiPointElement, {
|
mutateElement(multiPointElement, {
|
||||||
points: linePoints.map((point, index) =>
|
points: linePoints.map((point, i) =>
|
||||||
index === linePoints.length - 1
|
i === linePoints.length - 1
|
||||||
? ([firstPoint[0], firstPoint[1]] as const)
|
? ([firstPoint[0], firstPoint[1]] as const)
|
||||||
: point,
|
: point,
|
||||||
),
|
),
|
||||||
@@ -101,64 +63,39 @@ export const actionFinalize = register({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!appState.elementLocked) {
|
||||||
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 !== "draw") {
|
|
||||||
appState.selectedElementIds[multiPointElement.id] = true;
|
appState.selectedElementIds[multiPointElement.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (!appState.elementLocked || !multiPointElement) {
|
||||||
(!appState.elementLocked && appState.elementType !== "draw") ||
|
resetCursor();
|
||||||
!multiPointElement
|
|
||||||
) {
|
|
||||||
resetCursor(canvas);
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
elementType:
|
elementType:
|
||||||
(appState.elementLocked || appState.elementType === "draw") &&
|
appState.elementLocked && multiPointElement
|
||||||
multiPointElement
|
|
||||||
? appState.elementType
|
? appState.elementType
|
||||||
: "selection",
|
: "selection",
|
||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
editingElement: null,
|
editingElement: null,
|
||||||
startBoundElement: null,
|
|
||||||
suggestedBindings: [],
|
|
||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
multiPointElement &&
|
multiPointElement && !appState.elementLocked
|
||||||
!appState.elementLocked &&
|
|
||||||
appState.elementType !== "draw"
|
|
||||||
? {
|
? {
|
||||||
...appState.selectedElementIds,
|
...appState.selectedElementIds,
|
||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.elementType === "draw",
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
(event.key === KEYS.ESCAPE &&
|
(event.key === KEYS.ESCAPE &&
|
||||||
(appState.editingLinearElement !== null ||
|
!appState.draggingElement &&
|
||||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
appState.multiElement === null) ||
|
||||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||||
appState.multiElement !== null),
|
appState.multiElement !== null),
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
|
@@ -1,191 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { CODES, KEYS } from "../keys";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { getShortcutKey } from "../utils";
|
|
||||||
import { register } from "./register";
|
|
||||||
import { UngroupIcon, GroupIcon } from "../components/icons";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|
||||||
import {
|
|
||||||
getSelectedGroupIds,
|
|
||||||
selectGroup,
|
|
||||||
selectGroupsForSelectedElements,
|
|
||||||
getElementsInGroup,
|
|
||||||
addToGroup,
|
|
||||||
removeFromSelectedGroups,
|
|
||||||
isElementInGroup,
|
|
||||||
} from "../groups";
|
|
||||||
import { getNonDeletedElements } from "../element";
|
|
||||||
import { randomId } from "../random";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
|
|
||||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
|
||||||
if (elements.length >= 2) {
|
|
||||||
const groupIds = elements[0].groupIds;
|
|
||||||
for (const groupId of groupIds) {
|
|
||||||
if (
|
|
||||||
elements.reduce(
|
|
||||||
(acc, element) => acc && isElementInGroup(element, groupId),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const enableActionGroup = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
) => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actionGroup = register({
|
|
||||||
name: "group",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
if (selectedElements.length < 2) {
|
|
||||||
// nothing to group
|
|
||||||
return { appState, elements, commitToHistory: false };
|
|
||||||
}
|
|
||||||
// if everything is already grouped into 1 group, there is nothing to do
|
|
||||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
|
||||||
if (selectedGroupIds.length === 1) {
|
|
||||||
const selectedGroupId = selectedGroupIds[0];
|
|
||||||
const elementIdsInGroup = new Set(
|
|
||||||
getElementsInGroup(elements, selectedGroupId).map(
|
|
||||||
(element) => element.id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const selectedElementIds = new Set(
|
|
||||||
selectedElements.map((element) => element.id),
|
|
||||||
);
|
|
||||||
const combinedSet = new Set([
|
|
||||||
...Array.from(elementIdsInGroup),
|
|
||||||
...Array.from(selectedElementIds),
|
|
||||||
]);
|
|
||||||
if (combinedSet.size === elementIdsInGroup.size) {
|
|
||||||
// no incremental ids in the selected ids
|
|
||||||
return { appState, elements, commitToHistory: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newGroupId = randomId();
|
|
||||||
const updatedElements = elements.map((element) => {
|
|
||||||
if (!appState.selectedElementIds[element.id]) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
return newElementWith(element, {
|
|
||||||
groupIds: addToGroup(
|
|
||||||
element.groupIds,
|
|
||||||
newGroupId,
|
|
||||||
appState.editingGroupId,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// keep the z order within the group the same, but move them
|
|
||||||
// to the z order of the highest element in the layer stack
|
|
||||||
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
|
||||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
|
||||||
const lastGroupElementIndex = updatedElements.lastIndexOf(
|
|
||||||
lastElementInGroup,
|
|
||||||
);
|
|
||||||
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
|
||||||
const elementsBeforeGroup = updatedElements
|
|
||||||
.slice(0, lastGroupElementIndex)
|
|
||||||
.filter(
|
|
||||||
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
|
||||||
);
|
|
||||||
const updatedElementsInOrder = [
|
|
||||||
...elementsBeforeGroup,
|
|
||||||
...elementsInGroup,
|
|
||||||
...elementsAfterGroup,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
appState: selectGroup(
|
|
||||||
newGroupId,
|
|
||||||
{ ...appState, selectedGroupIds: {} },
|
|
||||||
getNonDeletedElements(updatedElementsInOrder),
|
|
||||||
),
|
|
||||||
elements: updatedElementsInOrder,
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
contextItemLabel: "labels.group",
|
|
||||||
contextItemPredicate: (elements, appState) =>
|
|
||||||
enableActionGroup(elements, appState),
|
|
||||||
keyTest: (event) =>
|
|
||||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
|
||||||
type="button"
|
|
||||||
icon={<GroupIcon theme={appState.theme} />}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
title={`${t("labels.group")} — ${getShortcutKey("CtrlOrCmd+G")}`}
|
|
||||||
aria-label={t("labels.group")}
|
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
|
||||||
></ToolButton>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionUngroup = register({
|
|
||||||
name: "ungroup",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
const groupIds = getSelectedGroupIds(appState);
|
|
||||||
if (groupIds.length === 0) {
|
|
||||||
return { appState, elements, commitToHistory: false };
|
|
||||||
}
|
|
||||||
const nextElements = elements.map((element) => {
|
|
||||||
const nextGroupIds = removeFromSelectedGroups(
|
|
||||||
element.groupIds,
|
|
||||||
appState.selectedGroupIds,
|
|
||||||
);
|
|
||||||
if (nextGroupIds.length === element.groupIds.length) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
return newElementWith(element, {
|
|
||||||
groupIds: nextGroupIds,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
appState: selectGroupsForSelectedElements(
|
|
||||||
{ ...appState, selectedGroupIds: {} },
|
|
||||||
getNonDeletedElements(nextElements),
|
|
||||||
),
|
|
||||||
elements: nextElements,
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
keyTest: (event) =>
|
|
||||||
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
|
||||||
contextItemLabel: "labels.ungroup",
|
|
||||||
contextItemPredicate: (elements, appState) =>
|
|
||||||
getSelectedGroupIds(appState).length > 0,
|
|
||||||
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
hidden={getSelectedGroupIds(appState).length === 0}
|
|
||||||
icon={<UngroupIcon theme={appState.theme} />}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
title={`${t("labels.ungroup")} — ${getShortcutKey("CtrlOrCmd+Shift+G")}`}
|
|
||||||
aria-label={t("labels.ungroup")}
|
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
|
||||||
></ToolButton>
|
|
||||||
),
|
|
||||||
});
|
|
@@ -3,18 +3,20 @@ import React from "react";
|
|||||||
import { undo, redo } from "../components/icons";
|
import { undo, redo } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { SceneHistory, HistoryEntry } from "../history";
|
import { SceneHistory } from "../history";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isWindows, KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getElementMap } from "../element";
|
import { getElementMap } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
|
||||||
|
|
||||||
const writeData = (
|
const writeData = (
|
||||||
prevElements: readonly ExcalidrawElement[],
|
prevElements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
updater: () => HistoryEntry | null,
|
updater: () => {
|
||||||
|
elements: ExcalidrawElement[];
|
||||||
|
appState: AppState;
|
||||||
|
} | null,
|
||||||
): ActionResult => {
|
): ActionResult => {
|
||||||
const commitToHistory = false;
|
const commitToHistory = false;
|
||||||
if (
|
if (
|
||||||
@@ -31,44 +33,40 @@ const writeData = (
|
|||||||
const prevElementMap = getElementMap(prevElements);
|
const prevElementMap = getElementMap(prevElements);
|
||||||
const nextElements = data.elements;
|
const nextElements = data.elements;
|
||||||
const nextElementMap = getElementMap(nextElements);
|
const nextElementMap = getElementMap(nextElements);
|
||||||
|
|
||||||
const deletedElements = prevElements.filter(
|
|
||||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
|
||||||
);
|
|
||||||
const elements = nextElements
|
|
||||||
.map((nextElement) =>
|
|
||||||
newElementWith(
|
|
||||||
prevElementMap[nextElement.id] || nextElement,
|
|
||||||
nextElement,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.concat(
|
|
||||||
deletedElements.map((prevElement) =>
|
|
||||||
newElementWith(prevElement, { isDeleted: true }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
fixBindingsAfterDeletion(elements, deletedElements);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements: nextElements
|
||||||
|
.map((nextElement) =>
|
||||||
|
newElementWith(
|
||||||
|
prevElementMap[nextElement.id] || nextElement,
|
||||||
|
nextElement,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.concat(
|
||||||
|
prevElements
|
||||||
|
.filter(
|
||||||
|
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
||||||
|
)
|
||||||
|
.map((prevElement) =>
|
||||||
|
newElementWith(prevElement, { isDeleted: true }),
|
||||||
|
),
|
||||||
|
),
|
||||||
appState: { ...appState, ...data.appState },
|
appState: { ...appState, ...data.appState },
|
||||||
commitToHistory,
|
commitToHistory,
|
||||||
syncHistory: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { commitToHistory };
|
return { commitToHistory };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const testUndo = (shift: boolean) => (event: KeyboardEvent) =>
|
||||||
|
event[KEYS.CTRL_OR_CMD] && /z/i.test(event.key) && event.shiftKey === shift;
|
||||||
|
|
||||||
type ActionCreator = (history: SceneHistory) => Action;
|
type ActionCreator = (history: SceneHistory) => Action;
|
||||||
|
|
||||||
export const createUndoAction: ActionCreator = (history) => ({
|
export const createUndoAction: ActionCreator = (history) => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
writeData(elements, appState, () => history.undoOnce()),
|
writeData(elements, appState, () => history.undoOnce()),
|
||||||
keyTest: (event) =>
|
keyTest: testUndo(false),
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
|
||||||
event.key.toLowerCase() === KEYS.Z &&
|
|
||||||
!event.shiftKey,
|
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -84,11 +82,7 @@ export const createRedoAction: ActionCreator = (history) => ({
|
|||||||
name: "redo",
|
name: "redo",
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
writeData(elements, appState, () => history.redoOnce()),
|
writeData(elements, appState, () => history.redoOnce()),
|
||||||
keyTest: (event) =>
|
keyTest: testUndo(true),
|
||||||
(event[KEYS.CTRL_OR_CMD] &&
|
|
||||||
event.shiftKey &&
|
|
||||||
event.key.toLowerCase() === KEYS.Z) ||
|
|
||||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
|
@@ -5,7 +5,7 @@ import { t } from "../i18n";
|
|||||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { HelpIcon } from "../components/HelpIcon";
|
import { HelpIcon } from "../components/HelpIcon";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
@@ -65,7 +65,7 @@ export const actionFullScreen = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.code === CODES.F && !event[KEYS.CTRL_OR_CMD],
|
keyTest: (event) => event.keyCode === KEYS.F_KEY_CODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
@@ -74,13 +74,13 @@ export const actionShortcuts = register({
|
|||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
showHelpDialog: !appState.showHelpDialog,
|
showShortcutsDialog: true,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<HelpIcon title={t("helpDialog.title")} onClick={updateData} />
|
<HelpIcon title={t("shortcutsDialog.title")} onClick={updateData} />
|
||||||
),
|
),
|
||||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||||
});
|
});
|
||||||
|
@@ -1,58 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { getClientColors, getClientInitials } from "../clients";
|
|
||||||
import { Avatar } from "../components/Avatar";
|
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
|
||||||
import { Collaborator } from "../types";
|
|
||||||
import { register } from "./register";
|
|
||||||
|
|
||||||
export const actionGoToCollaborator = register({
|
|
||||||
name: "goToCollaborator",
|
|
||||||
perform: (_elements, appState, value) => {
|
|
||||||
const point = value as Collaborator["pointer"];
|
|
||||||
if (!point) {
|
|
||||||
return { appState, commitToHistory: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
...centerScrollOn({
|
|
||||||
scenePoint: point,
|
|
||||||
viewportDimensions: {
|
|
||||||
width: appState.width,
|
|
||||||
height: appState.height,
|
|
||||||
},
|
|
||||||
zoom: appState.zoom,
|
|
||||||
}),
|
|
||||||
// Close mobile menu
|
|
||||||
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
|
||||||
},
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ appState, updateData, id }) => {
|
|
||||||
const clientId = id;
|
|
||||||
if (!clientId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collaborator = appState.collaborators.get(clientId);
|
|
||||||
|
|
||||||
if (!collaborator) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { background, stroke } = getClientColors(clientId, appState);
|
|
||||||
const shortName = getClientInitials(collaborator.username);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Avatar
|
|
||||||
color={background}
|
|
||||||
border={stroke}
|
|
||||||
onClick={() => updateData(collaborator.pointer)}
|
|
||||||
>
|
|
||||||
{shortName}
|
|
||||||
</Avatar>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
@@ -1,53 +1,25 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { AppState } from "../../src/types";
|
|
||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
|
||||||
import { ButtonSelect } from "../components/ButtonSelect";
|
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
|
||||||
import { IconPicker } from "../components/IconPicker";
|
|
||||||
import {
|
import {
|
||||||
ArrowheadArrowIcon,
|
|
||||||
ArrowheadBarIcon,
|
|
||||||
ArrowheadDotIcon,
|
|
||||||
ArrowheadNoneIcon,
|
|
||||||
EdgeRoundIcon,
|
|
||||||
EdgeSharpIcon,
|
|
||||||
FillCrossHatchIcon,
|
|
||||||
FillHachureIcon,
|
|
||||||
FillSolidIcon,
|
|
||||||
SloppinessArchitectIcon,
|
|
||||||
SloppinessArtistIcon,
|
|
||||||
SloppinessCartoonistIcon,
|
|
||||||
StrokeStyleDashedIcon,
|
|
||||||
StrokeStyleDottedIcon,
|
|
||||||
StrokeStyleSolidIcon,
|
|
||||||
StrokeWidthIcon,
|
|
||||||
} from "../components/icons";
|
|
||||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
|
|
||||||
import {
|
|
||||||
getNonDeletedElements,
|
|
||||||
isTextElement,
|
|
||||||
redrawTextBoundingBox,
|
|
||||||
} from "../element";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
|
||||||
import {
|
|
||||||
Arrowhead,
|
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
FontFamily,
|
|
||||||
TextAlign,
|
TextAlign,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getLanguage, t } from "../i18n";
|
|
||||||
import { randomInteger } from "../random";
|
|
||||||
import {
|
import {
|
||||||
canChangeSharpness,
|
|
||||||
canHaveArrowheads,
|
|
||||||
getCommonAttributeOfSelectedElements,
|
getCommonAttributeOfSelectedElements,
|
||||||
getTargetElements,
|
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
import { ButtonSelect } from "../components/ButtonSelect";
|
||||||
|
import {
|
||||||
|
isTextElement,
|
||||||
|
redrawTextBoundingBox,
|
||||||
|
getNonDeletedElements,
|
||||||
|
} from "../element";
|
||||||
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
|
import { AppState } from "../../src/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { DEFAULT_FONT } from "../appState";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
|
||||||
const changeProperty = (
|
const changeProperty = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -164,23 +136,11 @@ export const actionChangeFillStyle = register({
|
|||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fill")}</legend>
|
<legend>{t("labels.fill")}</legend>
|
||||||
<ButtonIconSelect
|
<ButtonSelect
|
||||||
options={[
|
options={[
|
||||||
{
|
{ value: "hachure", text: t("labels.hachure") },
|
||||||
value: "hachure",
|
{ value: "cross-hatch", text: t("labels.crossHatch") },
|
||||||
text: t("labels.hachure"),
|
{ value: "solid", text: t("labels.solid") },
|
||||||
icon: <FillHachureIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "cross-hatch",
|
|
||||||
text: t("labels.crossHatch"),
|
|
||||||
icon: <FillCrossHatchIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "solid",
|
|
||||||
text: t("labels.solid"),
|
|
||||||
icon: <FillSolidIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
group="fill"
|
group="fill"
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
@@ -213,24 +173,12 @@ export const actionChangeStrokeWidth = register({
|
|||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.strokeWidth")}</legend>
|
<legend>{t("labels.strokeWidth")}</legend>
|
||||||
<ButtonIconSelect
|
<ButtonSelect
|
||||||
group="stroke-width"
|
group="stroke-width"
|
||||||
options={[
|
options={[
|
||||||
{
|
{ value: 1, text: t("labels.thin") },
|
||||||
value: 1,
|
{ value: 2, text: t("labels.bold") },
|
||||||
text: t("labels.thin"),
|
{ value: 4, text: t("labels.extraBold") },
|
||||||
icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={2} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 2,
|
|
||||||
text: t("labels.bold"),
|
|
||||||
icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={6} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 4,
|
|
||||||
text: t("labels.extraBold"),
|
|
||||||
icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={10} />,
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
@@ -250,7 +198,6 @@ export const actionChangeSloppiness = register({
|
|||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
seed: randomInteger(),
|
|
||||||
roughness: value,
|
roughness: value,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -261,24 +208,12 @@ export const actionChangeSloppiness = register({
|
|||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.sloppiness")}</legend>
|
<legend>{t("labels.sloppiness")}</legend>
|
||||||
<ButtonIconSelect
|
<ButtonSelect
|
||||||
group="sloppiness"
|
group="sloppiness"
|
||||||
options={[
|
options={[
|
||||||
{
|
{ value: 0, text: t("labels.architect") },
|
||||||
value: 0,
|
{ value: 1, text: t("labels.artist") },
|
||||||
text: t("labels.architect"),
|
{ value: 2, text: t("labels.cartoonist") },
|
||||||
icon: <SloppinessArchitectIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 1,
|
|
||||||
text: t("labels.artist"),
|
|
||||||
icon: <SloppinessArtistIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 2,
|
|
||||||
text: t("labels.cartoonist"),
|
|
||||||
icon: <SloppinessCartoonistIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
@@ -292,53 +227,6 @@ export const actionChangeSloppiness = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeStrokeStyle = register({
|
|
||||||
name: "changeStrokeStyle",
|
|
||||||
perform: (elements, appState, value) => {
|
|
||||||
return {
|
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
|
||||||
newElementWith(el, {
|
|
||||||
strokeStyle: value,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
appState: { ...appState, currentItemStrokeStyle: value },
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<fieldset>
|
|
||||||
<legend>{t("labels.strokeStyle")}</legend>
|
|
||||||
<ButtonIconSelect
|
|
||||||
group="strokeStyle"
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: "solid",
|
|
||||||
text: t("labels.strokeStyle_solid"),
|
|
||||||
icon: <StrokeStyleSolidIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "dashed",
|
|
||||||
text: t("labels.strokeStyle_dashed"),
|
|
||||||
icon: <StrokeStyleDashedIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "dotted",
|
|
||||||
text: t("labels.strokeStyle_dotted"),
|
|
||||||
icon: <StrokeStyleDottedIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={getFormValue(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(element) => element.strokeStyle,
|
|
||||||
appState.currentItemStrokeStyle,
|
|
||||||
)}
|
|
||||||
onChange={(value) => updateData(value)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionChangeOpacity = register({
|
export const actionChangeOpacity = register({
|
||||||
name: "changeOpacity",
|
name: "changeOpacity",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
@@ -395,7 +283,7 @@ export const actionChangeFontSize = register({
|
|||||||
elements: changeProperty(elements, appState, (el) => {
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
if (isTextElement(el)) {
|
if (isTextElement(el)) {
|
||||||
const element: ExcalidrawTextElement = newElementWith(el, {
|
const element: ExcalidrawTextElement = newElementWith(el, {
|
||||||
fontSize: value,
|
font: `${value}px ${el.font.split("px ")[1]}`,
|
||||||
});
|
});
|
||||||
redrawTextBoundingBox(element);
|
redrawTextBoundingBox(element);
|
||||||
return element;
|
return element;
|
||||||
@@ -405,7 +293,9 @@ export const actionChangeFontSize = register({
|
|||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemFontSize: value,
|
currentItemFont: `${value}px ${
|
||||||
|
appState.currentItemFont.split("px ")[1]
|
||||||
|
}`,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -424,8 +314,8 @@ export const actionChangeFontSize = register({
|
|||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(element) => isTextElement(element) && element.fontSize,
|
(element) => isTextElement(element) && +element.font.split("px ")[0],
|
||||||
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
+(appState.currentItemFont || DEFAULT_FONT).split("px ")[0],
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
/>
|
/>
|
||||||
@@ -440,7 +330,7 @@ export const actionChangeFontFamily = register({
|
|||||||
elements: changeProperty(elements, appState, (el) => {
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
if (isTextElement(el)) {
|
if (isTextElement(el)) {
|
||||||
const element: ExcalidrawTextElement = newElementWith(el, {
|
const element: ExcalidrawTextElement = newElementWith(el, {
|
||||||
fontFamily: value,
|
font: `${el.font.split("px ")[0]}px ${value}`,
|
||||||
});
|
});
|
||||||
redrawTextBoundingBox(element);
|
redrawTextBoundingBox(element);
|
||||||
return element;
|
return element;
|
||||||
@@ -450,35 +340,33 @@ export const actionChangeFontFamily = register({
|
|||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemFontFamily: value,
|
currentItemFont: `${
|
||||||
|
appState.currentItemFont.split("px ")[0]
|
||||||
|
}px ${value}`,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
const options: { value: FontFamily; text: string }[] = [
|
<fieldset>
|
||||||
{ value: 1, text: t("labels.handDrawn") },
|
<legend>{t("labels.fontFamily")}</legend>
|
||||||
{ value: 2, text: t("labels.normal") },
|
<ButtonSelect
|
||||||
{ value: 3, text: t("labels.code") },
|
group="font-family"
|
||||||
];
|
options={[
|
||||||
|
{ value: "Virgil", text: t("labels.handDrawn") },
|
||||||
return (
|
{ value: "Helvetica", text: t("labels.normal") },
|
||||||
<fieldset>
|
{ value: "Cascadia", text: t("labels.code") },
|
||||||
<legend>{t("labels.fontFamily")}</legend>
|
]}
|
||||||
<ButtonSelect<FontFamily | false>
|
value={getFormValue(
|
||||||
group="font-family"
|
elements,
|
||||||
options={options}
|
appState,
|
||||||
value={getFormValue(
|
(element) => isTextElement(element) && element.font.split("px ")[1],
|
||||||
elements,
|
(appState.currentItemFont || DEFAULT_FONT).split("px ")[1],
|
||||||
appState,
|
)}
|
||||||
(element) => isTextElement(element) && element.fontFamily,
|
onChange={(value) => updateData(value)}
|
||||||
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
/>
|
||||||
)}
|
</fieldset>
|
||||||
onChange={(value) => updateData(value)}
|
),
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeTextAlign = register({
|
export const actionChangeTextAlign = register({
|
||||||
@@ -524,199 +412,3 @@ export const actionChangeTextAlign = register({
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeSharpness = register({
|
|
||||||
name: "changeSharpness",
|
|
||||||
perform: (elements, appState, value) => {
|
|
||||||
const targetElements = getTargetElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
const shouldUpdateForNonLinearElements = targetElements.length
|
|
||||||
? targetElements.every((el) => !isLinearElement(el))
|
|
||||||
: !isLinearElementType(appState.elementType);
|
|
||||||
const shouldUpdateForLinearElements = targetElements.length
|
|
||||||
? targetElements.every(isLinearElement)
|
|
||||||
: isLinearElementType(appState.elementType);
|
|
||||||
return {
|
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
|
||||||
newElementWith(el, {
|
|
||||||
strokeSharpness: value,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
currentItemStrokeSharpness: shouldUpdateForNonLinearElements
|
|
||||||
? value
|
|
||||||
: appState.currentItemStrokeSharpness,
|
|
||||||
currentItemLinearStrokeSharpness: shouldUpdateForLinearElements
|
|
||||||
? value
|
|
||||||
: appState.currentItemLinearStrokeSharpness,
|
|
||||||
},
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<fieldset>
|
|
||||||
<legend>{t("labels.edges")}</legend>
|
|
||||||
<ButtonIconSelect
|
|
||||||
group="edges"
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: "sharp",
|
|
||||||
text: t("labels.sharp"),
|
|
||||||
icon: <EdgeSharpIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "round",
|
|
||||||
text: t("labels.round"),
|
|
||||||
icon: <EdgeRoundIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={getFormValue(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(element) => element.strokeSharpness,
|
|
||||||
(canChangeSharpness(appState.elementType) &&
|
|
||||||
(isLinearElementType(appState.elementType)
|
|
||||||
? appState.currentItemLinearStrokeSharpness
|
|
||||||
: appState.currentItemStrokeSharpness)) ||
|
|
||||||
null,
|
|
||||||
)}
|
|
||||||
onChange={(value) => updateData(value)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionChangeArrowhead = register({
|
|
||||||
name: "changeArrowhead",
|
|
||||||
perform: (
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
value: { position: "start" | "end"; type: Arrowhead },
|
|
||||||
) => {
|
|
||||||
return {
|
|
||||||
elements: changeProperty(elements, appState, (el) => {
|
|
||||||
if (isLinearElement(el)) {
|
|
||||||
const { position, type } = value;
|
|
||||||
|
|
||||||
if (position === "start") {
|
|
||||||
const element: ExcalidrawLinearElement = newElementWith(el, {
|
|
||||||
startArrowhead: type,
|
|
||||||
});
|
|
||||||
return element;
|
|
||||||
} else if (position === "end") {
|
|
||||||
const element: ExcalidrawLinearElement = newElementWith(el, {
|
|
||||||
endArrowhead: type,
|
|
||||||
});
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return el;
|
|
||||||
}),
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
[value.position === "start"
|
|
||||||
? "currentItemStartArrowhead"
|
|
||||||
: "currentItemEndArrowhead"]: value.type,
|
|
||||||
},
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
|
||||||
const isRTL = getLanguage().rtl;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<fieldset>
|
|
||||||
<legend>{t("labels.arrowheads")}</legend>
|
|
||||||
<div className="iconSelectList">
|
|
||||||
<IconPicker
|
|
||||||
label="arrowhead_start"
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: null,
|
|
||||||
text: t("labels.arrowhead_none"),
|
|
||||||
icon: <ArrowheadNoneIcon theme={appState.theme} />,
|
|
||||||
keyBinding: "q",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "arrow",
|
|
||||||
text: t("labels.arrowhead_arrow"),
|
|
||||||
icon: (
|
|
||||||
<ArrowheadArrowIcon theme={appState.theme} flip={!isRTL} />
|
|
||||||
),
|
|
||||||
keyBinding: "w",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "bar",
|
|
||||||
text: t("labels.arrowhead_bar"),
|
|
||||||
icon: <ArrowheadBarIcon theme={appState.theme} flip={!isRTL} />,
|
|
||||||
keyBinding: "e",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "dot",
|
|
||||||
text: t("labels.arrowhead_dot"),
|
|
||||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
|
|
||||||
keyBinding: "r",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={getFormValue<Arrowhead | null>(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(element) =>
|
|
||||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
|
||||||
? element.startArrowhead
|
|
||||||
: appState.currentItemStartArrowhead,
|
|
||||||
appState.currentItemStartArrowhead,
|
|
||||||
)}
|
|
||||||
onChange={(value) => updateData({ position: "start", type: value })}
|
|
||||||
/>
|
|
||||||
<IconPicker
|
|
||||||
label="arrowhead_end"
|
|
||||||
group="arrowheads"
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: null,
|
|
||||||
text: t("labels.arrowhead_none"),
|
|
||||||
keyBinding: "q",
|
|
||||||
icon: <ArrowheadNoneIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "arrow",
|
|
||||||
text: t("labels.arrowhead_arrow"),
|
|
||||||
keyBinding: "w",
|
|
||||||
icon: (
|
|
||||||
<ArrowheadArrowIcon theme={appState.theme} flip={isRTL} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "bar",
|
|
||||||
text: t("labels.arrowhead_bar"),
|
|
||||||
keyBinding: "e",
|
|
||||||
icon: <ArrowheadBarIcon theme={appState.theme} flip={isRTL} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "dot",
|
|
||||||
text: t("labels.arrowhead_dot"),
|
|
||||||
keyBinding: "r",
|
|
||||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={getFormValue<Arrowhead | null>(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(element) =>
|
|
||||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
|
||||||
? element.endArrowhead
|
|
||||||
: appState.currentItemEndArrowhead,
|
|
||||||
appState.currentItemEndArrowhead,
|
|
||||||
)}
|
|
||||||
onChange={(value) => updateData({ position: "end", type: value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
@@ -1,31 +1,22 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { selectGroupsForSelectedElements } from "../groups";
|
|
||||||
import { getNonDeletedElements } from "../element";
|
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
if (appState.editingLinearElement) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
appState: selectGroupsForSelectedElements(
|
appState: {
|
||||||
{
|
...appState,
|
||||||
...appState,
|
selectedElementIds: elements.reduce((map, element) => {
|
||||||
editingGroupId: null,
|
if (!element.isDeleted) {
|
||||||
selectedElementIds: elements.reduce((map, element) => {
|
map[element.id] = true;
|
||||||
if (!element.isDeleted) {
|
}
|
||||||
map[element.id] = true;
|
return map;
|
||||||
}
|
}, {} as any),
|
||||||
return map;
|
},
|
||||||
}, {} as any),
|
|
||||||
},
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
),
|
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.selectAll",
|
contextItemLabel: "labels.selectAll",
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === "a",
|
||||||
});
|
});
|
||||||
|
@@ -3,18 +3,12 @@ import {
|
|||||||
isExcalidrawElement,
|
isExcalidrawElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { DEFAULT_FONT, DEFAULT_TEXT_ALIGN } from "../appState";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import {
|
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
DEFAULT_FONT_FAMILY,
|
|
||||||
DEFAULT_TEXT_ALIGN,
|
|
||||||
} from "../constants";
|
|
||||||
|
|
||||||
// `copiedStyles` is exported only for tests.
|
let copiedStyles: string = "{}";
|
||||||
export let copiedStyles: string = "{}";
|
|
||||||
|
|
||||||
export const actionCopyStyles = register({
|
export const actionCopyStyles = register({
|
||||||
name: "copyStyles",
|
name: "copyStyles",
|
||||||
@@ -24,16 +18,13 @@ export const actionCopyStyles = register({
|
|||||||
copiedStyles = JSON.stringify(element);
|
copiedStyles = JSON.stringify(element);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
toastMessage: t("toast.copyStyles"),
|
|
||||||
},
|
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyStyles",
|
contextItemLabel: "labels.copyStyles",
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "C",
|
||||||
|
contextMenuOrder: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionPasteStyles = register({
|
export const actionPasteStyles = register({
|
||||||
@@ -50,15 +41,13 @@ export const actionPasteStyles = register({
|
|||||||
backgroundColor: pastedElement?.backgroundColor,
|
backgroundColor: pastedElement?.backgroundColor,
|
||||||
strokeWidth: pastedElement?.strokeWidth,
|
strokeWidth: pastedElement?.strokeWidth,
|
||||||
strokeColor: pastedElement?.strokeColor,
|
strokeColor: pastedElement?.strokeColor,
|
||||||
strokeStyle: pastedElement?.strokeStyle,
|
|
||||||
fillStyle: pastedElement?.fillStyle,
|
fillStyle: pastedElement?.fillStyle,
|
||||||
opacity: pastedElement?.opacity,
|
opacity: pastedElement?.opacity,
|
||||||
roughness: pastedElement?.roughness,
|
roughness: pastedElement?.roughness,
|
||||||
});
|
});
|
||||||
if (isTextElement(newElement)) {
|
if (isTextElement(newElement)) {
|
||||||
mutateElement(newElement, {
|
mutateElement(newElement, {
|
||||||
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
font: pastedElement?.font || DEFAULT_FONT,
|
||||||
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
|
||||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
});
|
});
|
||||||
redrawTextBoundingBox(newElement);
|
redrawTextBoundingBox(newElement);
|
||||||
@@ -72,5 +61,6 @@ export const actionPasteStyles = register({
|
|||||||
},
|
},
|
||||||
contextItemLabel: "labels.pasteStyles",
|
contextItemLabel: "labels.pasteStyles",
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "V",
|
||||||
|
contextMenuOrder: 1,
|
||||||
});
|
});
|
||||||
|
@@ -1,22 +0,0 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
|
||||||
import { register } from "./register";
|
|
||||||
import { GRID_SIZE } from "../constants";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
export const actionToggleGridMode = register({
|
|
||||||
name: "gridMode",
|
|
||||||
perform(elements, appState) {
|
|
||||||
trackEvent("view", "mode", "grid");
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
|
||||||
},
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
checked: (appState: AppState) => appState.gridSize !== null,
|
|
||||||
contextItemLabel: "labels.showGrid",
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
|
||||||
});
|
|
@@ -1,16 +0,0 @@
|
|||||||
import { register } from "./register";
|
|
||||||
|
|
||||||
export const actionToggleStats = register({
|
|
||||||
name: "stats",
|
|
||||||
perform(elements, appState) {
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
showStats: !this.checked!(appState),
|
|
||||||
},
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
checked: (appState) => appState.showStats,
|
|
||||||
contextItemLabel: "stats.title",
|
|
||||||
});
|
|
@@ -1,22 +0,0 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
|
||||||
import { register } from "./register";
|
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
export const actionToggleViewMode = register({
|
|
||||||
name: "viewMode",
|
|
||||||
perform(elements, appState) {
|
|
||||||
trackEvent("view", "mode", "view");
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
viewModeEnabled: !this.checked!(appState),
|
|
||||||
selectedElementIds: {},
|
|
||||||
},
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
checked: (appState) => appState.viewModeEnabled,
|
|
||||||
contextItemLabel: "labels.viewMode",
|
|
||||||
keyTest: (event) =>
|
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
|
|
||||||
});
|
|
@@ -1,22 +0,0 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
|
||||||
import { register } from "./register";
|
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
export const actionToggleZenMode = register({
|
|
||||||
name: "zenMode",
|
|
||||||
perform(elements, appState) {
|
|
||||||
trackEvent("view", "mode", "zen");
|
|
||||||
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
zenModeEnabled: !this.checked!(appState),
|
|
||||||
},
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
checked: (appState) => appState.zenModeEnabled,
|
|
||||||
contextItemLabel: "buttons.zenMode",
|
|
||||||
keyTest: (event) =>
|
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
|
|
||||||
});
|
|
@@ -5,22 +5,79 @@ import {
|
|||||||
moveAllLeft,
|
moveAllLeft,
|
||||||
moveAllRight,
|
moveAllRight,
|
||||||
} from "../zindex";
|
} from "../zindex";
|
||||||
import { KEYS, isDarwin, CODES } from "../keys";
|
import { KEYS, isDarwin } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import {
|
import {
|
||||||
SendBackwardIcon,
|
sendBackward,
|
||||||
BringToFrontIcon,
|
bringToFront,
|
||||||
SendToBackIcon,
|
sendToBack,
|
||||||
BringForwardIcon,
|
bringForward,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
|
||||||
|
function getElementIndices(
|
||||||
|
direction: "left" | "right",
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) {
|
||||||
|
const selectedIndices: number[] = [];
|
||||||
|
let deletedIndicesCache: number[] = [];
|
||||||
|
|
||||||
|
function cb(element: ExcalidrawElement, index: number) {
|
||||||
|
if (element.isDeleted) {
|
||||||
|
// we want to build an array of deleted elements that are preceeding
|
||||||
|
// a selected element so that we move them together
|
||||||
|
deletedIndicesCache.push(index);
|
||||||
|
} else {
|
||||||
|
if (appState.selectedElementIds[element.id]) {
|
||||||
|
selectedIndices.push(...deletedIndicesCache, index);
|
||||||
|
}
|
||||||
|
// always empty cache of deleted elements after either pushing a group
|
||||||
|
// of selected/deleted elements, of after encountering non-deleted elem
|
||||||
|
deletedIndicesCache = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sending back → select contiguous deleted elements that are to the left of
|
||||||
|
// selected element(s)
|
||||||
|
if (direction === "left") {
|
||||||
|
let i = -1;
|
||||||
|
const len = elements.length;
|
||||||
|
while (++i < len) {
|
||||||
|
cb(elements[i], i);
|
||||||
|
}
|
||||||
|
// moving to front → loop from right to left so that we don't need to
|
||||||
|
// backtrack when gathering deleted elements
|
||||||
|
} else {
|
||||||
|
let i = elements.length;
|
||||||
|
while (--i > -1) {
|
||||||
|
cb(elements[i], i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// sort in case we were gathering indexes from right to left
|
||||||
|
return selectedIndices.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveElements(
|
||||||
|
func: typeof moveOneLeft,
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) {
|
||||||
|
const _elements = elements.slice();
|
||||||
|
const direction =
|
||||||
|
func === moveOneLeft || func === moveAllLeft ? "left" : "right";
|
||||||
|
const indices = getElementIndices(direction, _elements, appState);
|
||||||
|
return func(_elements, indices);
|
||||||
|
}
|
||||||
|
|
||||||
export const actionSendBackward = register({
|
export const actionSendBackward = register({
|
||||||
name: "sendBackward",
|
name: "sendBackward",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveOneLeft(elements, appState),
|
elements: moveElements(moveOneLeft, elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -28,17 +85,15 @@ export const actionSendBackward = register({
|
|||||||
contextItemLabel: "labels.sendBackward",
|
contextItemLabel: "labels.sendBackward",
|
||||||
keyPriority: 40,
|
keyPriority: 40,
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] && !event.shiftKey && event.code === "BracketLeft",
|
||||||
!event.shiftKey &&
|
PanelComponent: ({ updateData }) => (
|
||||||
event.code === CODES.BRACKET_LEFT,
|
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="zIndexButton"
|
className="zIndexButton"
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`}
|
title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`}
|
||||||
>
|
>
|
||||||
<SendBackwardIcon theme={appState.theme} />
|
{sendBackward}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -47,7 +102,7 @@ export const actionBringForward = register({
|
|||||||
name: "bringForward",
|
name: "bringForward",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveOneRight(elements, appState),
|
elements: moveElements(moveOneRight, elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -55,17 +110,15 @@ export const actionBringForward = register({
|
|||||||
contextItemLabel: "labels.bringForward",
|
contextItemLabel: "labels.bringForward",
|
||||||
keyPriority: 40,
|
keyPriority: 40,
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] && !event.shiftKey && event.code === "BracketRight",
|
||||||
!event.shiftKey &&
|
PanelComponent: ({ updateData }) => (
|
||||||
event.code === CODES.BRACKET_RIGHT,
|
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="zIndexButton"
|
className="zIndexButton"
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`}
|
title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`}
|
||||||
>
|
>
|
||||||
<BringForwardIcon theme={appState.theme} />
|
{bringForward}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -74,21 +127,20 @@ export const actionSendToBack = register({
|
|||||||
name: "sendToBack",
|
name: "sendToBack",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveAllLeft(elements, appState),
|
elements: moveElements(moveAllLeft, elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.sendToBack",
|
contextItemLabel: "labels.sendToBack",
|
||||||
keyTest: (event) =>
|
keyTest: (event) => {
|
||||||
isDarwin
|
return isDarwin
|
||||||
? event[KEYS.CTRL_OR_CMD] &&
|
? event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === "BracketLeft"
|
||||||
event.altKey &&
|
|
||||||
event.code === CODES.BRACKET_LEFT
|
|
||||||
: event[KEYS.CTRL_OR_CMD] &&
|
: event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event.code === CODES.BRACKET_LEFT,
|
event.code === "BracketLeft";
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
},
|
||||||
|
PanelComponent: ({ updateData }) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="zIndexButton"
|
className="zIndexButton"
|
||||||
@@ -99,7 +151,7 @@ export const actionSendToBack = register({
|
|||||||
: getShortcutKey("CtrlOrCmd+Shift+[")
|
: getShortcutKey("CtrlOrCmd+Shift+[")
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<SendToBackIcon theme={appState.theme} />
|
{sendToBack}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -108,21 +160,20 @@ export const actionBringToFront = register({
|
|||||||
name: "bringToFront",
|
name: "bringToFront",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveAllRight(elements, appState),
|
elements: moveElements(moveAllRight, elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.bringToFront",
|
contextItemLabel: "labels.bringToFront",
|
||||||
keyTest: (event) =>
|
keyTest: (event) => {
|
||||||
isDarwin
|
return isDarwin
|
||||||
? event[KEYS.CTRL_OR_CMD] &&
|
? event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === "BracketRight"
|
||||||
event.altKey &&
|
|
||||||
event.code === CODES.BRACKET_RIGHT
|
|
||||||
: event[KEYS.CTRL_OR_CMD] &&
|
: event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event.code === CODES.BRACKET_RIGHT,
|
event.code === "BracketRight";
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
},
|
||||||
|
PanelComponent: ({ updateData }) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="zIndexButton"
|
className="zIndexButton"
|
||||||
@@ -133,7 +184,7 @@ export const actionBringToFront = register({
|
|||||||
: getShortcutKey("CtrlOrCmd+Shift+]")
|
: getShortcutKey("CtrlOrCmd+Shift+]")
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<BringToFrontIcon theme={appState.theme} />
|
{bringToFront}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
@@ -34,7 +34,6 @@ export {
|
|||||||
actionChangeProjectName,
|
actionChangeProjectName,
|
||||||
actionChangeExportBackground,
|
actionChangeExportBackground,
|
||||||
actionSaveScene,
|
actionSaveScene,
|
||||||
actionSaveAsScene,
|
|
||||||
actionLoadScene,
|
actionLoadScene,
|
||||||
} from "./actionExport";
|
} from "./actionExport";
|
||||||
|
|
||||||
@@ -45,35 +44,3 @@ export {
|
|||||||
actionFullScreen,
|
actionFullScreen,
|
||||||
actionShortcuts,
|
actionShortcuts,
|
||||||
} from "./actionMenu";
|
} from "./actionMenu";
|
||||||
|
|
||||||
export { actionGroup, actionUngroup } from "./actionGroup";
|
|
||||||
|
|
||||||
export { actionGoToCollaborator } from "./actionNavigate";
|
|
||||||
|
|
||||||
export { actionAddToLibrary } from "./actionAddToLibrary";
|
|
||||||
|
|
||||||
export {
|
|
||||||
actionAlignTop,
|
|
||||||
actionAlignBottom,
|
|
||||||
actionAlignLeft,
|
|
||||||
actionAlignRight,
|
|
||||||
actionAlignVerticallyCentered,
|
|
||||||
actionAlignHorizontallyCentered,
|
|
||||||
} from "./actionAlign";
|
|
||||||
|
|
||||||
export {
|
|
||||||
distributeHorizontally,
|
|
||||||
distributeVertically,
|
|
||||||
} from "./actionDistribute";
|
|
||||||
|
|
||||||
export {
|
|
||||||
actionCopy,
|
|
||||||
actionCut,
|
|
||||||
actionCopyAsPng,
|
|
||||||
actionCopyAsSvg,
|
|
||||||
} from "./actionClipboard";
|
|
||||||
|
|
||||||
export { actionToggleGridMode } from "./actionToggleGridMode";
|
|
||||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
|
||||||
|
|
||||||
export { actionToggleStats } from "./actionToggleStats";
|
|
||||||
|
@@ -3,44 +3,33 @@ import {
|
|||||||
Action,
|
Action,
|
||||||
ActionsManagerInterface,
|
ActionsManagerInterface,
|
||||||
UpdaterFn,
|
UpdaterFn,
|
||||||
|
ActionFilterFn,
|
||||||
ActionName,
|
ActionName,
|
||||||
ActionResult,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, ExcalidrawProps } from "../types";
|
import { AppState } from "../types";
|
||||||
import { MODES } from "../constants";
|
import { t } from "../i18n";
|
||||||
|
import { globalSceneState } from "../scene";
|
||||||
// This is the <App> component, but for now we don't care about anything but its
|
|
||||||
// `canvas` state.
|
|
||||||
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
|
|
||||||
|
|
||||||
export class ActionManager implements ActionsManagerInterface {
|
export class ActionManager implements ActionsManagerInterface {
|
||||||
actions = {} as ActionsManagerInterface["actions"];
|
actions = {} as ActionsManagerInterface["actions"];
|
||||||
|
|
||||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
updater: UpdaterFn;
|
||||||
|
|
||||||
|
getAppState: () => AppState;
|
||||||
|
|
||||||
getAppState: () => Readonly<AppState>;
|
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||||
app: App;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
updater: UpdaterFn,
|
updater: UpdaterFn,
|
||||||
getAppState: () => AppState,
|
getAppState: () => AppState,
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
getElementsIncludingDeleted: () => ReturnType<
|
||||||
app: App,
|
typeof globalSceneState["getElementsIncludingDeleted"]
|
||||||
|
>,
|
||||||
) {
|
) {
|
||||||
this.updater = (actionResult) => {
|
this.updater = updater;
|
||||||
if (actionResult && "then" in actionResult) {
|
|
||||||
actionResult.then((actionResult) => {
|
|
||||||
return updater(actionResult);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return updater(actionResult);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.getAppState = getAppState;
|
this.getAppState = getAppState;
|
||||||
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
|
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
|
||||||
this.app = app;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
registerAction(action: Action) {
|
registerAction(action: Action) {
|
||||||
@@ -67,12 +56,6 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { viewModeEnabled } = this.getAppState();
|
|
||||||
if (viewModeEnabled) {
|
|
||||||
if (!Object.values(MODES).includes(data[0].name)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.updater(
|
this.updater(
|
||||||
@@ -80,7 +63,6 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
this.getElementsIncludingDeleted(),
|
this.getElementsIncludingDeleted(),
|
||||||
this.getAppState(),
|
this.getAppState(),
|
||||||
null,
|
null,
|
||||||
this.app,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@@ -92,16 +74,34 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
this.getElementsIncludingDeleted(),
|
this.getElementsIncludingDeleted(),
|
||||||
this.getAppState(),
|
this.getAppState(),
|
||||||
null,
|
null,
|
||||||
this.app,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Id is an attribute that we can use to pass in data like keys.
|
getContextMenuItems(actionFilter: ActionFilterFn = (action) => action) {
|
||||||
// This is needed for dynamically generated action components
|
return Object.values(this.actions)
|
||||||
// like the user list. We can use this key to extract more
|
.filter(actionFilter)
|
||||||
// data from app state. This is an alternative to generic prop hell!
|
.filter((action) => "contextItemLabel" in action)
|
||||||
renderAction = (name: ActionName, id?: string) => {
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
|
||||||
|
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
|
||||||
|
)
|
||||||
|
.map((action) => ({
|
||||||
|
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
|
||||||
|
action: () => {
|
||||||
|
this.updater(
|
||||||
|
action.perform(
|
||||||
|
this.getElementsIncludingDeleted(),
|
||||||
|
this.getAppState(),
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAction = (name: ActionName) => {
|
||||||
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
|
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
|
||||||
const action = this.actions[name];
|
const action = this.actions[name];
|
||||||
const PanelComponent = action.PanelComponent!;
|
const PanelComponent = action.PanelComponent!;
|
||||||
@@ -111,7 +111,6 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
this.getElementsIncludingDeleted(),
|
this.getElementsIncludingDeleted(),
|
||||||
this.getAppState(),
|
this.getAppState(),
|
||||||
formState,
|
formState,
|
||||||
this.app,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -121,8 +120,6 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
elements={this.getElementsIncludingDeleted()}
|
elements={this.getElementsIncludingDeleted()}
|
||||||
appState={this.getAppState()}
|
appState={this.getAppState()}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
id={id}
|
|
||||||
appProps={this.app.props}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -2,7 +2,7 @@ import { Action } from "./types";
|
|||||||
|
|
||||||
export let actions: readonly Action[] = [];
|
export let actions: readonly Action[] = [];
|
||||||
|
|
||||||
export const register = (action: Action): Action => {
|
export function register(action: Action): Action {
|
||||||
actions = actions.concat(action);
|
actions = actions.concat(action);
|
||||||
return action;
|
return action;
|
||||||
};
|
}
|
||||||
|
@@ -1,67 +0,0 @@
|
|||||||
import { t } from "../i18n";
|
|
||||||
import { isDarwin } from "../keys";
|
|
||||||
import { getShortcutKey } from "../utils";
|
|
||||||
|
|
||||||
export type ShortcutName =
|
|
||||||
| "cut"
|
|
||||||
| "copy"
|
|
||||||
| "paste"
|
|
||||||
| "copyStyles"
|
|
||||||
| "pasteStyles"
|
|
||||||
| "selectAll"
|
|
||||||
| "deleteSelectedElements"
|
|
||||||
| "duplicateSelection"
|
|
||||||
| "sendBackward"
|
|
||||||
| "bringForward"
|
|
||||||
| "sendToBack"
|
|
||||||
| "bringToFront"
|
|
||||||
| "copyAsPng"
|
|
||||||
| "copyAsSvg"
|
|
||||||
| "group"
|
|
||||||
| "ungroup"
|
|
||||||
| "gridMode"
|
|
||||||
| "zenMode"
|
|
||||||
| "stats"
|
|
||||||
| "addToLibrary"
|
|
||||||
| "viewMode";
|
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
|
||||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
|
||||||
copy: [getShortcutKey("CtrlOrCmd+C")],
|
|
||||||
paste: [getShortcutKey("CtrlOrCmd+V")],
|
|
||||||
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
|
|
||||||
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
|
|
||||||
selectAll: [getShortcutKey("CtrlOrCmd+A")],
|
|
||||||
deleteSelectedElements: [getShortcutKey("Del")],
|
|
||||||
duplicateSelection: [
|
|
||||||
getShortcutKey("CtrlOrCmd+D"),
|
|
||||||
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
|
||||||
],
|
|
||||||
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
|
|
||||||
bringForward: [getShortcutKey("CtrlOrCmd+]")],
|
|
||||||
sendToBack: [
|
|
||||||
isDarwin
|
|
||||||
? getShortcutKey("CtrlOrCmd+Alt+[")
|
|
||||||
: getShortcutKey("CtrlOrCmd+Shift+["),
|
|
||||||
],
|
|
||||||
bringToFront: [
|
|
||||||
isDarwin
|
|
||||||
? getShortcutKey("CtrlOrCmd+Alt+]")
|
|
||||||
: getShortcutKey("CtrlOrCmd+Shift+]"),
|
|
||||||
],
|
|
||||||
copyAsPng: [getShortcutKey("Shift+Alt+C")],
|
|
||||||
copyAsSvg: [],
|
|
||||||
group: [getShortcutKey("CtrlOrCmd+G")],
|
|
||||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
|
||||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
|
||||||
zenMode: [getShortcutKey("Alt+Z")],
|
|
||||||
stats: [],
|
|
||||||
addToLibrary: [],
|
|
||||||
viewMode: [getShortcutKey("Alt+R")],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
|
||||||
const shortcuts = shortcutMap[name];
|
|
||||||
// if multiple shortcuts availiable, take the first one
|
|
||||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
|
||||||
};
|
|
@@ -1,33 +1,23 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, ExcalidrawProps } from "../types";
|
import { AppState } from "../types";
|
||||||
|
|
||||||
/** if false, the action should be prevented */
|
export type ActionResult = {
|
||||||
export type ActionResult =
|
elements?: readonly ExcalidrawElement[] | null;
|
||||||
| {
|
appState?: AppState | null;
|
||||||
elements?: readonly ExcalidrawElement[] | null;
|
commitToHistory: boolean;
|
||||||
appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
|
};
|
||||||
commitToHistory: boolean;
|
|
||||||
syncHistory?: boolean;
|
|
||||||
}
|
|
||||||
| false;
|
|
||||||
|
|
||||||
type ActionFn = (
|
type ActionFn = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: Readonly<AppState>,
|
appState: AppState,
|
||||||
formData: any,
|
formData: any,
|
||||||
app: { canvas: HTMLCanvasElement | null },
|
) => ActionResult;
|
||||||
) => ActionResult | Promise<ActionResult>;
|
|
||||||
|
|
||||||
export type UpdaterFn = (res: ActionResult) => void;
|
export type UpdaterFn = (res: ActionResult, commitToHistory?: boolean) => void;
|
||||||
export type ActionFilterFn = (action: Action) => void;
|
export type ActionFilterFn = (action: Action) => void;
|
||||||
|
|
||||||
export type ActionName =
|
export type ActionName =
|
||||||
| "copy"
|
|
||||||
| "cut"
|
|
||||||
| "paste"
|
|
||||||
| "copyAsPng"
|
|
||||||
| "copyAsSvg"
|
|
||||||
| "sendBackward"
|
| "sendBackward"
|
||||||
| "bringForward"
|
| "bringForward"
|
||||||
| "sendToBack"
|
| "sendToBack"
|
||||||
@@ -35,16 +25,11 @@ export type ActionName =
|
|||||||
| "copyStyles"
|
| "copyStyles"
|
||||||
| "selectAll"
|
| "selectAll"
|
||||||
| "pasteStyles"
|
| "pasteStyles"
|
||||||
| "gridMode"
|
|
||||||
| "zenMode"
|
|
||||||
| "stats"
|
|
||||||
| "changeStrokeColor"
|
| "changeStrokeColor"
|
||||||
| "changeBackgroundColor"
|
| "changeBackgroundColor"
|
||||||
| "changeFillStyle"
|
| "changeFillStyle"
|
||||||
| "changeStrokeWidth"
|
| "changeStrokeWidth"
|
||||||
| "changeSloppiness"
|
| "changeSloppiness"
|
||||||
| "changeStrokeStyle"
|
|
||||||
| "changeArrowhead"
|
|
||||||
| "changeOpacity"
|
| "changeOpacity"
|
||||||
| "changeFontSize"
|
| "changeFontSize"
|
||||||
| "toggleCanvasMenu"
|
| "toggleCanvasMenu"
|
||||||
@@ -54,10 +39,8 @@ export type ActionName =
|
|||||||
| "finalize"
|
| "finalize"
|
||||||
| "changeProjectName"
|
| "changeProjectName"
|
||||||
| "changeExportBackground"
|
| "changeExportBackground"
|
||||||
| "changeExportEmbedScene"
|
|
||||||
| "changeShouldAddWatermark"
|
| "changeShouldAddWatermark"
|
||||||
| "saveScene"
|
| "saveScene"
|
||||||
| "saveAsScene"
|
|
||||||
| "loadScene"
|
| "loadScene"
|
||||||
| "duplicateSelection"
|
| "duplicateSelection"
|
||||||
| "deleteSelectedElements"
|
| "deleteSelectedElements"
|
||||||
@@ -67,26 +50,10 @@ export type ActionName =
|
|||||||
| "zoomOut"
|
| "zoomOut"
|
||||||
| "resetZoom"
|
| "resetZoom"
|
||||||
| "zoomToFit"
|
| "zoomToFit"
|
||||||
| "zoomToSelection"
|
|
||||||
| "changeFontFamily"
|
| "changeFontFamily"
|
||||||
| "changeTextAlign"
|
| "changeTextAlign"
|
||||||
| "toggleFullScreen"
|
| "toggleFullScreen"
|
||||||
| "toggleShortcuts"
|
| "toggleShortcuts";
|
||||||
| "group"
|
|
||||||
| "ungroup"
|
|
||||||
| "goToCollaborator"
|
|
||||||
| "addToLibrary"
|
|
||||||
| "changeSharpness"
|
|
||||||
| "alignTop"
|
|
||||||
| "alignBottom"
|
|
||||||
| "alignLeft"
|
|
||||||
| "alignRight"
|
|
||||||
| "alignVerticallyCentered"
|
|
||||||
| "alignHorizontallyCentered"
|
|
||||||
| "distributeHorizontally"
|
|
||||||
| "distributeVertically"
|
|
||||||
| "viewMode"
|
|
||||||
| "exportWithDarkMode";
|
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: ActionName;
|
name: ActionName;
|
||||||
@@ -94,8 +61,6 @@ export interface Action {
|
|||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
appProps: ExcalidrawProps;
|
|
||||||
id?: string;
|
|
||||||
}>;
|
}>;
|
||||||
perform: ActionFn;
|
perform: ActionFn;
|
||||||
keyPriority?: number;
|
keyPriority?: number;
|
||||||
@@ -105,16 +70,17 @@ export interface Action {
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => boolean;
|
) => boolean;
|
||||||
contextItemLabel?: string;
|
contextItemLabel?: string;
|
||||||
contextItemPredicate?: (
|
contextMenuOrder?: number;
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
) => boolean;
|
|
||||||
checked?: (appState: Readonly<AppState>) => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionsManagerInterface {
|
export interface ActionsManagerInterface {
|
||||||
actions: Record<ActionName, Action>;
|
actions: {
|
||||||
|
[actionName in ActionName]: Action;
|
||||||
|
};
|
||||||
registerAction: (action: Action) => void;
|
registerAction: (action: Action) => void;
|
||||||
handleKeyDown: (event: KeyboardEvent) => boolean;
|
handleKeyDown: (event: KeyboardEvent) => boolean;
|
||||||
|
getContextMenuItems: (
|
||||||
|
actionFilter: ActionFilterFn,
|
||||||
|
) => { label: string; action: () => void }[];
|
||||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||||
}
|
}
|
||||||
|
95
src/align.ts
@@ -1,95 +0,0 @@
|
|||||||
import { ExcalidrawElement } from "./element/types";
|
|
||||||
import { newElementWith } from "./element/mutateElement";
|
|
||||||
import { getCommonBounds } from "./element";
|
|
||||||
|
|
||||||
interface Box {
|
|
||||||
minX: number;
|
|
||||||
minY: number;
|
|
||||||
maxX: number;
|
|
||||||
maxY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Alignment {
|
|
||||||
position: "start" | "center" | "end";
|
|
||||||
axis: "x" | "y";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const alignElements = (
|
|
||||||
selectedElements: ExcalidrawElement[],
|
|
||||||
alignment: Alignment,
|
|
||||||
): ExcalidrawElement[] => {
|
|
||||||
const groups: ExcalidrawElement[][] = getMaximumGroups(selectedElements);
|
|
||||||
|
|
||||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
|
||||||
|
|
||||||
return groups.flatMap((group) => {
|
|
||||||
const translation = calculateTranslation(
|
|
||||||
group,
|
|
||||||
selectionBoundingBox,
|
|
||||||
alignment,
|
|
||||||
);
|
|
||||||
return group.map((element) =>
|
|
||||||
newElementWith(element, {
|
|
||||||
x: element.x + translation.x,
|
|
||||||
y: element.y + translation.y,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMaximumGroups = (
|
|
||||||
elements: ExcalidrawElement[],
|
|
||||||
): ExcalidrawElement[][] => {
|
|
||||||
const groups: Map<String, ExcalidrawElement[]> = new Map<
|
|
||||||
String,
|
|
||||||
ExcalidrawElement[]
|
|
||||||
>();
|
|
||||||
|
|
||||||
elements.forEach((element: ExcalidrawElement) => {
|
|
||||||
const groupId =
|
|
||||||
element.groupIds.length === 0
|
|
||||||
? element.id
|
|
||||||
: element.groupIds[element.groupIds.length - 1];
|
|
||||||
|
|
||||||
const currentGroupMembers = groups.get(groupId) || [];
|
|
||||||
|
|
||||||
groups.set(groupId, [...currentGroupMembers, element]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(groups.values());
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateTranslation = (
|
|
||||||
group: ExcalidrawElement[],
|
|
||||||
selectionBoundingBox: Box,
|
|
||||||
{ axis, position }: Alignment,
|
|
||||||
): { x: number; y: number } => {
|
|
||||||
const groupBoundingBox = getCommonBoundingBox(group);
|
|
||||||
|
|
||||||
const [min, max]: ["minX" | "minY", "maxX" | "maxY"] =
|
|
||||||
axis === "x" ? ["minX", "maxX"] : ["minY", "maxY"];
|
|
||||||
|
|
||||||
const noTranslation = { x: 0, y: 0 };
|
|
||||||
if (position === "start") {
|
|
||||||
return {
|
|
||||||
...noTranslation,
|
|
||||||
[axis]: selectionBoundingBox[min] - groupBoundingBox[min],
|
|
||||||
};
|
|
||||||
} else if (position === "end") {
|
|
||||||
return {
|
|
||||||
...noTranslation,
|
|
||||||
[axis]: selectionBoundingBox[max] - groupBoundingBox[max],
|
|
||||||
};
|
|
||||||
} // else if (position === "center") {
|
|
||||||
return {
|
|
||||||
...noTranslation,
|
|
||||||
[axis]:
|
|
||||||
(selectionBoundingBox[min] + selectionBoundingBox[max]) / 2 -
|
|
||||||
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
|
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
|
||||||
return { minX, minY, maxX, maxY };
|
|
||||||
};
|
|
@@ -1,18 +0,0 @@
|
|||||||
export const trackEvent =
|
|
||||||
typeof process !== "undefined" &&
|
|
||||||
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
window.gtag
|
|
||||||
? (category: string, name: string, label?: string, value?: number) => {
|
|
||||||
window.gtag("event", name, {
|
|
||||||
event_category: category,
|
|
||||||
event_label: label,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
|
||||||
? (category: string, name: string, label?: string, value?: number) => {}
|
|
||||||
: (category: string, name: string, label?: string, value?: number) => {
|
|
||||||
// Uncomment the next line to track locally
|
|
||||||
// console.info("Track Event", category, name, label, value);
|
|
||||||
};
|
|
254
src/appState.ts
@@ -1,186 +1,96 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import {
|
import { AppState, FlooredNumber } from "./types";
|
||||||
DEFAULT_FONT_FAMILY,
|
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
DEFAULT_TEXT_ALIGN,
|
|
||||||
} from "./constants";
|
|
||||||
import { t } from "./i18n";
|
|
||||||
import { AppState, NormalizedZoomValue } from "./types";
|
|
||||||
import { getDateTime } from "./utils";
|
import { getDateTime } from "./utils";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
|
||||||
export const getDefaultAppState = (): Omit<
|
export const DEFAULT_FONT = "20px Virgil";
|
||||||
AppState,
|
export const DEFAULT_TEXT_ALIGN = "left";
|
||||||
"offsetTop" | "offsetLeft"
|
|
||||||
> => {
|
export function getDefaultAppState(): AppState {
|
||||||
return {
|
return {
|
||||||
theme: "light",
|
|
||||||
collaborators: new Map(),
|
|
||||||
currentChartType: "bar",
|
|
||||||
currentItemBackgroundColor: "transparent",
|
|
||||||
currentItemEndArrowhead: "arrow",
|
|
||||||
currentItemFillStyle: "hachure",
|
|
||||||
currentItemFontFamily: DEFAULT_FONT_FAMILY,
|
|
||||||
currentItemFontSize: DEFAULT_FONT_SIZE,
|
|
||||||
currentItemLinearStrokeSharpness: "round",
|
|
||||||
currentItemOpacity: 100,
|
|
||||||
currentItemRoughness: 1,
|
|
||||||
currentItemStartArrowhead: null,
|
|
||||||
currentItemStrokeColor: oc.black,
|
|
||||||
currentItemStrokeSharpness: "sharp",
|
|
||||||
currentItemStrokeStyle: "solid",
|
|
||||||
currentItemStrokeWidth: 1,
|
|
||||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
|
||||||
cursorButton: "up",
|
|
||||||
draggingElement: null,
|
|
||||||
editingElement: null,
|
|
||||||
editingGroupId: null,
|
|
||||||
editingLinearElement: null,
|
|
||||||
elementLocked: false,
|
|
||||||
elementType: "selection",
|
|
||||||
errorMessage: null,
|
|
||||||
exportBackground: true,
|
|
||||||
exportEmbedScene: false,
|
|
||||||
exportWithDarkMode: false,
|
|
||||||
fileHandle: null,
|
|
||||||
gridSize: null,
|
|
||||||
height: window.innerHeight,
|
|
||||||
isBindingEnabled: true,
|
|
||||||
isLibraryOpen: false,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
errorMessage: null,
|
||||||
|
draggingElement: null,
|
||||||
|
resizingElement: null,
|
||||||
|
multiElement: null,
|
||||||
|
editingElement: null,
|
||||||
|
elementType: "selection",
|
||||||
|
elementLocked: false,
|
||||||
|
exportBackground: true,
|
||||||
|
shouldAddWatermark: false,
|
||||||
|
currentItemStrokeColor: oc.black,
|
||||||
|
currentItemBackgroundColor: "transparent",
|
||||||
|
currentItemFillStyle: "hachure",
|
||||||
|
currentItemStrokeWidth: 1,
|
||||||
|
currentItemRoughness: 1,
|
||||||
|
currentItemOpacity: 100,
|
||||||
|
currentItemFont: DEFAULT_FONT,
|
||||||
|
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||||
|
viewBackgroundColor: oc.white,
|
||||||
|
scrollX: 0 as FlooredNumber,
|
||||||
|
scrollY: 0 as FlooredNumber,
|
||||||
|
cursorX: 0,
|
||||||
|
cursorY: 0,
|
||||||
|
cursorButton: "up",
|
||||||
|
scrolledOutside: false,
|
||||||
|
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||||
|
username: "",
|
||||||
|
isCollaborating: false,
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
isRotating: false,
|
isRotating: false,
|
||||||
lastPointerDownWith: "mouse",
|
|
||||||
multiElement: null,
|
|
||||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
|
||||||
openMenu: null,
|
|
||||||
pasteDialog: { shown: false, data: null },
|
|
||||||
previousSelectedElementIds: {},
|
|
||||||
resizingElement: null,
|
|
||||||
scrolledOutside: false,
|
|
||||||
scrollX: 0,
|
|
||||||
scrollY: 0,
|
|
||||||
selectedElementIds: {},
|
|
||||||
selectedGroupIds: {},
|
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
shouldAddWatermark: false,
|
zoom: 1,
|
||||||
|
openMenu: null,
|
||||||
|
lastPointerDownWith: "mouse",
|
||||||
|
selectedElementIds: {},
|
||||||
|
collaborators: new Map(),
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
showHelpDialog: false,
|
showShortcutsDialog: false,
|
||||||
showStats: false,
|
|
||||||
startBoundElement: null,
|
|
||||||
suggestedBindings: [],
|
|
||||||
toastMessage: null,
|
|
||||||
viewBackgroundColor: oc.white,
|
|
||||||
width: window.innerWidth,
|
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
|
||||||
viewModeEnabled: false,
|
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
export function clearAppStateForLocalStorage(appState: AppState) {
|
||||||
* Config containing all AppState keys. Used to determine whether given state
|
const {
|
||||||
* prop should be stripped when exporting to given storage type.
|
draggingElement,
|
||||||
*/
|
resizingElement,
|
||||||
const APP_STATE_STORAGE_CONF = (<
|
multiElement,
|
||||||
Values extends {
|
editingElement,
|
||||||
/** whether to keep when storing to browser storage (localStorage/IDB) */
|
selectionElement,
|
||||||
browser: boolean;
|
isResizing,
|
||||||
/** whether to keep when exporting to file/database */
|
isRotating,
|
||||||
export: boolean;
|
collaborators,
|
||||||
},
|
isCollaborating,
|
||||||
T extends Record<keyof AppState, Values>
|
isLoading,
|
||||||
>(
|
errorMessage,
|
||||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
showShortcutsDialog,
|
||||||
) => config)({
|
...exportedState
|
||||||
theme: { browser: true, export: false },
|
} = appState;
|
||||||
collaborators: { browser: false, export: false },
|
return exportedState;
|
||||||
currentChartType: { browser: true, export: false },
|
}
|
||||||
currentItemBackgroundColor: { browser: true, export: false },
|
|
||||||
currentItemEndArrowhead: { browser: true, export: false },
|
|
||||||
currentItemFillStyle: { browser: true, export: false },
|
|
||||||
currentItemFontFamily: { browser: true, export: false },
|
|
||||||
currentItemFontSize: { browser: true, export: false },
|
|
||||||
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
|
||||||
currentItemOpacity: { browser: true, export: false },
|
|
||||||
currentItemRoughness: { browser: true, export: false },
|
|
||||||
currentItemStartArrowhead: { browser: true, export: false },
|
|
||||||
currentItemStrokeColor: { browser: true, export: false },
|
|
||||||
currentItemStrokeSharpness: { browser: true, export: false },
|
|
||||||
currentItemStrokeStyle: { browser: true, export: false },
|
|
||||||
currentItemStrokeWidth: { browser: true, export: false },
|
|
||||||
currentItemTextAlign: { browser: true, export: false },
|
|
||||||
cursorButton: { browser: true, export: false },
|
|
||||||
draggingElement: { browser: false, export: false },
|
|
||||||
editingElement: { browser: false, export: false },
|
|
||||||
editingGroupId: { browser: true, export: false },
|
|
||||||
editingLinearElement: { browser: false, export: false },
|
|
||||||
elementLocked: { browser: true, export: false },
|
|
||||||
elementType: { browser: true, export: false },
|
|
||||||
errorMessage: { browser: false, export: false },
|
|
||||||
exportBackground: { browser: true, export: false },
|
|
||||||
exportEmbedScene: { browser: true, export: false },
|
|
||||||
exportWithDarkMode: { browser: true, export: false },
|
|
||||||
fileHandle: { browser: false, export: false },
|
|
||||||
gridSize: { browser: true, export: true },
|
|
||||||
height: { browser: false, export: false },
|
|
||||||
isBindingEnabled: { browser: false, export: false },
|
|
||||||
isLibraryOpen: { browser: false, export: false },
|
|
||||||
isLoading: { browser: false, export: false },
|
|
||||||
isResizing: { browser: false, export: false },
|
|
||||||
isRotating: { browser: false, export: false },
|
|
||||||
lastPointerDownWith: { browser: true, export: false },
|
|
||||||
multiElement: { browser: false, export: false },
|
|
||||||
name: { browser: true, export: false },
|
|
||||||
offsetLeft: { browser: false, export: false },
|
|
||||||
offsetTop: { browser: false, export: false },
|
|
||||||
openMenu: { browser: true, export: false },
|
|
||||||
pasteDialog: { browser: false, export: false },
|
|
||||||
previousSelectedElementIds: { browser: true, export: false },
|
|
||||||
resizingElement: { browser: false, export: false },
|
|
||||||
scrolledOutside: { browser: true, export: false },
|
|
||||||
scrollX: { browser: true, export: false },
|
|
||||||
scrollY: { browser: true, export: false },
|
|
||||||
selectedElementIds: { browser: true, export: false },
|
|
||||||
selectedGroupIds: { browser: true, export: false },
|
|
||||||
selectionElement: { browser: false, export: false },
|
|
||||||
shouldAddWatermark: { browser: true, export: false },
|
|
||||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
|
||||||
showHelpDialog: { browser: false, export: false },
|
|
||||||
showStats: { browser: true, export: false },
|
|
||||||
startBoundElement: { browser: false, export: false },
|
|
||||||
suggestedBindings: { browser: false, export: false },
|
|
||||||
toastMessage: { browser: false, export: false },
|
|
||||||
viewBackgroundColor: { browser: true, export: true },
|
|
||||||
width: { browser: false, export: false },
|
|
||||||
zenModeEnabled: { browser: true, export: false },
|
|
||||||
zoom: { browser: true, export: false },
|
|
||||||
viewModeEnabled: { browser: false, export: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
export function clearAppStatePropertiesForHistory(
|
||||||
appState: Partial<AppState>,
|
appState: AppState,
|
||||||
exportType: ExportType,
|
): Partial<AppState> {
|
||||||
) => {
|
return {
|
||||||
type ExportableKeys = {
|
selectedElementIds: appState.selectedElementIds,
|
||||||
[K in keyof typeof APP_STATE_STORAGE_CONF]: typeof APP_STATE_STORAGE_CONF[K][ExportType] extends true
|
exportBackground: appState.exportBackground,
|
||||||
? K
|
shouldAddWatermark: appState.shouldAddWatermark,
|
||||||
: never;
|
currentItemStrokeColor: appState.currentItemStrokeColor,
|
||||||
}[keyof typeof APP_STATE_STORAGE_CONF];
|
currentItemBackgroundColor: appState.currentItemBackgroundColor,
|
||||||
const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] };
|
currentItemFillStyle: appState.currentItemFillStyle,
|
||||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
currentItemStrokeWidth: appState.currentItemStrokeWidth,
|
||||||
const propConfig = APP_STATE_STORAGE_CONF[key];
|
currentItemRoughness: appState.currentItemRoughness,
|
||||||
if (propConfig?.[exportType]) {
|
currentItemOpacity: appState.currentItemOpacity,
|
||||||
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
currentItemFont: appState.currentItemFont,
|
||||||
stateForExport[key] = appState[key];
|
currentItemTextAlign: appState.currentItemTextAlign,
|
||||||
}
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
}
|
name: appState.name,
|
||||||
return stateForExport;
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
|
export function cleanAppStateForExport(appState: AppState) {
|
||||||
return _clearAppStateForStorage(appState, "browser");
|
return {
|
||||||
};
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
|
};
|
||||||
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
}
|
||||||
return _clearAppStateForStorage(appState, "export");
|
|
||||||
};
|
|
||||||
|
479
src/charts.ts
@@ -1,479 +0,0 @@
|
|||||||
import colors from "./colors";
|
|
||||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
|
|
||||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
|
||||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
|
||||||
import { randomId } from "./random";
|
|
||||||
|
|
||||||
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
|
||||||
|
|
||||||
const BAR_WIDTH = 32;
|
|
||||||
const BAR_GAP = 12;
|
|
||||||
const BAR_HEIGHT = 256;
|
|
||||||
const GRID_OPACITY = 50;
|
|
||||||
|
|
||||||
export interface Spreadsheet {
|
|
||||||
title: string | null;
|
|
||||||
labels: string[] | null;
|
|
||||||
values: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
|
|
||||||
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
|
|
||||||
|
|
||||||
type ParseSpreadsheetResult =
|
|
||||||
| { type: typeof NOT_SPREADSHEET; reason: string }
|
|
||||||
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
|
||||||
|
|
||||||
const tryParseNumber = (s: string): number | null => {
|
|
||||||
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
|
|
||||||
if (!match) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parseFloat(match[1].replace(/,/g, ""));
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
|
||||||
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
|
||||||
|
|
||||||
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|
||||||
const numCols = cells[0].length;
|
|
||||||
|
|
||||||
if (numCols > 2) {
|
|
||||||
return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (numCols === 1) {
|
|
||||||
if (!isNumericColumn(cells, 0)) {
|
|
||||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasHeader = tryParseNumber(cells[0][0]) === null;
|
|
||||||
const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
|
|
||||||
tryParseNumber(line[0]),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (values.length < 2) {
|
|
||||||
return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: VALID_SPREADSHEET,
|
|
||||||
spreadsheet: {
|
|
||||||
title: hasHeader ? cells[0][0] : null,
|
|
||||||
labels: null,
|
|
||||||
values: values as number[],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
|
||||||
|
|
||||||
if (!isNumericColumn(cells, valueColumnIndex)) {
|
|
||||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
|
||||||
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
|
|
||||||
const rows = hasHeader ? cells.slice(1) : cells;
|
|
||||||
|
|
||||||
if (rows.length < 2) {
|
|
||||||
return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: VALID_SPREADSHEET,
|
|
||||||
spreadsheet: {
|
|
||||||
title: hasHeader ? cells[0][valueColumnIndex] : null,
|
|
||||||
labels: rows.map((row) => row[labelColumnIndex]),
|
|
||||||
values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const transposeCells = (cells: string[][]) => {
|
|
||||||
const nextCells: string[][] = [];
|
|
||||||
for (let col = 0; col < cells[0].length; col++) {
|
|
||||||
const nextCellRow: string[] = [];
|
|
||||||
for (let row = 0; row < cells.length; row++) {
|
|
||||||
nextCellRow.push(cells[row][col]);
|
|
||||||
}
|
|
||||||
nextCells.push(nextCellRow);
|
|
||||||
}
|
|
||||||
return nextCells;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
|
||||||
// Copy/paste from excel, spreadhseets, tsv, csv.
|
|
||||||
// For now we only accept 2 columns with an optional header
|
|
||||||
|
|
||||||
// Check for tab separated values
|
|
||||||
let lines = text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim().split("\t"));
|
|
||||||
|
|
||||||
// Check for comma separated files
|
|
||||||
if (lines.length && lines[0].length !== 2) {
|
|
||||||
lines = text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim().split(","));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lines.length === 0) {
|
|
||||||
return { type: NOT_SPREADSHEET, reason: "No values" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const numColsFirstLine = lines[0].length;
|
|
||||||
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
|
|
||||||
|
|
||||||
if (!isSpreadsheet) {
|
|
||||||
return {
|
|
||||||
type: NOT_SPREADSHEET,
|
|
||||||
reason: "All rows don't have same number of columns",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = tryParseCells(lines);
|
|
||||||
if (result.type !== VALID_SPREADSHEET) {
|
|
||||||
const transposedResults = tryParseCells(transposeCells(lines));
|
|
||||||
if (transposedResults.type === VALID_SPREADSHEET) {
|
|
||||||
return transposedResults;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const bgColors = colors.elementBackground.slice(
|
|
||||||
2,
|
|
||||||
colors.elementBackground.length,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Put all the common properties here so when the whole chart is selected
|
|
||||||
// the properties dialog shows the correct selected values
|
|
||||||
const commonProps = {
|
|
||||||
fillStyle: "hachure",
|
|
||||||
fontFamily: DEFAULT_FONT_FAMILY,
|
|
||||||
fontSize: DEFAULT_FONT_SIZE,
|
|
||||||
opacity: 100,
|
|
||||||
roughness: 1,
|
|
||||||
strokeColor: colors.elementStroke[0],
|
|
||||||
strokeSharpness: "sharp",
|
|
||||||
strokeStyle: "solid",
|
|
||||||
strokeWidth: 1,
|
|
||||||
verticalAlign: "middle",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
|
||||||
const chartWidth =
|
|
||||||
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
|
|
||||||
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
|
|
||||||
return { chartWidth, chartHeight };
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartXLabels = (
|
|
||||||
spreadsheet: Spreadsheet,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
groupId: string,
|
|
||||||
backgroundColor: string,
|
|
||||||
): ChartElements => {
|
|
||||||
return (
|
|
||||||
spreadsheet.labels?.map((label, index) => {
|
|
||||||
return newTextElement({
|
|
||||||
groupIds: [groupId],
|
|
||||||
backgroundColor,
|
|
||||||
...commonProps,
|
|
||||||
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
|
|
||||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
|
||||||
y: y + BAR_GAP / 2,
|
|
||||||
width: BAR_WIDTH,
|
|
||||||
angle: 5.87,
|
|
||||||
fontSize: 16,
|
|
||||||
textAlign: "center",
|
|
||||||
verticalAlign: "top",
|
|
||||||
});
|
|
||||||
}) || []
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartYLabels = (
|
|
||||||
spreadsheet: Spreadsheet,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
groupId: string,
|
|
||||||
backgroundColor: string,
|
|
||||||
): ChartElements => {
|
|
||||||
const minYLabel = newTextElement({
|
|
||||||
groupIds: [groupId],
|
|
||||||
backgroundColor,
|
|
||||||
...commonProps,
|
|
||||||
x: x - BAR_GAP,
|
|
||||||
y: y - BAR_GAP,
|
|
||||||
text: "0",
|
|
||||||
textAlign: "right",
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxYLabel = newTextElement({
|
|
||||||
groupIds: [groupId],
|
|
||||||
backgroundColor,
|
|
||||||
...commonProps,
|
|
||||||
x: x - BAR_GAP,
|
|
||||||
y: y - BAR_HEIGHT - minYLabel.height / 2,
|
|
||||||
text: Math.max(...spreadsheet.values).toLocaleString(),
|
|
||||||
textAlign: "right",
|
|
||||||
});
|
|
||||||
|
|
||||||
return [minYLabel, maxYLabel];
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartLines = (
|
|
||||||
spreadsheet: Spreadsheet,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
groupId: string,
|
|
||||||
backgroundColor: string,
|
|
||||||
): ChartElements => {
|
|
||||||
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
|
||||||
const xLine = newLinearElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
type: "line",
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
width: chartWidth,
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[chartWidth, 0],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const yLine = newLinearElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
type: "line",
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
height: chartHeight,
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[0, -chartHeight],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxLine = newLinearElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
type: "line",
|
|
||||||
x,
|
|
||||||
y: y - BAR_HEIGHT - BAR_GAP,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
strokeStyle: "dotted",
|
|
||||||
width: chartWidth,
|
|
||||||
opacity: GRID_OPACITY,
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[chartWidth, 0],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return [xLine, yLine, maxLine];
|
|
||||||
};
|
|
||||||
|
|
||||||
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
|
|
||||||
const chartBaseElements = (
|
|
||||||
spreadsheet: Spreadsheet,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
groupId: string,
|
|
||||||
backgroundColor: string,
|
|
||||||
debug?: boolean,
|
|
||||||
): ChartElements => {
|
|
||||||
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
|
||||||
|
|
||||||
const title = spreadsheet.title
|
|
||||||
? newTextElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
text: spreadsheet.title,
|
|
||||||
x: x + chartWidth / 2,
|
|
||||||
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
|
|
||||||
strokeSharpness: "sharp",
|
|
||||||
strokeStyle: "solid",
|
|
||||||
textAlign: "center",
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const debugRect = debug
|
|
||||||
? newElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
type: "rectangle",
|
|
||||||
x,
|
|
||||||
y: y - chartHeight,
|
|
||||||
width: chartWidth,
|
|
||||||
height: chartHeight,
|
|
||||||
strokeColor: colors.elementStroke[0],
|
|
||||||
fillStyle: "solid",
|
|
||||||
opacity: 6,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
...(debugRect ? [debugRect] : []),
|
|
||||||
...(title ? [title] : []),
|
|
||||||
...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
|
|
||||||
...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
|
|
||||||
...chartLines(spreadsheet, x, y, groupId, backgroundColor),
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartTypeBar = (
|
|
||||||
spreadsheet: Spreadsheet,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
): ChartElements => {
|
|
||||||
const max = Math.max(...spreadsheet.values);
|
|
||||||
const groupId = randomId();
|
|
||||||
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
|
||||||
|
|
||||||
const bars = spreadsheet.values.map((value, index) => {
|
|
||||||
const barHeight = (value / max) * BAR_HEIGHT;
|
|
||||||
return newElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
type: "rectangle",
|
|
||||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
|
|
||||||
y: y - barHeight - BAR_GAP,
|
|
||||||
width: BAR_WIDTH,
|
|
||||||
height: barHeight,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return [
|
|
||||||
...bars,
|
|
||||||
...chartBaseElements(
|
|
||||||
spreadsheet,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
groupId,
|
|
||||||
backgroundColor,
|
|
||||||
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartTypeLine = (
|
|
||||||
spreadsheet: Spreadsheet,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
): ChartElements => {
|
|
||||||
const max = Math.max(...spreadsheet.values);
|
|
||||||
const groupId = randomId();
|
|
||||||
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
|
||||||
|
|
||||||
let index = 0;
|
|
||||||
const points = [];
|
|
||||||
for (const value of spreadsheet.values) {
|
|
||||||
const cx = index * (BAR_WIDTH + BAR_GAP);
|
|
||||||
const cy = -(value / max) * BAR_HEIGHT;
|
|
||||||
points.push([cx, cy]);
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxX = Math.max(...points.map((element) => element[0]));
|
|
||||||
const maxY = Math.max(...points.map((element) => element[1]));
|
|
||||||
const minX = Math.min(...points.map((element) => element[0]));
|
|
||||||
const minY = Math.min(...points.map((element) => element[1]));
|
|
||||||
|
|
||||||
const line = newLinearElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
type: "line",
|
|
||||||
x: x + BAR_GAP + BAR_WIDTH / 2,
|
|
||||||
y: y - BAR_GAP,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
height: maxY - minY,
|
|
||||||
width: maxX - minX,
|
|
||||||
strokeWidth: 2,
|
|
||||||
points: points as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dots = spreadsheet.values.map((value, index) => {
|
|
||||||
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
|
||||||
const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
|
|
||||||
return newElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
fillStyle: "solid",
|
|
||||||
strokeWidth: 2,
|
|
||||||
type: "ellipse",
|
|
||||||
x: x + cx + BAR_WIDTH / 2,
|
|
||||||
y: y + cy - BAR_GAP * 2,
|
|
||||||
width: BAR_GAP,
|
|
||||||
height: BAR_GAP,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const lines = spreadsheet.values.map((value, index) => {
|
|
||||||
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
|
||||||
const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
|
|
||||||
return newLinearElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
type: "line",
|
|
||||||
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
|
|
||||||
y: y - cy,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
height: cy,
|
|
||||||
strokeStyle: "dotted",
|
|
||||||
opacity: GRID_OPACITY,
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[0, cy],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return [
|
|
||||||
...chartBaseElements(
|
|
||||||
spreadsheet,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
groupId,
|
|
||||||
backgroundColor,
|
|
||||||
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
|
||||||
),
|
|
||||||
line,
|
|
||||||
...lines,
|
|
||||||
...dots,
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const renderSpreadsheet = (
|
|
||||||
chartType: string,
|
|
||||||
spreadsheet: Spreadsheet,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
): ChartElements => {
|
|
||||||
if (chartType === "line") {
|
|
||||||
return chartTypeLine(spreadsheet, x, y);
|
|
||||||
}
|
|
||||||
return chartTypeBar(spreadsheet, x, y);
|
|
||||||
};
|
|
@@ -1,37 +0,0 @@
|
|||||||
import colors from "./colors";
|
|
||||||
import { AppState } from "./types";
|
|
||||||
|
|
||||||
export const getClientColors = (clientId: string, appState: AppState) => {
|
|
||||||
if (appState?.collaborators) {
|
|
||||||
const currentUser = appState.collaborators.get(clientId);
|
|
||||||
if (currentUser?.color) {
|
|
||||||
return currentUser.color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Naive way of getting an integer out of the clientId
|
|
||||||
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
|
|
||||||
|
|
||||||
// Skip transparent background.
|
|
||||||
const backgrounds = colors.elementBackground.slice(1);
|
|
||||||
const strokes = colors.elementStroke.slice(1);
|
|
||||||
return {
|
|
||||||
background: backgrounds[sum % backgrounds.length],
|
|
||||||
stroke: strokes[sum % strokes.length],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getClientInitials = (username?: string | null) => {
|
|
||||||
if (!username) {
|
|
||||||
return "?";
|
|
||||||
}
|
|
||||||
const names = username.trim().split(" ");
|
|
||||||
|
|
||||||
if (names.length < 2) {
|
|
||||||
return names[0].substring(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstName = names[0];
|
|
||||||
const lastName = names[names.length - 1];
|
|
||||||
|
|
||||||
return (firstName[0] + lastName[0]).toUpperCase();
|
|
||||||
};
|
|
185
src/clipboard.ts
@@ -5,16 +5,6 @@ import {
|
|||||||
import { getSelectedElements } from "./scene";
|
import { getSelectedElements } from "./scene";
|
||||||
import { AppState } from "./types";
|
import { AppState } from "./types";
|
||||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
|
||||||
import { canvasToBlob } from "./data/blob";
|
|
||||||
|
|
||||||
const TYPE_ELEMENTS = "excalidraw/elements";
|
|
||||||
|
|
||||||
type ElementsClipboard = {
|
|
||||||
type: typeof TYPE_ELEMENTS;
|
|
||||||
created: number;
|
|
||||||
elements: ExcalidrawElement[];
|
|
||||||
};
|
|
||||||
|
|
||||||
let CLIPBOARD = "";
|
let CLIPBOARD = "";
|
||||||
let PREFER_APP_CLIPBOARD = false;
|
let PREFER_APP_CLIPBOARD = false;
|
||||||
@@ -31,139 +21,104 @@ export const probablySupportsClipboardBlob =
|
|||||||
"ClipboardItem" in window &&
|
"ClipboardItem" in window &&
|
||||||
"toBlob" in HTMLCanvasElement.prototype;
|
"toBlob" in HTMLCanvasElement.prototype;
|
||||||
|
|
||||||
const isElementsClipboard = (contents: any): contents is ElementsClipboard => {
|
export async function copyToAppClipboard(
|
||||||
if (contents?.type === TYPE_ELEMENTS) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const copyToClipboard = async (
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) {
|
||||||
const contents: ElementsClipboard = {
|
CLIPBOARD = JSON.stringify(getSelectedElements(elements, appState));
|
||||||
type: TYPE_ELEMENTS,
|
|
||||||
created: Date.now(),
|
|
||||||
elements: getSelectedElements(elements, appState),
|
|
||||||
};
|
|
||||||
const json = JSON.stringify(contents);
|
|
||||||
CLIPBOARD = json;
|
|
||||||
try {
|
try {
|
||||||
|
// when copying to in-app clipboard, clear system clipboard so that if
|
||||||
|
// system clip contains text on paste we know it was copied *after* user
|
||||||
|
// copied elements, and thus we should prefer the text content.
|
||||||
|
await copyTextToSystemClipboard(null);
|
||||||
PREFER_APP_CLIPBOARD = false;
|
PREFER_APP_CLIPBOARD = false;
|
||||||
await copyTextToSystemClipboard(json);
|
} catch {
|
||||||
} catch (error) {
|
// if clearing system clipboard didn't work, we should prefer in-app
|
||||||
|
// clipboard even if there's text in system clipboard on paste, because
|
||||||
|
// we can't be sure of the order of copy operations
|
||||||
PREFER_APP_CLIPBOARD = true;
|
PREFER_APP_CLIPBOARD = true;
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const getAppClipboard = (): Partial<ElementsClipboard> => {
|
export function getAppClipboard(): {
|
||||||
|
elements?: readonly ExcalidrawElement[];
|
||||||
|
} {
|
||||||
if (!CLIPBOARD) {
|
if (!CLIPBOARD) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(CLIPBOARD);
|
const clipboardElements = JSON.parse(CLIPBOARD);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Array.isArray(clipboardElements) &&
|
||||||
|
clipboardElements.length > 0 &&
|
||||||
|
clipboardElements[0].type // need to implement a better check here...
|
||||||
|
) {
|
||||||
|
return { elements: clipboardElements };
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const parsePotentialSpreadsheet = (
|
return {};
|
||||||
text: string,
|
}
|
||||||
): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
|
|
||||||
const result = tryParseSpreadsheet(text);
|
|
||||||
if (result.type === VALID_SPREADSHEET) {
|
|
||||||
return { spreadsheet: result.spreadsheet };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
export async function getClipboardContent(
|
||||||
* Retrieves content from system clipboard (either from ClipboardEvent or
|
|
||||||
* via async clipboard API if supported)
|
|
||||||
*/
|
|
||||||
const getSystemClipboard = async (
|
|
||||||
event: ClipboardEvent | null,
|
event: ClipboardEvent | null,
|
||||||
): Promise<string> => {
|
): Promise<{
|
||||||
|
text?: string;
|
||||||
|
elements?: readonly ExcalidrawElement[];
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const text = event
|
const text = event
|
||||||
? event.clipboardData?.getData("text/plain").trim()
|
? event.clipboardData?.getData("text/plain").trim()
|
||||||
: probablySupportsClipboardReadText &&
|
: probablySupportsClipboardReadText &&
|
||||||
(await navigator.clipboard.readText());
|
(await navigator.clipboard.readText());
|
||||||
|
|
||||||
return text || "";
|
if (text && !PREFER_APP_CLIPBOARD && !text.includes(SVG_EXPORT_TAG)) {
|
||||||
} catch {
|
return { text };
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attemps to parse clipboard. Prefers system clipboard.
|
|
||||||
*/
|
|
||||||
export const parseClipboard = async (
|
|
||||||
event: ClipboardEvent | null,
|
|
||||||
): Promise<{
|
|
||||||
spreadsheet?: Spreadsheet;
|
|
||||||
elements?: readonly ExcalidrawElement[];
|
|
||||||
text?: string;
|
|
||||||
errorMessage?: string;
|
|
||||||
}> => {
|
|
||||||
const systemClipboard = await getSystemClipboard(event);
|
|
||||||
|
|
||||||
// if system clipboard empty, couldn't be resolved, or contains previously
|
|
||||||
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
|
|
||||||
// elements
|
|
||||||
if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) {
|
|
||||||
return getAppClipboard();
|
|
||||||
}
|
|
||||||
|
|
||||||
// if system clipboard contains spreadsheet, use it even though it's
|
|
||||||
// technically possible it's staler than in-app clipboard
|
|
||||||
const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard);
|
|
||||||
if (spreadsheetResult) {
|
|
||||||
return spreadsheetResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
const appClipboardData = getAppClipboard();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const systemClipboardData = JSON.parse(systemClipboard);
|
|
||||||
// system clipboard elements are newer than in-app clipboard
|
|
||||||
if (
|
|
||||||
isElementsClipboard(systemClipboardData) &&
|
|
||||||
(!appClipboardData?.created ||
|
|
||||||
appClipboardData.created < systemClipboardData.created)
|
|
||||||
) {
|
|
||||||
return { elements: systemClipboardData.elements };
|
|
||||||
}
|
}
|
||||||
// in-app clipboard is newer than system clipboard
|
} catch (error) {
|
||||||
return appClipboardData;
|
console.error(error);
|
||||||
} catch {
|
|
||||||
// system clipboard doesn't contain excalidraw elements → return plaintext
|
|
||||||
// unless we set a flag to prefer in-app clipboard because browser didn't
|
|
||||||
// support storing to system clipboard on copy
|
|
||||||
return PREFER_APP_CLIPBOARD && appClipboardData.elements
|
|
||||||
? appClipboardData
|
|
||||||
: { text: systemClipboard };
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
|
return getAppClipboard();
|
||||||
const blob = await canvasToBlob(canvas);
|
}
|
||||||
await navigator.clipboard.write([
|
|
||||||
new window.ClipboardItem({ "image/png": blob }),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const copyTextToSystemClipboard = async (text: string | null) => {
|
export async function copyCanvasToClipboardAsPng(canvas: HTMLCanvasElement) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
canvas.toBlob(async function (blob: any) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new window.ClipboardItem({ "image/png": blob }),
|
||||||
|
]);
|
||||||
|
resolve();
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyCanvasToClipboardAsSvg(svgroot: SVGSVGElement) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(svgroot.outerHTML);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyTextToSystemClipboard(text: string | null) {
|
||||||
let copied = false;
|
let copied = false;
|
||||||
if (probablySupportsClipboardWriteText) {
|
if (probablySupportsClipboardWriteText) {
|
||||||
try {
|
try {
|
||||||
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
|
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
|
||||||
// not focused
|
// not focused
|
||||||
await navigator.clipboard.writeText(text || "");
|
await navigator.clipboard.writeText(text || "");
|
||||||
copied = true;
|
copied = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -172,14 +127,14 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Note that execCommand doesn't allow copying empty strings, so if we're
|
// Note that execCommand doesn't allow copying empty strings, so if we're
|
||||||
// clearing clipboard using this API, we must copy at least an empty char
|
// clearing clipboard using this API, we must copy at least an empty char
|
||||||
if (!copied && !copyTextViaExecCommand(text || " ")) {
|
if (!copied && !copyTextViaExecCommand(text || " ")) {
|
||||||
throw new Error("couldn't copy");
|
throw new Error("couldn't copy");
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
|
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
|
||||||
const copyTextViaExecCommand = (text: string) => {
|
function copyTextViaExecCommand(text: string) {
|
||||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
const textarea = document.createElement("textarea");
|
const textarea = document.createElement("textarea");
|
||||||
@@ -213,4 +168,4 @@ const copyTextViaExecCommand = (text: string) => {
|
|||||||
textarea.remove();
|
textarea.remove();
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
};
|
}
|
||||||
|