Compare commits
5 Commits
vscode-rea
...
improve_co
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ce4b64b2a3 | ||
![]() |
ef82e15ee8 | ||
![]() |
9f6e3c5a9d | ||
![]() |
8a106dde57 | ||
![]() |
2dc84f04be |
@@ -1,10 +1,10 @@
|
||||
*
|
||||
!.env
|
||||
!.eslintrc.json
|
||||
!.npmrc
|
||||
!.prettierrc
|
||||
!package.json
|
||||
!public/
|
||||
!src/
|
||||
!.npmrc
|
||||
!.eslintrc.json
|
||||
!.prettierrc
|
||||
!package-lock.json
|
||||
!package.json
|
||||
!tsconfig.json
|
||||
!yarn.lock
|
||||
!.env
|
||||
|
5
.env
Normal file
@@ -0,0 +1,5 @@
|
||||
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://excalidraw-socket.herokuapp.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,22 +0,0 @@
|
||||
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||
REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||
|
||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
||||
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
|
||||
REACT_APP_WS_SERVER_URL=http://localhost:3002
|
||||
|
||||
# set this only if using the collaboration workflow we use on excalidraw.com
|
||||
REACT_APP_PORTAL_URL=
|
||||
|
||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
||||
|
||||
# put these in your .env.local, or make sure you don't commit!
|
||||
# must be lowercase `true` when turned on
|
||||
#
|
||||
# whether to enable Service Workers in development
|
||||
REACT_APP_DEV_ENABLE_SW=
|
||||
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||
# debugging Service Workers.
|
||||
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
|
@@ -1,17 +1 @@
|
||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
|
||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
||||
REACT_APP_PORTAL_URL=https://portal.excalidraw.com
|
||||
# Fill to set socket server URL used for collaboration.
|
||||
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
|
||||
REACT_APP_WS_SERVER_URL=
|
||||
|
||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||
|
||||
# production-only vars
|
||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||
|
||||
REACT_APP_PLUS_APP=https://app.excalidraw.com
|
||||
REACT_APP_INCLUDE_GTAG=true
|
||||
|
@@ -4,5 +4,3 @@ package-lock.json
|
||||
.vscode/
|
||||
firebase/
|
||||
dist/
|
||||
public/workbox
|
||||
src/packages/excalidraw/types
|
||||
|
@@ -1,7 +1,40 @@
|
||||
{
|
||||
"extends": ["@excalidraw/eslint-config", "react-app"],
|
||||
"extends": ["prettier", "react-app"],
|
||||
"plugins": ["prettier"],
|
||||
"rules": {
|
||||
"curly": "warn",
|
||||
"dot-notation": "warn",
|
||||
"import/no-anonymous-default-export": "off",
|
||||
"no-restricted-globals": "off"
|
||||
"no-console": [
|
||||
"warn",
|
||||
{
|
||||
"allow": ["warn", "error", "info"]
|
||||
}
|
||||
],
|
||||
"no-else-return": "warn",
|
||||
"no-lonely-if": "warn",
|
||||
"no-restricted-syntax": [
|
||||
"warn",
|
||||
{
|
||||
"message": "Use 't(...)' instead of literal text in JSX",
|
||||
"selector": "JSXText[value=/\\w/]"
|
||||
}
|
||||
],
|
||||
"no-unneeded-ternary": "warn",
|
||||
"no-unused-expressions": "warn",
|
||||
"no-unused-vars": "warn",
|
||||
"no-useless-return": "warn",
|
||||
"no-var": "warn",
|
||||
"object-shorthand": "warn",
|
||||
"one-var": ["warn", "never"],
|
||||
"prefer-arrow-callback": "warn",
|
||||
"prefer-const": [
|
||||
"warn",
|
||||
{
|
||||
"destructuring": "all"
|
||||
}
|
||||
],
|
||||
"prefer-template": "warn",
|
||||
"prettier/prettier": "warn"
|
||||
}
|
||||
}
|
||||
|
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 |
27
.github/workflows/autorelease-excalidraw.yml
vendored
@@ -1,27 +0,0 @@
|
||||
name: Auto release excalidraw next
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
Auto-release-excalidraw-next:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js 14.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
- name: Set up publish access
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Auto release
|
||||
run: |
|
||||
yarn add @actions/core
|
||||
yarn autorelease
|
55
.github/workflows/autorelease-preview.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: Auto release excalidraw preview
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
Auto-release-excalidraw-preview:
|
||||
name: Auto release preview
|
||||
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: React to release comment
|
||||
uses: peter-evans/create-or-update-comment@v1
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
reactions: "+1"
|
||||
- name: Get PR SHA
|
||||
id: sha
|
||||
uses: actions/github-script@v4
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { owner, repo, number } = context.issue;
|
||||
const pr = await github.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: number,
|
||||
});
|
||||
return pr.data.head.sha
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
ref: ${{ steps.sha.outputs.result }}
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js 14.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
- name: Set up publish access
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Auto release preview
|
||||
id: "autorelease"
|
||||
run: |
|
||||
yarn add @actions/core
|
||||
yarn autorelease preview ${{ github.event.issue.number }}
|
||||
- name: Post comment post release
|
||||
if: always()
|
||||
uses: peter-evans/create-or-update-comment@v1
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
|
4
.github/workflows/build-docker.yml
vendored
@@ -4,10 +4,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v1
|
||||
- run: docker build -t excalidraw .
|
||||
|
33
.github/workflows/build-packages.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Build packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup Node.js 12.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm ci --prefix src/packages/excalidraw
|
||||
npm ci --prefix src/packages/utils
|
||||
|
||||
- name: Build @excalidraw/excalidraw
|
||||
run: |
|
||||
npm run pack --prefix src/packages/excalidraw
|
||||
|
||||
- name: Build @excalidraw/utils
|
||||
run: |
|
||||
npm run pack --prefix src/packages/utils
|
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 }}
|
33
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
schedule:
|
||||
- cron: "18 7 * * 0"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["typescript"]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
24
.github/workflows/lint.yml
vendored
@@ -1,22 +1,28 @@
|
||||
name: Lint
|
||||
|
||||
on: pull_request
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
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:
|
||||
node-version: 14.x
|
||||
node-version: 12.x
|
||||
|
||||
- name: Install and lint
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
yarn test:other
|
||||
yarn test:code
|
||||
yarn test:typecheck
|
||||
npm ci
|
||||
npm run test:other
|
||||
npm run test:code
|
||||
npm run test:typecheck
|
||||
env:
|
||||
CI: true
|
||||
|
29
.github/workflows/locales-coverage.yml
vendored
@@ -3,7 +3,7 @@ name: Build locales coverage
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- l10n_master
|
||||
- "l10n_master"
|
||||
|
||||
jobs:
|
||||
locales:
|
||||
@@ -14,34 +14,19 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
|
||||
- name: Setup Node.js 14.x
|
||||
uses: actions/setup-node@v2
|
||||
- name: Setup Node.js 12.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.x
|
||||
node-version: 12.x
|
||||
|
||||
- name: Create report file
|
||||
run: |
|
||||
yarn locales-coverage
|
||||
npm run 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 config --global user.name 'Kostas Bariotis'
|
||||
git config --global user.email 'konmpar@gmail.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 }}
|
||||
|
2
.github/workflows/publish-docker.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: docker/build-push-action@v2
|
||||
- uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
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:
|
||||
push:
|
||||
@@ -6,23 +6,27 @@ on:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
sentry:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Node.js 14.x
|
||||
uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v1.0.0
|
||||
|
||||
- name: Setup Node.js 12.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.x
|
||||
node-version: 12.x
|
||||
|
||||
- name: Install and build
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
yarn build:app
|
||||
npm ci
|
||||
npm run build:app
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Install Sentry
|
||||
run: |
|
||||
curl -sL https://sentry.io/get-cli/ | bash
|
||||
|
||||
- name: Create new Sentry release
|
||||
run: |
|
||||
export SENTRY_RELEASE=$(sentry-cli releases propose-version)
|
||||
|
23
.github/workflows/test.yml
vendored
@@ -1,17 +1,26 @@
|
||||
name: Tests
|
||||
|
||||
on: pull_request
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Node.js 14.x
|
||||
uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup Node.js 12.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.x
|
||||
node-version: 12.x
|
||||
|
||||
- name: Install and test
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
yarn test:app
|
||||
npm ci
|
||||
npm run test:app
|
||||
env:
|
||||
CI: true
|
||||
|
9
.gitignore
vendored
@@ -5,11 +5,9 @@
|
||||
.env.test.local
|
||||
.envrc
|
||||
.eslintcache
|
||||
.history
|
||||
.idea
|
||||
.vercel
|
||||
.vscode
|
||||
.yarn
|
||||
*.log
|
||||
*.tgz
|
||||
build
|
||||
@@ -18,12 +16,7 @@ firebase
|
||||
logs
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
package-lock.json
|
||||
static
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
src/packages/excalidraw/types
|
||||
src/packages/excalidraw/example/public/bundle.js
|
||||
src/packages/excalidraw/example/public/excalidraw-assets-dev
|
||||
src/packages/excalidraw/example/public/excalidraw.development.js
|
||||
|
||||
yarn.lock
|
||||
|
@@ -1,2 +0,0 @@
|
||||
#!/bin/sh
|
||||
yarn lint-staged
|
3
.prettierrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"trailingComma": "all"
|
||||
}
|
@@ -5,10 +5,11 @@
|
||||
### Option 1 - Manual
|
||||
|
||||
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`
|
||||
|
||||
> 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
|
||||
> git remote add upstream https://github.com/excalidraw/excalidraw.git
|
||||
@@ -19,45 +20,8 @@
|
||||
### Option 2 - CodeSandbox
|
||||
|
||||
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. Tap on `Fork Sandbox`
|
||||
1. Write your code
|
||||
1. Commit and PR automatically
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
Don't worry if you get any of the below wrong, or if you don't know how. We'll gladly help out.
|
||||
|
||||
### Title
|
||||
|
||||
Make sure the title starts with a semantic prefix:
|
||||
|
||||
- **feat**: A new feature
|
||||
- **fix**: A bug fix
|
||||
- **docs**: Documentation only changes
|
||||
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
|
||||
- **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
- **perf**: A code change that improves performance
|
||||
- **test**: Adding missing tests or correcting existing tests
|
||||
- **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
|
||||
- **ci**: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
|
||||
- **chore**: Other changes that don't modify src or test files
|
||||
- **revert**: Reverts a previous commit
|
||||
|
||||
### Changelog
|
||||
|
||||
Add a brief description of your pull request to the changelog located here: [`src/packages/excalidraw/CHANGELOG.md`](src/packages/excalidraw/CHANGELOG.md)
|
||||
|
||||
Notes:
|
||||
|
||||
- Make sure to prepend to the section corresponding with the semantic prefix you selected in the title
|
||||
- Link to your pull request - this will require updating the CHANGELOG _after_ creating the pull request
|
||||
|
||||
### Testing
|
||||
|
||||
Once you submit your pull request it will automatically be tested. Be sure to check the results of the test and fix any issues that arise.
|
||||
|
||||
It's also a good idea to consider if your change should include additional tests. This is highly recommended for new features or bug-fixes. For example, it's good practice to create a test for each bug you fix which ensures that we don't regress the code in the future.
|
||||
|
||||
Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well.
|
||||
|
@@ -2,15 +2,16 @@ FROM node:14-alpine AS build
|
||||
|
||||
WORKDIR /opt/node_app
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn --ignore-optional
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm i --no-optional
|
||||
|
||||
ARG REACT_APP_INCLUDE_GTAG=false
|
||||
ARG NODE_ENV=production
|
||||
|
||||
COPY . .
|
||||
RUN yarn build:app:docker
|
||||
RUN npm run build:app:docker
|
||||
|
||||
FROM nginx:1.21-alpine
|
||||
FROM nginx:1.17-alpine
|
||||
|
||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
||||
|
||||
|
199
README.md
@@ -1,8 +1,8 @@
|
||||
<div align="center" style="display:flex;flex-direction:column;">
|
||||
<a href="https://excalidraw.com">
|
||||
<img width="540" src="./public/og-image-sm.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
||||
<img src="./public/og-image.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
||||
</a>
|
||||
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.<br>Collaborative and end-to-end encrypted.</h3>
|
||||
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.</h3>
|
||||
<p>
|
||||
<a href="https://twitter.com/Excalidraw">
|
||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
|
||||
@@ -10,171 +10,62 @@
|
||||
<a target="_blank" href="https://crowdin.com/project/excalidraw">
|
||||
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
||||
</a>
|
||||
<a target="_blank" href="https://hub.docker.com/r/excalidraw/excalidraw">
|
||||
<img src="https://img.shields.io/docker/pulls/excalidraw/excalidraw">
|
||||
</a>
|
||||
</p>
|
||||
<p>Ask questions or hang out on our <a target="_blank" href="https://discord.gg/UexuTaE">discord.gg/UexuTaE</a>.</p>
|
||||
</div>
|
||||
|
||||
## Try it now
|
||||
|
||||
Go to [excalidraw.com](https://excalidraw.com) to start sketching.
|
||||
|
||||
Read the latest news and updates on our [blog](https://blog.excalidraw.com). A good start is to see all the updates of [One Year of Excalidraw](https://blog.excalidraw.com/one-year-of-excalidraw/).
|
||||
|
||||
## Supporting Excalidraw
|
||||
|
||||
If you like the project, you can become a sponsor at [Open Collective](https://opencollective.com/excalidraw).
|
||||
|
||||
[<img src="https://opencollective.com/excalidraw/tiers/sponsors/0/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/0/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/1/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/1/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/2/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/2/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/3/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/3/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/4/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/4/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/5/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/5/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/6/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/6/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/7/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/7/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/8/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/8/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/9/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/9/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/10/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/10/website)
|
||||
|
||||
<a href="https://opencollective.com/excalidraw#category-CONTRIBUTE" target="_blank"><img src="https://opencollective.com/excalidraw/tiers/backers.svg?avatarHeight=32"/></a>
|
||||
|
||||
Last but not least, we're thankful to these companies for offering their services for free:
|
||||
|
||||
[](https://vercel.com) [](https://sentry.io) [](https://crowdin.com)
|
||||
|
||||
## Who's integrating Excalidraw
|
||||
|
||||
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) • [VS Code](https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor)
|
||||
|
||||
## Documentation
|
||||
|
||||
### Shortcuts
|
||||
|
||||
You can almost do anything with shortcuts. Click on the help icon on the bottom right corner to see them all.
|
||||
|
||||
### Curved lines and arrows
|
||||
|
||||
Choose line or arrow and click click click instead of drag.
|
||||
|
||||
### Charts
|
||||
|
||||
You can easily create charts by copy pasting data from Excel or just plain comma separated text.
|
||||
|
||||
### Translating
|
||||
|
||||
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
|
||||
|
||||
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
|
||||
|
||||
### Create a collaboration session manually
|
||||
|
||||
In order to create a session manually, you just need to generate a link of this form:
|
||||
|
||||
```
|
||||
https://excalidraw.com/#room=[0-9a-f]{20},[a-zA-Z0-9_-]{22}
|
||||
```
|
||||
|
||||
#### Example
|
||||
|
||||
```
|
||||
https://excalidraw.com/#room=91bd46ae3aa84dff9d20,pfLqgEoY1c2ioq8LmGwsFA
|
||||
```
|
||||
|
||||
The first set of digits is the room. This is visible from the server that’s going to dispatch messages to everyone that knows this number.
|
||||
|
||||
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
||||
|
||||
> Note: Please ensure that the encryption key is 22 characters long.
|
||||
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.
|
||||
|
||||
## 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
|
||||
## Run the code
|
||||
|
||||
### Code Sandbox
|
||||
|
||||
- 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!
|
||||
|
||||
### Local Installation
|
||||
|
||||
These instructions will get you a copy of the project up and running on your local machine for development and testing purposes.
|
||||
|
||||
#### Requirements
|
||||
|
||||
- [Node.js](https://nodejs.org/en/)
|
||||
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
#### Clone the repo
|
||||
|
||||
```bash
|
||||
git clone https://github.com/excalidraw/excalidraw.git
|
||||
```
|
||||
|
||||
#### Install the dependencies
|
||||
|
||||
```bash
|
||||
yarn
|
||||
```
|
||||
|
||||
#### Start the server
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
|
||||
|
||||
#### Collaboration
|
||||
|
||||
For collaboration, you will need to set up [collab server](https://github.com/excalidraw/excalidraw-room) in local.
|
||||
|
||||
#### Commands
|
||||
|
||||
##### Install the dependencies
|
||||
|
||||
```
|
||||
yarn
|
||||
```
|
||||
|
||||
##### Run the project
|
||||
|
||||
```
|
||||
yarn start
|
||||
```
|
||||
|
||||
##### Reformat all files with Prettier
|
||||
|
||||
```
|
||||
yarn fix
|
||||
```
|
||||
|
||||
##### Run tests
|
||||
|
||||
```
|
||||
yarn test
|
||||
```
|
||||
|
||||
##### Update test snapshots
|
||||
|
||||
```
|
||||
yarn test:update
|
||||
```
|
||||
|
||||
##### Test for formatting with Prettier
|
||||
|
||||
```
|
||||
yarn test:code
|
||||
```
|
||||
| Command | Description |
|
||||
| --------------------- | --------------------------------- |
|
||||
| `npm install` | Install the dependencies |
|
||||
| `npm start` | Run the project |
|
||||
| `npm run fix` | Reformat all files with Prettier |
|
||||
| `npm test` | Run tests |
|
||||
| `npm run test:update` | Update test snapshots |
|
||||
| `npm run test:code` | Test for formatting with Prettier |
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
You can use docker-compose to work on Excalidraw locally if you don't want to setup a Node.js env.
|
||||
You can use docker-compose to work on excalidraw locally if you don't want to setup a Node.js env.
|
||||
|
||||
```sh
|
||||
docker-compose up --build -d
|
||||
```
|
||||
|
||||
### Self-hosting
|
||||
## Self hosting
|
||||
|
||||
We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self-host your own client under your own domain, on Kubernetes, AWS ECS, etc.
|
||||
We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self host your own client under your own domain, on Kubernetes, AWS ECS, etc.
|
||||
|
||||
```sh
|
||||
docker build -t excalidraw/excalidraw .
|
||||
@@ -185,17 +76,63 @@ The Docker image is free of analytics and other tracking libraries.
|
||||
|
||||
**At the moment, self-hosting your own instance doesn't support sharing or collaboration features.**
|
||||
|
||||
We are working towards providing a full-fledged solution for self-hosting your own Excalidraw.
|
||||
We are working towards providing a full-fledged solution for self hosting your own Excalidraw.
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change.
|
||||
|
||||
## Notable used tools
|
||||
## 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.
|
||||
|
||||
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
|
||||
|
||||
## Excalidraw is built using these awesome tools
|
||||
|
||||
- [React](https://reactjs.org)
|
||||
- [Rough.js](https://roughjs.com)
|
||||
- [TypeScript](https://www.typescriptlang.org)
|
||||
- [Vercel](https://vercel.com)
|
||||
|
||||
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
|
||||
|
||||
## 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>
|
||||
|
64
analytics.md
Normal file
@@ -0,0 +1,64 @@
|
||||
| Excalidraw | Category | Name | Label | Value |
|
||||
| ----------------------- | -------- | ---------------------------------- | ------------------------------- | --------- |
|
||||
| Shape / Selection | shape | selection, rectangle, diamond, etc | `toolbar` or `shortcut` |
|
||||
| Text on double click | shape | text | `double-click` |
|
||||
| Lock selection | shape | lock | `on` or `off` |
|
||||
| Clear canvas | action | clear canvas |
|
||||
| Zoom in | action | zoom | in | `zoom` |
|
||||
| Zoom out | action | zoom | out | `zoom` |
|
||||
| Zoom fit | action | zoom | fit | `zoom` |
|
||||
| Zoom reset | action | zoom | reset | `zoom` |
|
||||
| Scroll back to content | action | scroll to content |
|
||||
| Load file | io | load | `MIME type` |
|
||||
| Import from URL | io | import |
|
||||
| Save | io | save |
|
||||
| Save as | io | save as |
|
||||
| Export to backend | io | export | backend |
|
||||
| Export as SVG | io | export | `svg` or `clipboard-svg` |
|
||||
| Export to PNG | io | export | `png` or `clipboard-png` |
|
||||
| Canvas color | change | canvas color | `color` |
|
||||
| Background color | change | background color | `color` |
|
||||
| Stroke color | change | stroke color | `color` |
|
||||
| Stroke width | change | stroke | width | `width` |
|
||||
| Stroke style | change | style | `solid` or `dashed` or `dotted` |
|
||||
| Stroke sloppiness | change | stroke | sloppiness | `value` |
|
||||
| Fill | change | fill | `value` |
|
||||
| Edge | change | edge | `value` |
|
||||
| Opacity | change | opacity | value | `opacity` |
|
||||
| Project name | change | title |
|
||||
| Theme | change | theme | `light` or `dark` |
|
||||
| Change language | change | language | `language` |
|
||||
| Send to back | layer | move | `back` |
|
||||
| Send backward | layer | move | `down` |
|
||||
| Bring to front | layer | move | `front` |
|
||||
| Bring forward | layer | move | `up` |
|
||||
| Align left | align | align | `left` |
|
||||
| Align right | align | align | `right` |
|
||||
| Align top | align | align | `top` |
|
||||
| Align bottom | align | align | `bottom` |
|
||||
| Center horizontally | align | horizontally | `center` |
|
||||
| Center vertically | align | vertically | `center` |
|
||||
| Distribute horizontally | align | distribute | `horizontally` |
|
||||
| Distribute vertically | align | distribute | `vertically` |
|
||||
| Start session | share | session start |
|
||||
| Join session | share | session join |
|
||||
| Start end | share | session end |
|
||||
| Copy room link | share | copy link |
|
||||
| Go to collaborator | share | go to collaborator |
|
||||
| Change name | share | name |
|
||||
| Add to library | library | add |
|
||||
| Remove from library | library | remove |
|
||||
| Load library | library | load |
|
||||
| Save library | library | save |
|
||||
| Import library | library | import |
|
||||
| Shortcuts dialog | dialog | shortcuts |
|
||||
| Collaboration dialog | dialog | collaboration |
|
||||
| Export dialog | dialog | export |
|
||||
| Library dialog | dialog | library |
|
||||
| E2EE shield | exit | e2ee shield |
|
||||
| GitHub corner | exit | github |
|
||||
| Excalidraw blog | exit | blog |
|
||||
| Excalidraw guides | exit | guides |
|
||||
| File issues | exit | issues |
|
||||
| First load | load | first load |
|
||||
| Load from stroage | load | storage | size | `bytes` |
|
@@ -18,7 +18,7 @@ services:
|
||||
volumes:
|
||||
- ./:/opt/node_app/app:delegated
|
||||
- ./package.json:/opt/node_app/package.json
|
||||
- ./yarn.lock:/opt/node_app/yarn.lock
|
||||
- ./package-lock.json:/opt/node_app/package-lock.json
|
||||
- notused:/opt/node_app/app/node_modules
|
||||
|
||||
volumes:
|
||||
|
@@ -2,8 +2,5 @@
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"storage": {
|
||||
"rules": "storage.rules"
|
||||
}
|
||||
}
|
||||
|
@@ -1,11 +0,0 @@
|
||||
rules_version = '2';
|
||||
service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /{files}/rooms/{room}/{file} {
|
||||
allow get, write: if true;
|
||||
}
|
||||
match /{files}/shareLinks/{shareLink}/{file} {
|
||||
allow get, write: if true;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"public": true,
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
@@ -25,18 +24,9 @@
|
||||
],
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/webex/:match*",
|
||||
"destination": "https://for-webex.excalidraw.com"
|
||||
},
|
||||
{
|
||||
"source": "/:path*",
|
||||
"has": [
|
||||
{
|
||||
"type": "host",
|
||||
"value": "vscode.excalidraw.com"
|
||||
}
|
||||
],
|
||||
"destination": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor"
|
||||
"source": "/([^.]+)",
|
||||
"destination": "/",
|
||||
"statusCode": 301
|
||||
}
|
||||
]
|
||||
}
|
26607
package-lock.json
generated
Normal file
117
package.json
@@ -19,102 +19,87 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@tldraw/vec": "1.7.1",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/pica": "5.1.3",
|
||||
"@types/react": "17.0.39",
|
||||
"@types/react-dom": "17.0.11",
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"browser-fs-access": "0.29.1",
|
||||
"@sentry/browser": "5.28.0",
|
||||
"@sentry/integrations": "5.28.0",
|
||||
"@testing-library/jest-dom": "5.11.6",
|
||||
"@testing-library/react": "11.2.2",
|
||||
"@types/jest": "26.0.16",
|
||||
"@types/nanoid": "2.1.0",
|
||||
"@types/react": "17.0.0",
|
||||
"@types/react-dom": "17.0.0",
|
||||
"@types/socket.io-client": "1.4.34",
|
||||
"browser-nativefs": "0.11.1",
|
||||
"clsx": "1.1.1",
|
||||
"fake-indexeddb": "3.1.7",
|
||||
"firebase": "8.3.3",
|
||||
"i18next-browser-languagedetector": "6.1.4",
|
||||
"idb-keyval": "6.0.3",
|
||||
"image-blob-reduce": "3.0.1",
|
||||
"jotai": "1.6.4",
|
||||
"firebase": "8.1.2",
|
||||
"i18next-browser-languagedetector": "6.0.1",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"nanoid": "3.3.3",
|
||||
"open-color": "1.9.1",
|
||||
"nanoid": "2.1.11",
|
||||
"node-sass": "4.14.1",
|
||||
"open-color": "1.7.0",
|
||||
"pako": "1.0.11",
|
||||
"perfect-freehand": "1.0.16",
|
||||
"png-chunk-text": "1.0.0",
|
||||
"png-chunks-encode": "1.0.0",
|
||||
"png-chunks-extract": "1.0.0",
|
||||
"points-on-curve": "0.2.0",
|
||||
"pwacompat": "2.0.17",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"roughjs": "4.5.2",
|
||||
"sass": "1.51.0",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
"react-scripts": "4.0.1",
|
||||
"roughjs": "4.3.1",
|
||||
"socket.io-client": "2.3.1",
|
||||
"typescript": "4.5.5"
|
||||
"typescript": "4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@excalidraw/eslint-config": "1.0.0",
|
||||
"@excalidraw/prettier-config": "1.0.2",
|
||||
"@types/chai": "4.3.0",
|
||||
"@types/lodash.throttle": "4.1.7",
|
||||
"@types/pako": "1.0.3",
|
||||
"@types/resize-observer-browser": "0.1.7",
|
||||
"chai": "4.3.6",
|
||||
"dotenv": "16.0.1",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "2.4.0",
|
||||
"lint-staged": "12.3.7",
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.6.2",
|
||||
"rewire": "6.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"@typescript-eslint/typescript-estree": "5.10.2"
|
||||
"@types/lodash.throttle": "4.1.6",
|
||||
"@types/pako": "1.0.1",
|
||||
"asar": "3.0.3",
|
||||
"eslint-config-prettier": "7.0.0",
|
||||
"eslint-plugin-prettier": "3.1.4",
|
||||
"firebase-tools": "8.17.0",
|
||||
"husky": "4.3.0",
|
||||
"jest-canvas-mock": "2.3.0",
|
||||
"lint-staged": "10.5.3",
|
||||
"pepjs": "0.5.2",
|
||||
"prettier": "2.2.1",
|
||||
"rewire": "5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"homepage": ".",
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
},
|
||||
"jest": {
|
||||
"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,
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
||||
"build:version": "node ./scripts/build-version.js",
|
||||
"build:prebuild": "node ./scripts/prebuild.js",
|
||||
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
|
||||
"build:app:docker": "REACT_APP_INCLUDE_GTAG=false REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||
"build:app": "REACT_APP_INCLUDE_GTAG=true REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
|
||||
"build:zip": "node ./scripts/build-version.js",
|
||||
"build": "npm run build:app && npm run build:zip",
|
||||
"eject": "react-scripts eject",
|
||||
"fix:code": "yarn test:code --fix",
|
||||
"fix:other": "yarn prettier --write",
|
||||
"fix": "yarn fix:other && yarn fix:code",
|
||||
"fix:code": "npm run test:code -- --fix",
|
||||
"fix:other": "npm run prettier -- --write",
|
||||
"fix": "npm run fix:other && npm run fix:code",
|
||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"start": "react-scripts start",
|
||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
||||
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
|
||||
"test:app": "react-scripts test --passWithNoTests",
|
||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||
"test:code": "eslint --max-warnings=0 --ignore-path .gitignore --ext .js,.ts,.tsx .",
|
||||
"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:update": "yarn test:app --updateSnapshot --watchAll=false",
|
||||
"test": "yarn test:app",
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease": "node scripts/prerelease.js",
|
||||
"release": "node scripts/release.js"
|
||||
"test:update": "npm run test:app -- --updateSnapshot --watchAll=false",
|
||||
"test": "npm run 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 */
|
||||
@font-face {
|
||||
font-family: "Virgil";
|
||||
src: url("Virgil.woff2");
|
||||
src: url("FG_Virgil.woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
|
@@ -51,34 +51,13 @@
|
||||
name="twitter:description"
|
||||
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
|
||||
<script>
|
||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||
//
|
||||
// Redirect only the bare root path, so link/room/library urls are not
|
||||
// redirected.
|
||||
//
|
||||
// Putting into index.html for best performance (can't redirect on server
|
||||
// due to location.hash checks).
|
||||
if (
|
||||
window.location.pathname === "/" &&
|
||||
!window.location.hash &&
|
||||
!window.location.search &&
|
||||
// if its present redirect
|
||||
document.cookie.includes("excplus-autoredirect=true")
|
||||
) {
|
||||
window.location.href = "https://app.excalidraw.com";
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- OG tags require absolute url for images -->
|
||||
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||
|
||||
<!-- Excalidraw version -->
|
||||
<meta name="version" content="{version}" />
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
href="Virgil.woff2"
|
||||
href="FG_Virgil.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
@@ -91,6 +70,12 @@
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<link
|
||||
href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
|
||||
rel="preconnect"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<link
|
||||
rel="manifest"
|
||||
href="manifest.json"
|
||||
@@ -98,31 +83,11 @@
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD === "true") { %>
|
||||
<script>
|
||||
{
|
||||
const _WebSocket = window.WebSocket;
|
||||
window.WebSocket = function (url) {
|
||||
if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
|
||||
console.info(
|
||||
"[!!!] Live reload is disabled via process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
|
||||
);
|
||||
} else {
|
||||
return new _WebSocket(url);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<% } %>
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
// setting this so that libraries installation reuses this window tab.
|
||||
window.name = "_excalidraw";
|
||||
</script>
|
||||
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
||||
|
||||
<% if (process.env.REACT_APP_INCLUDE_GTAG === 'true') { %>
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
|
||||
src="https://www.googletagmanager.com/gtag/js?id=UA-387204-13"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
@@ -130,23 +95,22 @@
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
|
||||
gtag("config", "UA-387204-13");
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
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: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
@@ -156,28 +120,27 @@
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap; /* added line */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
.LoadingMessage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1200px) {
|
||||
#root {
|
||||
-webkit-touch-callout: default;
|
||||
-webkit-user-select: auto;
|
||||
-khtml-user-select: auto;
|
||||
-moz-user-select: auto;
|
||||
-ms-user-select: auto;
|
||||
user-select: auto;
|
||||
}
|
||||
.LoadingMessage span {
|
||||
background-color: var(--button-gray-1);
|
||||
border-radius: 5px;
|
||||
padding: 0.8em 1.2em;
|
||||
color: var(--popup-text-color);
|
||||
font-size: 1.3em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -187,6 +150,10 @@
|
||||
<header>
|
||||
<h1 class="visually-hidden">Excalidraw</h1>
|
||||
</header>
|
||||
<div id="root"></div>
|
||||
<div id="root">
|
||||
<div class="LoadingMessage">
|
||||
<span>Loading scene...</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -25,50 +25,5 @@
|
||||
"application/vnd.excalidraw+json": [".excalidraw"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/web-share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"files": [
|
||||
{
|
||||
"name": "file",
|
||||
"accept": ["application/vnd.excalidraw+json", "application/json", ".excalidraw"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/screenshots/virtual-whiteboard.png",
|
||||
"type": "image/png",
|
||||
"sizes": "462x945"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/wireframe.png",
|
||||
"type": "image/png",
|
||||
"sizes": "462x945"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/illustration.png",
|
||||
"type": "image/png",
|
||||
"sizes": "462x945"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/shapes.png",
|
||||
"type": "image/png",
|
||||
"sizes": "462x945"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/collaboration.png",
|
||||
"type": "image/png",
|
||||
"sizes": "462x945"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/export.png",
|
||||
"type": "image/png",
|
||||
"sizes": "462x945"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 76 KiB |
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 83 KiB |
Before Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 27 KiB |
Before Width: | Height: | Size: 27 KiB |
@@ -1,81 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
/* eslint-disable no-restricted-globals */
|
||||
/* global importScripts, workbox */
|
||||
|
||||
/**
|
||||
* Welcome to your Workbox-powered service worker!
|
||||
*
|
||||
* You'll need to register this file in your web app and you should
|
||||
* disable HTTP caching for this file too.
|
||||
* See https://goo.gl/nhQhGp
|
||||
*
|
||||
* The rest of the code is auto-generated. Please don't update this file
|
||||
* directly; instead, make changes to your Workbox build configuration
|
||||
* and re-run your build process.
|
||||
* See https://goo.gl/2aRDsh
|
||||
*/
|
||||
|
||||
// in dev, `process` is undefined because this file is not compiled until build
|
||||
const IS_DEVELOPMENT =
|
||||
typeof process === "undefined" || process.env.NODE_ENV !== "production";
|
||||
|
||||
if (IS_DEVELOPMENT) {
|
||||
importScripts(
|
||||
"https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js",
|
||||
);
|
||||
workbox.setConfig({
|
||||
debug: true,
|
||||
});
|
||||
} else {
|
||||
importScripts("/workbox/workbox-sw.js");
|
||||
workbox.setConfig({
|
||||
modulePathPrefix: "/workbox/",
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data && event.data.type === "SKIP_WAITING") {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
workbox.core.clientsClaim();
|
||||
|
||||
if (!IS_DEVELOPMENT) {
|
||||
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
workbox.routing.registerNavigationRoute(
|
||||
workbox.precaching.getCacheKeyForURL("./index.html"),
|
||||
{
|
||||
blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Cache relevant font files
|
||||
workbox.routing.registerRoute(
|
||||
new RegExp("/(fonts.css|.+.(ttf|woff2|otf))"),
|
||||
new workbox.strategies.StaleWhileRevalidate({
|
||||
cacheName: "fonts",
|
||||
plugins: [new workbox.expiration.Plugin({ maxEntries: 10 })],
|
||||
}),
|
||||
);
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (
|
||||
event.request.method === "POST" &&
|
||||
event.request.url.endsWith("/web-share-target")
|
||||
) {
|
||||
return event.respondWith(
|
||||
(async () => {
|
||||
const formData = await event.request.formData();
|
||||
const file = formData.get("file");
|
||||
const webShareTargetCache = await caches.open("web-share-target");
|
||||
await webShareTargetCache.put("shared-file", new Response(file));
|
||||
return Response.redirect("/?web-share-target", 303);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
});
|
@@ -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,72 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const { exec, execSync } = require("child_process");
|
||||
const core = require("@actions/core");
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
const pkg = require(excalidrawPackage);
|
||||
const isPreview = process.argv.slice(2)[0] === "preview";
|
||||
|
||||
const getShortCommitHash = () => {
|
||||
return execSync("git rev-parse --short HEAD").toString().trim();
|
||||
};
|
||||
|
||||
const publish = () => {
|
||||
const tag = isPreview ? "preview" : "next";
|
||||
|
||||
try {
|
||||
execSync(`yarn --frozen-lockfile`);
|
||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||
execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`);
|
||||
console.info(`Published ${pkg.name}@${tag}🎉`);
|
||||
core.setOutput(
|
||||
"result",
|
||||
`**Preview version has been shipped** :rocket:
|
||||
You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
|
||||
);
|
||||
} catch (error) {
|
||||
core.setOutput("result", "package couldn't be published :warning:!");
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
// get files changed between prev and head commit
|
||||
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||
if (error || stderr) {
|
||||
console.error(error);
|
||||
core.setOutput("result", ":warning: Package couldn't be published!");
|
||||
process.exit(1);
|
||||
}
|
||||
const changedFiles = stdout.trim().split("\n");
|
||||
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
||||
|
||||
const excalidrawPackageFiles = changedFiles.filter((file) => {
|
||||
return (
|
||||
(file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
|
||||
!filesToIgnoreRegex.test(file)
|
||||
);
|
||||
});
|
||||
if (!excalidrawPackageFiles.length) {
|
||||
console.info("Skipping release as no valid diff found");
|
||||
core.setOutput("result", "Skipping release as no valid diff found");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// update package.json
|
||||
let version = `${pkg.version}-${getShortCommitHash()}`;
|
||||
|
||||
// update readme
|
||||
|
||||
if (isPreview) {
|
||||
// use pullNumber-commithash as the version for preview
|
||||
const pullRequestNumber = process.argv.slice(3)[0];
|
||||
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
|
||||
}
|
||||
pkg.version = version;
|
||||
|
||||
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
||||
|
||||
console.info("Publish in progress...");
|
||||
publish();
|
||||
});
|
@@ -1,16 +1,11 @@
|
||||
const { readdirSync, writeFileSync } = require("fs");
|
||||
const files = readdirSync(`${__dirname}/../src/locales`);
|
||||
|
||||
const flatten = (object = {}, result = {}, extraKey = "") => {
|
||||
for (const key in object) {
|
||||
if (typeof object[key] !== "object") {
|
||||
result[extraKey + key] = object[key];
|
||||
} else {
|
||||
flatten(object[key], result, `${extraKey}${key}.`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const flatten = (object) =>
|
||||
Object.keys(object).reduce(
|
||||
(initial, current) => ({ ...initial, ...object[current] }),
|
||||
{},
|
||||
);
|
||||
|
||||
const locales = files.filter(
|
||||
(file) => file !== "README.md" && file !== "percentages.json",
|
||||
@@ -24,8 +19,10 @@ for (let index = 0; index < locales.length; index++) {
|
||||
|
||||
const allKeys = Object.keys(data);
|
||||
const translatedKeys = allKeys.filter((item) => data[item] !== "");
|
||||
const percentage = Math.floor((100 * translatedKeys.length) / allKeys.length);
|
||||
percentages[currentLocale.replace(".json", "")] = percentage;
|
||||
|
||||
const percentage = (100 * translatedKeys.length) / allKeys.length;
|
||||
|
||||
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
// In order to run:
|
||||
// npm install canvas # please do not check it in
|
||||
// yarn build-node
|
||||
// npm run build-node
|
||||
// node build/static/js/build-node.js
|
||||
// open test.png
|
||||
|
||||
|
@@ -2,60 +2,36 @@
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const versionFile = path.join("build", "version.json");
|
||||
const indexFile = path.join("build", "index.html");
|
||||
const asar = require("asar");
|
||||
|
||||
const versionDate = (date) => date.toISOString().replace(".000", "");
|
||||
const zero = (digit) => `0${digit}`.slice(-2);
|
||||
|
||||
const commitHash = () => {
|
||||
try {
|
||||
return require("child_process")
|
||||
.execSync("git rev-parse --short HEAD")
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return "none";
|
||||
}
|
||||
const versionDate = (date) => {
|
||||
const date_ = `${date.getFullYear()}-${zero(date.getMonth() + 1)}-${zero(
|
||||
date.getDate(),
|
||||
)}`;
|
||||
const time = `${zero(date.getHours())}-${zero(date.getMinutes())}-${zero(
|
||||
date.getSeconds(),
|
||||
)}`;
|
||||
return `${date_}-${time}`;
|
||||
};
|
||||
|
||||
const commitDate = (hash) => {
|
||||
try {
|
||||
const unix = require("child_process")
|
||||
.execSync(`git show -s --format=%ct ${hash}`)
|
||||
.toString()
|
||||
.trim();
|
||||
const date = new Date(parseInt(unix) * 1000);
|
||||
return versionDate(date);
|
||||
} catch {
|
||||
return versionDate(new Date());
|
||||
}
|
||||
};
|
||||
|
||||
const getFullVersion = () => {
|
||||
const hash = commitHash();
|
||||
return `${commitDate(hash)}-${hash}`;
|
||||
};
|
||||
const now = new Date();
|
||||
|
||||
const data = JSON.stringify(
|
||||
{
|
||||
version: getFullVersion(),
|
||||
asar: "excalidraw.asar",
|
||||
version: versionDate(now),
|
||||
},
|
||||
undefined,
|
||||
2,
|
||||
);
|
||||
|
||||
fs.writeFileSync(versionFile, data);
|
||||
fs.writeFileSync(path.join("build", "version.json"), data);
|
||||
|
||||
// https://stackoverflow.com/a/14181136/8418
|
||||
fs.readFile(indexFile, "utf8", (error, data) => {
|
||||
if (error) {
|
||||
return console.error(error);
|
||||
}
|
||||
const result = data.replace(/{version}/g, getFullVersion());
|
||||
(async () => {
|
||||
const src = "build/";
|
||||
const dest = path.join("build", `excalidraw.asar`);
|
||||
|
||||
fs.writeFile(indexFile, result, "utf8", (error) => {
|
||||
if (error) {
|
||||
return console.error(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
await asar.createPackage(src, dest);
|
||||
})();
|
||||
|
@@ -1,203 +0,0 @@
|
||||
const fs = require("fs");
|
||||
|
||||
const THRESSHOLD = 85;
|
||||
|
||||
const crowdinMap = {
|
||||
"ar-SA": "en-ar",
|
||||
"bg-BG": "en-bg",
|
||||
"bn-BD": "en-bn",
|
||||
"ca-ES": "en-ca",
|
||||
"da-DK": "en-da",
|
||||
"de-DE": "en-de",
|
||||
"el-GR": "en-el",
|
||||
"es-ES": "en-es",
|
||||
"eu-ES": "en-eu",
|
||||
"fa-IR": "en-fa",
|
||||
"fi-FI": "en-fi",
|
||||
"fr-FR": "en-fr",
|
||||
"he-IL": "en-he",
|
||||
"hi-IN": "en-hi",
|
||||
"hu-HU": "en-hu",
|
||||
"id-ID": "en-id",
|
||||
"it-IT": "en-it",
|
||||
"ja-JP": "en-ja",
|
||||
"kab-KAB": "en-kab",
|
||||
"ko-KR": "en-ko",
|
||||
"my-MM": "en-my",
|
||||
"nb-NO": "en-nb",
|
||||
"nl-NL": "en-nl",
|
||||
"nn-NO": "en-nnno",
|
||||
"oc-FR": "en-oc",
|
||||
"pa-IN": "en-pain",
|
||||
"pl-PL": "en-pl",
|
||||
"pt-BR": "en-ptbr",
|
||||
"pt-PT": "en-pt",
|
||||
"ro-RO": "en-ro",
|
||||
"ru-RU": "en-ru",
|
||||
"si-LK": "en-silk",
|
||||
"sk-SK": "en-sk",
|
||||
"sl-SI": "en-sl",
|
||||
"sv-SE": "en-sv",
|
||||
"ta-IN": "en-ta",
|
||||
"tr-TR": "en-tr",
|
||||
"uk-UA": "en-uk",
|
||||
"zh-CN": "en-zhcn",
|
||||
"zh-HK": "en-zhhk",
|
||||
"zh-TW": "en-zhtw",
|
||||
"lt-LT": "en-lt",
|
||||
"lv-LV": "en-lv",
|
||||
"cs-CZ": "en-cs",
|
||||
"kk-KZ": "en-kk",
|
||||
"vi-vn": "en-vi",
|
||||
"mr-in": "en-mr",
|
||||
};
|
||||
|
||||
const flags = {
|
||||
"ar-SA": "🇸🇦",
|
||||
"bg-BG": "🇧🇬",
|
||||
"bn-BD": "🇧🇩",
|
||||
"ca-ES": "🏳",
|
||||
"cs-CZ": "🇨🇿",
|
||||
"da-DK": "🇩🇰",
|
||||
"de-DE": "🇩🇪",
|
||||
"el-GR": "🇬🇷",
|
||||
"es-ES": "🇪🇸",
|
||||
"fa-IR": "🇮🇷",
|
||||
"fi-FI": "🇫🇮",
|
||||
"fr-FR": "🇫🇷",
|
||||
"he-IL": "🇮🇱",
|
||||
"hi-IN": "🇮🇳",
|
||||
"hu-HU": "🇭🇺",
|
||||
"id-ID": "🇮🇩",
|
||||
"it-IT": "🇮🇹",
|
||||
"ja-JP": "🇯🇵",
|
||||
"kab-KAB": "🏳",
|
||||
"kk-KZ": "🇰🇿",
|
||||
"ko-KR": "🇰🇷",
|
||||
"lt-LT": "🇱🇹",
|
||||
"lv-LV": "🇱🇻",
|
||||
"my-MM": "🇲🇲",
|
||||
"nb-NO": "🇳🇴",
|
||||
"nl-NL": "🇳🇱",
|
||||
"nn-NO": "🇳🇴",
|
||||
"oc-FR": "🏳",
|
||||
"pa-IN": "🇮🇳",
|
||||
"pl-PL": "🇵🇱",
|
||||
"pt-BR": "🇧🇷",
|
||||
"pt-PT": "🇵🇹",
|
||||
"ro-RO": "🇷🇴",
|
||||
"ru-RU": "🇷🇺",
|
||||
"si-LK": "🇱🇰",
|
||||
"sk-SK": "🇸🇰",
|
||||
"sl-SI": "🇸🇮",
|
||||
"sv-SE": "🇸🇪",
|
||||
"ta-IN": "🇮🇳",
|
||||
"tr-TR": "🇹🇷",
|
||||
"uk-UA": "🇺🇦",
|
||||
"zh-CN": "🇨🇳",
|
||||
"zh-HK": "🇭🇰",
|
||||
"zh-TW": "🇹🇼",
|
||||
"eu-ES": "🇪🇦",
|
||||
"vi-VN": "🇻🇳",
|
||||
"mr-IN": "🇮🇳",
|
||||
};
|
||||
|
||||
const languages = {
|
||||
"ar-SA": "العربية",
|
||||
"bg-BG": "Български",
|
||||
"bn-BD": "Bengali",
|
||||
"ca-ES": "Català",
|
||||
"cs-CZ": "Česky",
|
||||
"da-DK": "Dansk",
|
||||
"de-DE": "Deutsch",
|
||||
"el-GR": "Ελληνικά",
|
||||
"es-ES": "Español",
|
||||
"eu-ES": "Euskara",
|
||||
"fa-IR": "فارسی",
|
||||
"fi-FI": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"he-IL": "עברית",
|
||||
"hi-IN": "हिन्दी",
|
||||
"hu-HU": "Magyar",
|
||||
"id-ID": "Bahasa Indonesia",
|
||||
"it-IT": "Italiano",
|
||||
"ja-JP": "日本語",
|
||||
"kab-KAB": "Taqbaylit",
|
||||
"kk-KZ": "Қазақ тілі",
|
||||
"ko-KR": "한국어",
|
||||
"lt-LT": "Lietuvių",
|
||||
"lv-LV": "Latviešu",
|
||||
"my-MM": "Burmese",
|
||||
"nb-NO": "Norsk bokmål",
|
||||
"nl-NL": "Nederlands",
|
||||
"nn-NO": "Norsk nynorsk",
|
||||
"oc-FR": "Occitan",
|
||||
"pa-IN": "ਪੰਜਾਬੀ",
|
||||
"pl-PL": "Polski",
|
||||
"pt-BR": "Português Brasileiro",
|
||||
"pt-PT": "Português",
|
||||
"ro-RO": "Română",
|
||||
"ru-RU": "Русский",
|
||||
"si-LK": "සිංහල",
|
||||
"sk-SK": "Slovenčina",
|
||||
"sl-SI": "Slovenščina",
|
||||
"sv-SE": "Svenska",
|
||||
"ta-IN": "Tamil",
|
||||
"tr-TR": "Türkçe",
|
||||
"uk-UA": "Українська",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-HK": "繁體中文 (香港)",
|
||||
"zh-TW": "繁體中文",
|
||||
"vi-VN": "Tiếng Việt",
|
||||
"mr-IN": "मराठी",
|
||||
};
|
||||
|
||||
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,20 +0,0 @@
|
||||
const fs = require("fs");
|
||||
|
||||
// for development purposes we want to have the service-worker.js file
|
||||
// accessible from the public folder. On build though, we need to compile it
|
||||
// and CRA expects that file to be in src/ folder.
|
||||
const moveServiceWorkerScript = () => {
|
||||
const oldPath = "./public/service-worker.js";
|
||||
const newPath = "./src/service-worker.js";
|
||||
|
||||
fs.rename(oldPath, newPath, (error) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
console.info("public/service-worker.js moved to src/");
|
||||
});
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
moveServiceWorkerScript();
|
@@ -1,37 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const util = require("util");
|
||||
const exec = util.promisify(require("child_process").exec);
|
||||
const updateChangelog = require("./updateChangelog");
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
|
||||
const updatePackageVersion = (nextVersion) => {
|
||||
const pkg = require(excalidrawPackage);
|
||||
pkg.version = nextVersion;
|
||||
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
||||
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
||||
};
|
||||
|
||||
const prerelease = async (nextVersion) => {
|
||||
try {
|
||||
await updateChangelog(nextVersion);
|
||||
updatePackageVersion(nextVersion);
|
||||
await exec(`git add -u`);
|
||||
await exec(
|
||||
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
||||
);
|
||||
|
||||
console.info("Done!");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const nextVersion = process.argv.slice(2)[0];
|
||||
if (!nextVersion) {
|
||||
console.error("Pass the next version to release!");
|
||||
process.exit(1);
|
||||
}
|
||||
prerelease(nextVersion);
|
@@ -1,44 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const { execSync } = require("child_process");
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
const pkg = require(excalidrawPackage);
|
||||
|
||||
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8");
|
||||
|
||||
const updateReadme = () => {
|
||||
const excalidrawIndex = originalReadMe.indexOf("### Excalidraw");
|
||||
|
||||
// remove note for stable readme
|
||||
const data = originalReadMe.slice(excalidrawIndex);
|
||||
|
||||
// update readme
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
};
|
||||
|
||||
const publish = () => {
|
||||
try {
|
||||
execSync(`yarn --frozen-lockfile`);
|
||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const release = () => {
|
||||
updateReadme();
|
||||
console.info("Note for stable readme removed");
|
||||
|
||||
publish();
|
||||
console.info(`Published ${pkg.version}!`);
|
||||
|
||||
// revert readme after release
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
|
||||
console.info("Readme reverted");
|
||||
};
|
||||
|
||||
release();
|
@@ -1,104 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const util = require("util");
|
||||
const exec = util.promisify(require("child_process").exec);
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
const pkg = require(excalidrawPackage);
|
||||
const lastVersion = pkg.version;
|
||||
const existingChangeLog = fs.readFileSync(
|
||||
`${excalidrawDir}/CHANGELOG.md`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
|
||||
const headerForType = {
|
||||
feat: "Features",
|
||||
fix: "Fixes",
|
||||
style: "Styles",
|
||||
refactor: " Refactor",
|
||||
perf: "Performance",
|
||||
build: "Build",
|
||||
};
|
||||
const badCommits = [];
|
||||
const getCommitHashForLastVersion = async () => {
|
||||
try {
|
||||
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
|
||||
const { stdout } = await exec(
|
||||
`git log --format=format:"%H" --grep=${commitMessage}`,
|
||||
);
|
||||
return stdout;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const getLibraryCommitsSinceLastRelease = async () => {
|
||||
const commitHash = await getCommitHashForLastVersion();
|
||||
const { stdout } = await exec(
|
||||
`git log --pretty=format:%s ${commitHash}...master`,
|
||||
);
|
||||
const commitsSinceLastRelease = stdout.split("\n");
|
||||
const commitList = {};
|
||||
supportedTypes.forEach((type) => {
|
||||
commitList[type] = [];
|
||||
});
|
||||
|
||||
commitsSinceLastRelease.forEach((commit) => {
|
||||
const indexOfColon = commit.indexOf(":");
|
||||
const type = commit.slice(0, indexOfColon);
|
||||
if (!supportedTypes.includes(type)) {
|
||||
return;
|
||||
}
|
||||
const messageWithoutType = commit.slice(indexOfColon + 1).trim();
|
||||
const messageWithCapitalizeFirst =
|
||||
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
|
||||
const prMatch = commit.match(/\(#([0-9]*)\)/);
|
||||
if (prMatch) {
|
||||
const prNumber = prMatch[1];
|
||||
|
||||
// return if the changelog already contains the pr number which would happen for package updates
|
||||
if (existingChangeLog.includes(prNumber)) {
|
||||
return;
|
||||
}
|
||||
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
|
||||
const messageWithPRLink = messageWithCapitalizeFirst.replace(
|
||||
/\(#[0-9]*\)/,
|
||||
prMarkdown,
|
||||
);
|
||||
commitList[type].push(messageWithPRLink);
|
||||
} else {
|
||||
badCommits.push(commit);
|
||||
commitList[type].push(messageWithCapitalizeFirst);
|
||||
}
|
||||
});
|
||||
console.info("Bad commits:", badCommits);
|
||||
return commitList;
|
||||
};
|
||||
|
||||
const updateChangelog = async (nextVersion) => {
|
||||
const commitList = await getLibraryCommitsSinceLastRelease();
|
||||
let changelogForLibrary =
|
||||
"## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
|
||||
supportedTypes.forEach((type) => {
|
||||
if (commitList[type].length) {
|
||||
changelogForLibrary += `### ${headerForType[type]}\n\n`;
|
||||
const commits = commitList[type];
|
||||
commits.forEach((commit) => {
|
||||
changelogForLibrary += `- ${commit}\n\n`;
|
||||
});
|
||||
}
|
||||
});
|
||||
changelogForLibrary += "---\n";
|
||||
const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
|
||||
let updatedContent =
|
||||
existingChangeLog.slice(0, lastVersionIndex) +
|
||||
changelogForLibrary +
|
||||
existingChangeLog.slice(lastVersionIndex);
|
||||
const currentDate = new Date().toISOString().slice(0, 10);
|
||||
const newVersion = `## ${nextVersion} (${currentDate})`;
|
||||
updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
|
||||
fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
|
||||
};
|
||||
|
||||
module.exports = updateChangelog;
|
@@ -2,59 +2,23 @@ import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { randomId } from "../random";
|
||||
import { t } from "../i18n";
|
||||
import { Library } from "../data/library";
|
||||
import { EVENT_LIBRARY, trackEvent } from "../analytics";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
if (selectedElements.some((element) => element.type === "image")) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: "Support for adding images to the library coming soon!",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return app.library
|
||||
.getLatestLibrary()
|
||||
.then((items) => {
|
||||
return app.library.setLibrary([
|
||||
{
|
||||
id: randomId(),
|
||||
status: "unpublished",
|
||||
elements: selectedElements.map(deepCopyElement),
|
||||
created: Date.now(),
|
||||
},
|
||||
...items,
|
||||
]);
|
||||
})
|
||||
.then(() => {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
toast: { message: t("toast.addedToLibrary") },
|
||||
},
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
};
|
||||
});
|
||||
Library.loadLibrary().then((items) => {
|
||||
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
||||
});
|
||||
trackEvent(EVENT_LIBRARY, "add");
|
||||
return false;
|
||||
},
|
||||
contextMenuOrder: 6,
|
||||
contextItemLabel: "labels.addToLibrary",
|
||||
});
|
||||
|
@@ -1,4 +1,7 @@
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import React from "react";
|
||||
import { KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
AlignLeftIcon,
|
||||
@@ -7,15 +10,14 @@ import {
|
||||
CenterHorizontallyIcon,
|
||||
CenterVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { trackEvent, EVENT_ALIGN } from "../analytics";
|
||||
|
||||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@@ -34,17 +36,15 @@ const alignSelectedElements = (
|
||||
|
||||
const updatedElements = alignElements(selectedElements, alignment);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
const updatedElementsMap = getElementMap(updatedElements);
|
||||
|
||||
return elements.map(
|
||||
(element) => updatedElementsMap.get(element.id) || element,
|
||||
);
|
||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
||||
};
|
||||
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "top");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@@ -60,7 +60,7 @@ export const actionAlignTop = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<AlignTopIcon theme={appState.theme} />}
|
||||
icon={<AlignTopIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignTop")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Up",
|
||||
@@ -73,8 +73,8 @@ export const actionAlignTop = register({
|
||||
|
||||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "bottom");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@@ -90,7 +90,7 @@ export const actionAlignBottom = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<AlignBottomIcon theme={appState.theme} />}
|
||||
icon={<AlignBottomIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignBottom")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Down",
|
||||
@@ -103,8 +103,8 @@ export const actionAlignBottom = register({
|
||||
|
||||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "left");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@@ -120,7 +120,7 @@ export const actionAlignLeft = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<AlignLeftIcon theme={appState.theme} />}
|
||||
icon={<AlignLeftIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignLeft")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Left",
|
||||
@@ -133,9 +133,8 @@ export const actionAlignLeft = register({
|
||||
|
||||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "right");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@@ -151,7 +150,7 @@ export const actionAlignRight = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<AlignRightIcon theme={appState.theme} />}
|
||||
icon={<AlignRightIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignRight")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Right",
|
||||
@@ -164,9 +163,8 @@ export const actionAlignRight = register({
|
||||
|
||||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "vertically", "center");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@@ -180,7 +178,7 @@ export const actionAlignVerticallyCentered = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<CenterVerticallyIcon theme={appState.theme} />}
|
||||
icon={<CenterVerticallyIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={t("labels.centerVertically")}
|
||||
aria-label={t("labels.centerVertically")}
|
||||
@@ -191,8 +189,8 @@ export const actionAlignVerticallyCentered = register({
|
||||
|
||||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "horizontally", "center");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@@ -206,7 +204,7 @@ export const actionAlignHorizontallyCentered = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<CenterHorizontallyIcon theme={appState.theme} />}
|
||||
icon={<CenterHorizontallyIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={t("labels.centerHorizontally")}
|
||||
aria-label={t("labels.centerHorizontally")}
|
||||
|
@@ -1,136 +0,0 @@
|
||||
import { VERTICAL_ALIGN } from "../constants";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
measureText,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isTextBindableContainer,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
trackEvent: { category: "element" },
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return selectedElements.some((element) => hasBoundTextElement(element));
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const { width, height, baseline } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
);
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
text: boundTextElement.originalText,
|
||||
});
|
||||
mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionBindText = register({
|
||||
name: "bindText",
|
||||
contextItemLabel: "labels.bindText",
|
||||
trackEvent: { category: "element" },
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
if (selectedElements.length === 2) {
|
||||
const textElement =
|
||||
isTextElement(selectedElements[0]) ||
|
||||
isTextElement(selectedElements[1]);
|
||||
|
||||
let bindingContainer;
|
||||
if (isTextBindableContainer(selectedElements[0])) {
|
||||
bindingContainer = selectedElements[0];
|
||||
} else if (isTextBindableContainer(selectedElements[1])) {
|
||||
bindingContainer = selectedElements[1];
|
||||
}
|
||||
if (
|
||||
textElement &&
|
||||
bindingContainer &&
|
||||
getBoundTextElement(bindingContainer) === null
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
let textElement: ExcalidrawTextElement;
|
||||
let container: ExcalidrawTextContainer;
|
||||
|
||||
if (
|
||||
isTextElement(selectedElements[0]) &&
|
||||
isTextBindableContainer(selectedElements[1])
|
||||
) {
|
||||
textElement = selectedElements[0];
|
||||
container = selectedElements[1];
|
||||
} else {
|
||||
textElement = selectedElements[1] as ExcalidrawTextElement;
|
||||
container = selectedElements[0] as ExcalidrawTextContainer;
|
||||
}
|
||||
mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
});
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
const updatedElements = elements.slice();
|
||||
const textElementIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === textElement.id,
|
||||
);
|
||||
updatedElements.splice(textElementIndex, 1);
|
||||
const containerIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === container.id,
|
||||
);
|
||||
updatedElements.splice(containerIndex + 1, 0, textElement);
|
||||
return {
|
||||
elements: updatedElements,
|
||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
@@ -1,48 +1,47 @@
|
||||
import React from "react";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { eraser, zoomIn, zoomOut } from "../components/icons";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { THEME, ZOOM_STEP } from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getDefaultAppState, isEraserActive } from "../appState";
|
||||
import ClearCanvas from "../components/ClearCanvas";
|
||||
import clsx from "clsx";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getCommonBounds } from "../element";
|
||||
import { getNewZoom } from "../scene/zoom";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics";
|
||||
import colors from "../colors";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
trackEvent: false,
|
||||
perform: (_, appState, value) => {
|
||||
if (value !== appState.viewBackgroundColor) {
|
||||
trackEvent(
|
||||
EVENT_CHANGE,
|
||||
"canvas color",
|
||||
colors.canvasBackground.includes(value)
|
||||
? `${value} (picker ${colors.canvasBackground.indexOf(value)})`
|
||||
: value,
|
||||
);
|
||||
}
|
||||
return {
|
||||
appState: { ...appState, ...value },
|
||||
commitToHistory: !!value.viewBackgroundColor,
|
||||
appState: { ...appState, viewBackgroundColor: value },
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
PanelComponent: ({ appState, updateData }) => {
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<ColorPicker
|
||||
label={t("labels.canvasBackground")}
|
||||
type="canvasBackground"
|
||||
color={appState.viewBackgroundColor}
|
||||
onChange={(color) => updateData({ viewBackgroundColor: color })}
|
||||
isActive={appState.openPopup === "canvasColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "canvasColorPicker" : null })
|
||||
}
|
||||
data-testid="canvas-background-picker"
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
onChange={(color) => updateData(color)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -51,51 +50,56 @@ export const actionChangeViewBackgroundColor = register({
|
||||
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.imageCache.clear();
|
||||
perform: (elements, appState: AppState) => {
|
||||
trackEvent(EVENT_ACTION, "clear canvas");
|
||||
return {
|
||||
elements: elements.map((element) =>
|
||||
newElementWith(element, { isDeleted: true }),
|
||||
),
|
||||
appState: {
|
||||
...getDefaultAppState(),
|
||||
files: {},
|
||||
theme: appState.theme,
|
||||
penMode: appState.penMode,
|
||||
penDetected: appState.penDetected,
|
||||
appearance: appState.appearance,
|
||||
elementLocked: appState.elementLocked,
|
||||
exportBackground: appState.exportBackground,
|
||||
exportEmbedScene: appState.exportEmbedScene,
|
||||
gridSize: appState.gridSize,
|
||||
shouldAddWatermark: appState.shouldAddWatermark,
|
||||
showStats: appState.showStats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? { ...appState.activeTool, type: "selection" }
|
||||
: appState.activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
||||
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={trash}
|
||||
title={t("buttons.clearReset")}
|
||||
aria-label={t("buttons.clearReset")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("alerts.clearReset"))) {
|
||||
updateData(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
const ZOOM_STEP = 0.1;
|
||||
|
||||
export const actionZoomIn = register({
|
||||
name: "zoomIn",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
perform: (_elements, appState) => {
|
||||
const zoom = getNewZoom(
|
||||
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
||||
appState.zoom,
|
||||
{ x: appState.width / 2, y: appState.height / 2 },
|
||||
);
|
||||
trackEvent(EVENT_ACTION, "zoom", "in", zoom.value * 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...getStateForZoom(
|
||||
{
|
||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||
viewportY: appState.height / 2 + appState.offsetTop,
|
||||
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
||||
},
|
||||
appState,
|
||||
),
|
||||
zoom,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
@@ -109,7 +113,6 @@ export const actionZoomIn = register({
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@@ -119,19 +122,18 @@ export const actionZoomIn = register({
|
||||
|
||||
export const actionZoomOut = register({
|
||||
name: "zoomOut",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
perform: (_elements, appState) => {
|
||||
const zoom = getNewZoom(
|
||||
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
||||
appState.zoom,
|
||||
{ x: appState.width / 2, y: appState.height / 2 },
|
||||
);
|
||||
|
||||
trackEvent(EVENT_ACTION, "zoom", "out", zoom.value * 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...getStateForZoom(
|
||||
{
|
||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||
viewportY: appState.height / 2 + appState.offsetTop,
|
||||
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
||||
},
|
||||
appState,
|
||||
),
|
||||
zoom,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
@@ -145,7 +147,6 @@ export const actionZoomOut = register({
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@@ -155,38 +156,29 @@ export const actionZoomOut = register({
|
||||
|
||||
export const actionResetZoom = register({
|
||||
name: "resetZoom",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
perform: (_elements, appState) => {
|
||||
trackEvent(EVENT_ACTION, "zoom", "reset", 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...getStateForZoom(
|
||||
{
|
||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||
viewportY: appState.height / 2 + appState.offsetTop,
|
||||
nextZoom: getNormalizedZoom(1),
|
||||
},
|
||||
appState,
|
||||
),
|
||||
zoom: getNewZoom(1 as NormalizedZoomValue, appState.zoom, {
|
||||
x: appState.width / 2,
|
||||
y: appState.height / 2,
|
||||
}),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
className="reset-zoom-button"
|
||||
title={t("buttons.resetZoom")}
|
||||
aria-label={t("buttons.resetZoom")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{(appState.zoom.value * 100).toFixed(0)}%
|
||||
</ToolButton>
|
||||
</Tooltip>
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={resetZoom}
|
||||
title={t("buttons.resetZoom")}
|
||||
aria-label={t("buttons.resetZoom")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||
@@ -212,136 +204,41 @@ const zoomValueToFitBoundsOnViewport = (
|
||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||
};
|
||||
|
||||
const zoomToFitElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
zoomToSelection: boolean,
|
||||
) => {
|
||||
const nonDeletedElements = getNonDeletedElements(elements);
|
||||
const selectedElements = getSelectedElements(nonDeletedElements, appState);
|
||||
|
||||
const commonBounds =
|
||||
zoomToSelection && selectedElements.length > 0
|
||||
? getCommonBounds(selectedElements)
|
||||
: getCommonBounds(nonDeletedElements);
|
||||
|
||||
const newZoom = {
|
||||
value: zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
}),
|
||||
};
|
||||
|
||||
const [x1, y1, x2, y2] = commonBounds;
|
||||
const centerX = (x1 + x2) / 2;
|
||||
const centerY = (y1 + y2) / 2;
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
},
|
||||
zoom: newZoom,
|
||||
}),
|
||||
zoom: newZoom,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const actionZoomToSelected = register({
|
||||
name: "zoomToSelection",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.TWO &&
|
||||
event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionZoomToFit = register({
|
||||
name: "zoomToFit",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
||||
perform: (elements, appState) => {
|
||||
const nonDeletedElements = elements.filter((element) => !element.isDeleted);
|
||||
const commonBounds = getCommonBounds(nonDeletedElements);
|
||||
|
||||
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
});
|
||||
const newZoom = getNewZoom(zoomValue, appState.zoom);
|
||||
|
||||
const [x1, y1, x2, y2] = commonBounds;
|
||||
const centerX = (x1 + x2) / 2;
|
||||
const centerY = (y1 + y2) / 2;
|
||||
trackEvent(EVENT_ACTION, "zoom", "fit", newZoom.value * 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
},
|
||||
zoom: newZoom,
|
||||
}),
|
||||
zoom: newZoom,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.ONE &&
|
||||
event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionToggleTheme = register({
|
||||
name: "toggleTheme",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
theme:
|
||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
||||
<DarkModeToggle
|
||||
value={appState.theme}
|
||||
onChange={(theme) => {
|
||||
updateData(theme);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
});
|
||||
|
||||
export const actionErase = register({
|
||||
name: "eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "eraser",
|
||||
lastActiveToolBeforeEraser: appState.activeTool,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.E,
|
||||
PanelComponent: ({ elements, appState, updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={eraser}
|
||||
className={clsx("eraser", { active: isEraserActive(appState) })}
|
||||
title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
|
||||
aria-label={t("toolBar.eraser")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size={data?.size || "medium"}
|
||||
></ToolButton>
|
||||
),
|
||||
});
|
||||
|
@@ -1,168 +0,0 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
probablySupportsClipboardWriteText,
|
||||
} from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { getSelectedElements } from "../scene/selection";
|
||||
import { exportCanvas } from "../data/index";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
|
||||
copyToClipboard(selectedElements, appState, app.files);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copy",
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
keyTest: undefined,
|
||||
});
|
||||
|
||||
export const actionCut = register({
|
||||
name: "cut",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, data, app) => {
|
||||
actionCopy.perform(elements, appState, data, app);
|
||||
return actionDeleteSelected.perform(elements, appState);
|
||||
},
|
||||
contextItemLabel: "labels.cut",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
|
||||
});
|
||||
|
||||
export const actionCopyAsSvg = register({
|
||||
name: "copyAsSvg",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
try {
|
||||
await exportCanvas(
|
||||
"clipboard-svg",
|
||||
selectedElements.length
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.files,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
contextItemLabel: "labels.copyAsSvg",
|
||||
});
|
||||
|
||||
export const actionCopyAsPng = register({
|
||||
name: "copyAsPng",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
try {
|
||||
await exportCanvas(
|
||||
"clipboard",
|
||||
selectedElements.length
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.files,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
toast: {
|
||||
message: t("toast.copyToClipboardAsPng", {
|
||||
exportSelection: selectedElements.length
|
||||
? t("toast.selection")
|
||||
: t("toast.canvas"),
|
||||
exportColorScheme: appState.exportWithDarkMode
|
||||
? t("buttons.darkMode")
|
||||
: t("buttons.lightMode"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
contextItemLabel: "labels.copyAsPng",
|
||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||
});
|
||||
|
||||
export const copyText = register({
|
||||
name: "copyText",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
|
||||
const text = selectedElements
|
||||
.reduce((acc: string[], element) => {
|
||||
if (isTextElement(element)) {
|
||||
acc.push(element.text);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.join("\n\n");
|
||||
copyTextToSystemClipboard(text);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
return (
|
||||
probablySupportsClipboardWriteText &&
|
||||
getSelectedElements(elements, appState, true).some(isTextElement)
|
||||
);
|
||||
},
|
||||
contextItemLabel: "labels.copyText",
|
||||
});
|
@@ -1,6 +1,7 @@
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { KEYS } from "../keys";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import React from "react";
|
||||
import { trash } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
@@ -11,8 +12,6 @@ import { newElementWith } from "../element/mutateElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import { updateActiveTool } from "../utils";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@@ -23,12 +22,6 @@ const deleteSelectedElements = (
|
||||
if (appState.selectedElementIds[el.id]) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
if (
|
||||
isBoundToContainer(el) &&
|
||||
appState.selectedElementIds[el.containerId]
|
||||
) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
}),
|
||||
appState: {
|
||||
@@ -59,12 +52,11 @@ const handleGroupEditingState = (
|
||||
|
||||
export const actionDeleteSelected = register({
|
||||
name: "deleteSelectedElements",
|
||||
trackEvent: { category: "element", action: "delete" },
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
elementId,
|
||||
selectedPointsIndices,
|
||||
activePointIndex,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
@@ -74,7 +66,8 @@ export const actionDeleteSelected = register({
|
||||
}
|
||||
if (
|
||||
// case: no point selected → delete whole element
|
||||
selectedPointsIndices == null ||
|
||||
activePointIndex == null ||
|
||||
activePointIndex === -1 ||
|
||||
// case: deleting last remaining point
|
||||
element.points.length < 2
|
||||
) {
|
||||
@@ -94,17 +87,15 @@ export const actionDeleteSelected = register({
|
||||
// We cannot do this inside `movePoint` because it is also called
|
||||
// when deleting the uncommitted point (which hasn't caused any binding)
|
||||
const binding = {
|
||||
startBindingElement: selectedPointsIndices?.includes(0)
|
||||
? null
|
||||
: startBindingElement,
|
||||
endBindingElement: selectedPointsIndices?.includes(
|
||||
element.points.length - 1,
|
||||
)
|
||||
? null
|
||||
: endBindingElement,
|
||||
startBindingElement:
|
||||
activePointIndex === 0 ? null : startBindingElement,
|
||||
endBindingElement:
|
||||
activePointIndex === element.points.length - 1
|
||||
? null
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||
LinearElementEditor.movePoint(element, activePointIndex, "delete");
|
||||
|
||||
return {
|
||||
elements,
|
||||
@@ -113,17 +104,17 @@ export const actionDeleteSelected = register({
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
...binding,
|
||||
selectedPointsIndices:
|
||||
selectedPointsIndices?.[0] > 0
|
||||
? [selectedPointsIndices[0] - 1]
|
||||
: [0],
|
||||
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
}
|
||||
let { elements: nextElements, appState: nextAppState } =
|
||||
deleteSelectedElements(elements, appState);
|
||||
|
||||
let {
|
||||
elements: nextElements,
|
||||
appState: nextAppState,
|
||||
} = deleteSelectedElements(elements, appState);
|
||||
fixBindingsAfterDeletion(
|
||||
nextElements,
|
||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||
@@ -135,7 +126,7 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||
elementType: "selection",
|
||||
multiElement: null,
|
||||
},
|
||||
commitToHistory: isSomeElementSelected(
|
||||
@@ -145,6 +136,7 @@ export const actionDeleteSelected = register({
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.delete",
|
||||
contextMenuOrder: 3,
|
||||
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
|
@@ -1,17 +1,19 @@
|
||||
import React from "react";
|
||||
import { CODES } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import {
|
||||
DistributeHorizontallyIcon,
|
||||
DistributeVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { distributeElements, Distribution } from "../distribute";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { distributeElements, Distribution } from "../disitrubte";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { EVENT_ALIGN, trackEvent } from "../analytics";
|
||||
|
||||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@@ -30,17 +32,15 @@ const distributeSelectedElements = (
|
||||
|
||||
const updatedElements = distributeElements(selectedElements, distribution);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
const updatedElementsMap = getElementMap(updatedElements);
|
||||
|
||||
return elements.map(
|
||||
(element) => updatedElementsMap.get(element.id) || element,
|
||||
);
|
||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
||||
};
|
||||
|
||||
export const distributeHorizontally = register({
|
||||
name: "distributeHorizontally",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "distribute", "horizontally");
|
||||
return {
|
||||
appState,
|
||||
elements: distributeSelectedElements(elements, appState, {
|
||||
@@ -50,13 +50,12 @@ export const distributeHorizontally = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
|
||||
keyTest: (event) => event.altKey && event.code === CODES.H,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<DistributeHorizontallyIcon theme={appState.theme} />}
|
||||
icon={<DistributeHorizontallyIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.distributeHorizontally")} — ${getShortcutKey(
|
||||
"Alt+H",
|
||||
@@ -69,8 +68,8 @@ export const distributeHorizontally = register({
|
||||
|
||||
export const distributeVertically = register({
|
||||
name: "distributeVertically",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "distribute", "vertically");
|
||||
return {
|
||||
appState,
|
||||
elements: distributeSelectedElements(elements, appState, {
|
||||
@@ -80,13 +79,12 @@ export const distributeVertically = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||
keyTest: (event) => event.altKey && event.code === CODES.V,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<DistributeVerticallyIcon theme={appState.theme} />}
|
||||
icon={<DistributeVerticallyIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.distributeVertically")} — ${getShortcutKey("Alt+V")}`}
|
||||
aria-label={t("labels.distributeVertically")}
|
||||
|
@@ -1,13 +1,15 @@
|
||||
import React from "react";
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { clone } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
selectGroupsForSelectedElements,
|
||||
getSelectedGroupForElement,
|
||||
@@ -17,24 +19,41 @@ import { AppState } from "../types";
|
||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||
import { ActionResult } from "./types";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import { bindTextToShapeAfterDuplication } from "../element/textElement";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
// duplicate selected point(s) if editing a line
|
||||
// duplicate point if selected while editing multi-point element
|
||||
if (appState.editingLinearElement) {
|
||||
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
|
||||
|
||||
if (!ret) {
|
||||
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,
|
||||
appState: ret.appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
}
|
||||
@@ -88,12 +107,9 @@ const duplicateElements = (
|
||||
const finalElements: ExcalidrawElement[] = [];
|
||||
|
||||
let index = 0;
|
||||
const selectedElementIds = arrayToMap(
|
||||
getSelectedElements(elements, appState, true),
|
||||
);
|
||||
while (index < elements.length) {
|
||||
const element = elements[index];
|
||||
if (selectedElementIds.get(element.id)) {
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
if (element.groupIds.length) {
|
||||
const groupId = getSelectedGroupForElement(appState, element);
|
||||
// if group selected, duplicate it atomically
|
||||
@@ -115,11 +131,7 @@ const duplicateElements = (
|
||||
}
|
||||
index++;
|
||||
}
|
||||
bindTextToShapeAfterDuplication(
|
||||
finalElements,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
|
||||
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
||||
|
||||
return {
|
||||
@@ -128,15 +140,10 @@ const duplicateElements = (
|
||||
{
|
||||
...appState,
|
||||
selectedGroupIds: {},
|
||||
selectedElementIds: newElements.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
selectedElementIds: newElements.reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {} as any),
|
||||
},
|
||||
getNonDeletedElements(finalElements),
|
||||
),
|
||||
|
@@ -1,95 +1,32 @@
|
||||
import { load, questionCircle, saveAs } from "../components/icons";
|
||||
import React from "react";
|
||||
import { EVENT_CHANGE, EVENT_IO, trackEvent } from "../analytics";
|
||||
import { load, save, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import "../components/ToolIcon.scss";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { resaveAsImageWithScene } from "../data/resave";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { KEYS } from "../keys";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
import { getExportSize } from "../scene/export";
|
||||
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ActiveFile } from "../components/ActiveFile";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { Theme } from "../element/types";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
trackEvent: false,
|
||||
perform: (_elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "title");
|
||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, appProps }) => (
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<ProjectName
|
||||
label={t("labels.fileTitle")}
|
||||
value={appState.name || "Unnamed"}
|
||||
onChange={(name: string) => updateData(name)}
|
||||
isNameEditable={
|
||||
typeof appProps.name === "undefined" && !appState.viewModeEnabled
|
||||
}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeExportScale = register({
|
||||
name: "changeExportScale",
|
||||
trackEvent: { category: "export", action: "scale" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportScale: value },
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
||||
const elements = getNonDeletedElements(allElements);
|
||||
const exportSelected = isSomeElementSelected(elements, appState);
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState)
|
||||
: elements;
|
||||
|
||||
return (
|
||||
<>
|
||||
{EXPORT_SCALES.map((s) => {
|
||||
const [width, height] = getExportSize(
|
||||
exportedElements,
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
s,
|
||||
);
|
||||
|
||||
const scaleButtonTitle = `${t(
|
||||
"buttons.scale",
|
||||
)} ${s}x (${width}x${height})`;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={s}
|
||||
size="small"
|
||||
type="radio"
|
||||
icon={`${s}x`}
|
||||
name="export-canvas-scale"
|
||||
title={scaleButtonTitle}
|
||||
aria-label={scaleButtonTitle}
|
||||
id="export-canvas-scale"
|
||||
checked={s === appState.exportScale}
|
||||
onChange={() => updateData(s)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeExportBackground = register({
|
||||
name: "changeExportBackground",
|
||||
trackEvent: { category: "export", action: "toggleBackground" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportBackground: value },
|
||||
@@ -97,18 +34,19 @@ export const actionChangeExportBackground = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<CheckboxItem
|
||||
checked={appState.exportBackground}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.exportBackground}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
{t("labels.withBackground")}
|
||||
</CheckboxItem>
|
||||
</label>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeExportEmbedScene = register({
|
||||
name: "changeExportEmbedScene",
|
||||
trackEvent: { category: "export", action: "embedScene" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportEmbedScene: value },
|
||||
@@ -116,84 +54,78 @@ export const actionChangeExportEmbedScene = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<CheckboxItem
|
||||
checked={appState.exportEmbedScene}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
<label title={t("labels.exportEmbedScene_details")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.exportEmbedScene}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
{t("labels.exportEmbedScene")}
|
||||
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
||||
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
|
||||
</Tooltip>
|
||||
</CheckboxItem>
|
||||
</label>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
export const actionChangeShouldAddWatermark = register({
|
||||
name: "changeShouldAddWatermark",
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, shouldAddWatermark: value },
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.shouldAddWatermark}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
{t("labels.addWatermark")}
|
||||
</label>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSaveScene = register({
|
||||
name: "saveScene",
|
||||
perform: async (elements, appState, value) => {
|
||||
try {
|
||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||
? await resaveAsImageWithScene(elements, appState, app.files)
|
||||
: await saveAsJSON(elements, appState, app.files);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
fileHandle,
|
||||
toast: fileHandleExists
|
||||
? {
|
||||
message: fileHandle?.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
)
|
||||
: t("toast.fileSaved"),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||
trackEvent(EVENT_IO, "save");
|
||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { commitToHistory: false };
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ActiveFile
|
||||
onSave={() => updateData(null)}
|
||||
fileName={appState.fileHandle?.name}
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={save}
|
||||
title={t("buttons.save")}
|
||||
aria-label={t("buttons.save")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={() => updateData(null)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
export const actionSaveAsScene = register({
|
||||
name: "saveAsScene",
|
||||
perform: async (elements, appState, value) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(
|
||||
elements,
|
||||
{
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
},
|
||||
app.files,
|
||||
);
|
||||
const { fileHandle } = await saveAsJSON(elements, {
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
});
|
||||
trackEvent(EVENT_IO, "save as");
|
||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { commitToHistory: false };
|
||||
}
|
||||
@@ -206,82 +138,46 @@ export const actionSaveFileToDisk = register({
|
||||
icon={saveAs}
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
hidden={!nativeFileSystemSupported}
|
||||
showAriaLabel={useIsMobile()}
|
||||
hidden={
|
||||
!("chooseFileSystemEntries" in window || "showOpenFilePicker" in window)
|
||||
}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-as-button"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, _, app) => {
|
||||
try {
|
||||
const {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
files,
|
||||
} = await loadFromJSON(appState, elements);
|
||||
return {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
files,
|
||||
commitToHistory: true,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
console.warn(error);
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
elements,
|
||||
appState: { ...appState, errorMessage: error.message },
|
||||
files: app.files,
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
||||
perform: (
|
||||
elements,
|
||||
appState,
|
||||
{ elements: loadedElements, appState: loadedAppState, error },
|
||||
) => ({
|
||||
elements: loadedElements,
|
||||
appState: {
|
||||
...loadedAppState,
|
||||
errorMessage: error,
|
||||
},
|
||||
commitToHistory: true,
|
||||
}),
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={load}
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
onClick={updateData}
|
||||
data-testid="load-button"
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={() => {
|
||||
loadFromJSON(appState)
|
||||
.then(({ elements, appState }) => {
|
||||
updateData({ elements, appState });
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
updateData({ error: error.message });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionExportWithDarkMode = register({
|
||||
name: "exportWithDarkMode",
|
||||
trackEvent: { category: "export", action: "toggleTheme" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportWithDarkMode: value },
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: "-45px",
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
<DarkModeToggle
|
||||
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
|
||||
onChange={(theme: Theme) => {
|
||||
updateData(theme === THEME.DARK);
|
||||
}}
|
||||
title={t("labels.toggleExportColorScheme")}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { isInvisiblySmallElement } from "../element";
|
||||
import { updateActiveTool, resetCursor } from "../utils";
|
||||
import { resetCursor } from "../utils";
|
||||
import React from "react";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { done } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
@@ -14,15 +15,16 @@ import {
|
||||
bindOrUnbindLinearElement,
|
||||
} from "../element/binding";
|
||||
import { isBindingElement } from "../element/typeChecks";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
const {
|
||||
elementId,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
|
||||
if (element) {
|
||||
@@ -40,7 +42,6 @@ export const actionFinalize = register({
|
||||
: undefined,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
@@ -49,29 +50,20 @@ export const actionFinalize = register({
|
||||
}
|
||||
|
||||
let newElements = elements;
|
||||
|
||||
const pendingImageElement =
|
||||
appState.pendingImageElementId &&
|
||||
scene.getElement(appState.pendingImageElementId);
|
||||
|
||||
if (pendingImageElement) {
|
||||
mutateElement(pendingImageElement, { isDeleted: true }, false);
|
||||
}
|
||||
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
focusContainer();
|
||||
window.document.activeElement.blur();
|
||||
}
|
||||
|
||||
const multiPointElement = appState.multiElement
|
||||
? appState.multiElement
|
||||
: appState.editingElement?.type === "freedraw"
|
||||
: appState.editingElement?.type === "draw"
|
||||
? appState.editingElement
|
||||
: null;
|
||||
|
||||
if (multiPointElement) {
|
||||
// pen and mouse have hover
|
||||
if (
|
||||
multiPointElement.type !== "freedraw" &&
|
||||
multiPointElement.type !== "draw" &&
|
||||
appState.lastPointerDownWith !== "touch"
|
||||
) {
|
||||
const { points, lastCommittedPoint } = multiPointElement;
|
||||
@@ -91,10 +83,10 @@ export const actionFinalize = register({
|
||||
// If the multi point line closes the loop,
|
||||
// set the last point to first point.
|
||||
// This ensures that loop remains closed at different scales.
|
||||
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
||||
const isLoop = isPathALoop(multiPointElement.points);
|
||||
if (
|
||||
multiPointElement.type === "line" ||
|
||||
multiPointElement.type === "freedraw"
|
||||
multiPointElement.type === "draw"
|
||||
) {
|
||||
if (isLoop) {
|
||||
const linePoints = multiPointElement.points;
|
||||
@@ -126,64 +118,35 @@ export const actionFinalize = register({
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
) {
|
||||
if (!appState.elementLocked) {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw") ||
|
||||
!multiPointElement
|
||||
) {
|
||||
resetCursor(canvas);
|
||||
if (!appState.elementLocked || !multiPointElement) {
|
||||
resetCursor();
|
||||
}
|
||||
|
||||
let activeTool: AppState["activeTool"];
|
||||
if (appState.activeTool.type === "eraser") {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "selection",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
elements: newElements,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
activeTool:
|
||||
(appState.activeTool.locked ||
|
||||
appState.activeTool.type === "freedraw") &&
|
||||
multiPointElement
|
||||
? appState.activeTool
|
||||
: activeTool,
|
||||
elementType:
|
||||
appState.elementLocked && multiPointElement
|
||||
? appState.elementType
|
||||
: "selection",
|
||||
draggingElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
selectedElementIds:
|
||||
multiPointElement &&
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
multiPointElement && !appState.elementLocked
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[multiPointElement.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
pendingImageElementId: null,
|
||||
},
|
||||
commitToHistory: appState.activeTool.type === "freedraw",
|
||||
commitToHistory: appState.elementType === "draw",
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
@@ -192,7 +155,7 @@ export const actionFinalize = register({
|
||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
PanelComponent: ({ appState, updateData, data }) => (
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={done}
|
||||
@@ -200,7 +163,6 @@ export const actionFinalize = register({
|
||||
aria-label={t("buttons.done")}
|
||||
onClick={updateData}
|
||||
visible={appState.multiElement != null}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
@@ -1,211 +0,0 @@
|
||||
import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
||||
import { AppState } from "../types";
|
||||
import { getTransformHandles } from "../element/transformHandles";
|
||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
||||
import { updateBoundElements } from "../element/binding";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { arrayToMap } from "../utils";
|
||||
|
||||
const enableActionFlipHorizontal = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const eligibleElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
return eligibleElements.length === 1 && eligibleElements[0].type !== "text";
|
||||
};
|
||||
|
||||
const enableActionFlipVertical = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const eligibleElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
return eligibleElements.length === 1;
|
||||
};
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "horizontal"),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.shiftKey && event.code === "KeyH",
|
||||
contextItemLabel: "labels.flipHorizontal",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
enableActionFlipHorizontal(elements, appState),
|
||||
});
|
||||
|
||||
export const actionFlipVertical = register({
|
||||
name: "flipVertical",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "vertical"),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.shiftKey && event.code === "KeyV",
|
||||
contextItemLabel: "labels.flipVertical",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
enableActionFlipVertical(elements, appState),
|
||||
});
|
||||
|
||||
const flipSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
// remove once we allow for groups of elements to be flipped
|
||||
if (selectedElements.length > 1) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const updatedElements = flipElements(
|
||||
selectedElements,
|
||||
appState,
|
||||
flipDirection,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
return elements.map(
|
||||
(element) => updatedElementsMap.get(element.id) || element,
|
||||
);
|
||||
};
|
||||
|
||||
const flipElements = (
|
||||
elements: NonDeleted<ExcalidrawElement>[],
|
||||
appState: AppState,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
): ExcalidrawElement[] => {
|
||||
elements.forEach((element) => {
|
||||
flipElement(element, appState);
|
||||
// If vertical flip, rotate an extra 180
|
||||
if (flipDirection === "vertical") {
|
||||
rotateElement(element, Math.PI);
|
||||
}
|
||||
});
|
||||
return elements;
|
||||
};
|
||||
|
||||
const flipElement = (
|
||||
element: NonDeleted<ExcalidrawElement>,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const originalX = element.x;
|
||||
const originalY = element.y;
|
||||
const width = element.width;
|
||||
const height = element.height;
|
||||
const originalAngle = normalizeAngle(element.angle);
|
||||
|
||||
let finalOffsetX = 0;
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
finalOffsetX =
|
||||
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
|
||||
element.width;
|
||||
}
|
||||
|
||||
// Rotate back to zero, if necessary
|
||||
mutateElement(element, {
|
||||
angle: normalizeAngle(0),
|
||||
});
|
||||
// Flip unrotated by pulling TransformHandle to opposite side
|
||||
const transformHandles = getTransformHandles(element, appState.zoom);
|
||||
let usingNWHandle = true;
|
||||
let newNCoordsX = 0;
|
||||
let nHandle = transformHandles.nw;
|
||||
if (!nHandle) {
|
||||
// Use ne handle instead
|
||||
usingNWHandle = false;
|
||||
nHandle = transformHandles.ne;
|
||||
if (!nHandle) {
|
||||
mutateElement(element, {
|
||||
angle: originalAngle,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isLinearElement(element)) {
|
||||
for (let index = 1; index < element.points.length; index++) {
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{ index, point: [-element.points[index][0], element.points[index][1]] },
|
||||
]);
|
||||
}
|
||||
LinearElementEditor.normalizePoints(element);
|
||||
} else {
|
||||
// calculate new x-coord for transformation
|
||||
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
|
||||
resizeSingleElement(
|
||||
new Map().set(element.id, element),
|
||||
true,
|
||||
element,
|
||||
usingNWHandle ? "nw" : "ne",
|
||||
false,
|
||||
newNCoordsX,
|
||||
nHandle[1],
|
||||
);
|
||||
// fix the size to account for handle sizes
|
||||
mutateElement(element, {
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
// Rotate by (360 degrees - original angle)
|
||||
let angle = normalizeAngle(2 * Math.PI - originalAngle);
|
||||
if (angle < 0) {
|
||||
// check, probably unnecessary
|
||||
angle = normalizeAngle(angle + 2 * Math.PI);
|
||||
}
|
||||
mutateElement(element, {
|
||||
angle,
|
||||
});
|
||||
|
||||
// Move back to original spot to appear "flipped in place"
|
||||
mutateElement(element, {
|
||||
x: originalX + finalOffsetX,
|
||||
y: originalY,
|
||||
});
|
||||
|
||||
updateBoundElements(element);
|
||||
};
|
||||
|
||||
const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
|
||||
const originalX = element.x;
|
||||
const originalY = element.y;
|
||||
let angle = normalizeAngle(element.angle + rotationAngle);
|
||||
if (angle < 0) {
|
||||
// check, probably unnecessary
|
||||
angle = normalizeAngle(2 * Math.PI + angle);
|
||||
}
|
||||
mutateElement(element, {
|
||||
angle,
|
||||
});
|
||||
|
||||
// Move back to original spot
|
||||
mutateElement(element, {
|
||||
x: originalX,
|
||||
y: originalY,
|
||||
});
|
||||
};
|
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
@@ -17,9 +18,8 @@ import {
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { randomId } from "../random";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
|
||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||
if (elements.length >= 2) {
|
||||
@@ -45,7 +45,6 @@ const enableActionGroup = (
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
return (
|
||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||
@@ -54,12 +53,10 @@ const enableActionGroup = (
|
||||
|
||||
export const actionGroup = register({
|
||||
name: "group",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
if (selectedElements.length < 2) {
|
||||
// nothing to group
|
||||
@@ -87,9 +84,8 @@ export const actionGroup = register({
|
||||
}
|
||||
}
|
||||
const newGroupId = randomId();
|
||||
const selectElementIds = arrayToMap(selectedElements);
|
||||
const updatedElements = elements.map((element) => {
|
||||
if (!selectElementIds.get(element.id)) {
|
||||
if (!appState.selectedElementIds[element.id]) {
|
||||
return element;
|
||||
}
|
||||
return newElementWith(element, {
|
||||
@@ -104,8 +100,9 @@ export const actionGroup = register({
|
||||
// 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 lastGroupElementIndex = updatedElements.lastIndexOf(
|
||||
lastElementInGroup,
|
||||
);
|
||||
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
||||
const elementsBeforeGroup = updatedElements
|
||||
.slice(0, lastGroupElementIndex)
|
||||
@@ -128,6 +125,7 @@ export const actionGroup = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextMenuOrder: 4,
|
||||
contextItemLabel: "labels.group",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
enableActionGroup(elements, appState),
|
||||
@@ -137,7 +135,7 @@ export const actionGroup = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<GroupIcon theme={appState.theme} />}
|
||||
icon={<GroupIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.group")} — ${getShortcutKey("CtrlOrCmd+G")}`}
|
||||
aria-label={t("labels.group")}
|
||||
@@ -148,18 +146,12 @@ export const actionGroup = register({
|
||||
|
||||
export const actionUngroup = register({
|
||||
name: "ungroup",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const groupIds = getSelectedGroupIds(appState);
|
||||
if (groupIds.length === 0) {
|
||||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
|
||||
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
|
||||
const nextElements = elements.map((element) => {
|
||||
if (isBoundToContainer(element)) {
|
||||
boundTextElementIds.push(element.id);
|
||||
}
|
||||
const nextGroupIds = removeFromSelectedGroups(
|
||||
element.groupIds,
|
||||
appState.selectedGroupIds,
|
||||
@@ -171,25 +163,18 @@ export const actionUngroup = register({
|
||||
groupIds: nextGroupIds,
|
||||
});
|
||||
});
|
||||
|
||||
const updateAppState = selectGroupsForSelectedElements(
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(nextElements),
|
||||
);
|
||||
|
||||
// remove binded text elements from selection
|
||||
boundTextElementIds.forEach(
|
||||
(id) => (updateAppState.selectedElementIds[id] = false),
|
||||
);
|
||||
return {
|
||||
appState: updateAppState,
|
||||
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(nextElements),
|
||||
),
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
||||
contextMenuOrder: 5,
|
||||
contextItemLabel: "labels.ungroup",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
getSelectedGroupIds(appState).length > 0,
|
||||
@@ -198,7 +183,7 @@ export const actionUngroup = register({
|
||||
<ToolButton
|
||||
type="button"
|
||||
hidden={getSelectedGroupIds(appState).length === 0}
|
||||
icon={<UngroupIcon theme={appState.theme} />}
|
||||
icon={<UngroupIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.ungroup")} — ${getShortcutKey("CtrlOrCmd+Shift+G")}`}
|
||||
aria-label={t("labels.ungroup")}
|
||||
|
@@ -1,14 +1,15 @@
|
||||
import { Action, ActionResult } from "./types";
|
||||
import React from "react";
|
||||
import { undo, redo } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import History, { HistoryEntry } from "../history";
|
||||
import { SceneHistory, HistoryEntry } from "../history";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { isWindows, KEYS } from "../keys";
|
||||
import { KEYS } from "../keys";
|
||||
import { getElementMap } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { arrayToMap } from "../utils";
|
||||
|
||||
const writeData = (
|
||||
prevElements: readonly ExcalidrawElement[],
|
||||
@@ -27,17 +28,17 @@ const writeData = (
|
||||
return { commitToHistory };
|
||||
}
|
||||
|
||||
const prevElementMap = arrayToMap(prevElements);
|
||||
const prevElementMap = getElementMap(prevElements);
|
||||
const nextElements = data.elements;
|
||||
const nextElementMap = arrayToMap(nextElements);
|
||||
const nextElementMap = getElementMap(nextElements);
|
||||
|
||||
const deletedElements = prevElements.filter(
|
||||
(prevElement) => !nextElementMap.has(prevElement.id),
|
||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
||||
);
|
||||
const elements = nextElements
|
||||
.map((nextElement) =>
|
||||
newElementWith(
|
||||
prevElementMap.get(nextElement.id) || nextElement,
|
||||
prevElementMap[nextElement.id] || nextElement,
|
||||
nextElement,
|
||||
),
|
||||
)
|
||||
@@ -58,24 +59,22 @@ const writeData = (
|
||||
return { commitToHistory };
|
||||
};
|
||||
|
||||
type ActionCreator = (history: History) => Action;
|
||||
const testUndo = (shift: boolean) => (event: KeyboardEvent) =>
|
||||
event[KEYS.CTRL_OR_CMD] && /z/i.test(event.key) && event.shiftKey === shift;
|
||||
|
||||
type ActionCreator = (history: SceneHistory) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
name: "undo",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.undoOnce()),
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key.toLowerCase() === KEYS.Z &&
|
||||
!event.shiftKey,
|
||||
PanelComponent: ({ updateData, data }) => (
|
||||
keyTest: testUndo(false),
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={undo}
|
||||
aria-label={t("buttons.undo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
commitToHistory: () => false,
|
||||
@@ -83,21 +82,15 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
|
||||
export const createRedoAction: ActionCreator = (history) => ({
|
||||
name: "redo",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.redoOnce()),
|
||||
keyTest: (event) =>
|
||||
(event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === KEYS.Z) ||
|
||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||
PanelComponent: ({ updateData, data }) => (
|
||||
keyTest: testUndo(true),
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={redo}
|
||||
aria-label={t("buttons.redo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
commitToHistory: () => false,
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { menu, palette } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
@@ -6,10 +7,10 @@ import { register } from "./register";
|
||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { HelpIcon } from "../components/HelpIcon";
|
||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
@@ -30,7 +31,6 @@ export const actionToggleCanvasMenu = register({
|
||||
|
||||
export const actionToggleEditMenu = register({
|
||||
name: "toggleEditMenu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_elements, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
@@ -55,7 +55,6 @@ export const actionToggleEditMenu = register({
|
||||
|
||||
export const actionFullScreen = register({
|
||||
name: "toggleFullScreen",
|
||||
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
|
||||
perform: () => {
|
||||
if (!isFullScreen()) {
|
||||
allowFullScreen();
|
||||
@@ -72,21 +71,18 @@ export const actionFullScreen = register({
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||
perform: (_elements, appState, _, { focusContainer }) => {
|
||||
if (appState.showHelpDialog) {
|
||||
focusContainer();
|
||||
}
|
||||
perform: (_elements, appState) => {
|
||||
trackEvent(EVENT_DIALOG, "shortcuts");
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
showHelpDialog: !appState.showHelpDialog,
|
||||
showShortcutsDialog: true,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<HelpIcon title={t("helpDialog.title")} onClick={updateData} />
|
||||
<HelpIcon title={t("shortcutsDialog.title")} onClick={updateData} />
|
||||
),
|
||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||
});
|
||||
|
@@ -1,14 +1,16 @@
|
||||
import { getClientColors } from "../clients";
|
||||
import React from "react";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { Collaborator } from "../types";
|
||||
import { register } from "./register";
|
||||
import { getClientColors, getClientInitials } from "../clients";
|
||||
import { Collaborator } from "../types";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { EVENT_SHARE, trackEvent } from "../analytics";
|
||||
|
||||
export const actionGoToCollaborator = register({
|
||||
name: "goToCollaborator",
|
||||
trackEvent: { category: "collab" },
|
||||
perform: (_elements, appState, value) => {
|
||||
const point = value as Collaborator["pointer"];
|
||||
trackEvent(EVENT_SHARE, "go to collaborator");
|
||||
if (!point) {
|
||||
return { appState, commitToHistory: false };
|
||||
}
|
||||
@@ -30,19 +32,29 @@ export const actionGoToCollaborator = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, data }) => {
|
||||
const [clientId, collaborator] = data as [string, Collaborator];
|
||||
PanelComponent: ({ appState, updateData, id }) => {
|
||||
const clientId = id;
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { background, stroke } = getClientColors(clientId, appState);
|
||||
const collaborator = appState.collaborators.get(clientId);
|
||||
|
||||
if (!collaborator) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { background, stroke } = getClientColors(clientId);
|
||||
const shortName = getClientInitials(collaborator.username);
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
color={background}
|
||||
border={stroke}
|
||||
onClick={() => updateData(collaborator.pointer)}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
/>
|
||||
>
|
||||
{shortName}
|
||||
</Avatar>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
@@ -1,12 +1,10 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { selectGroupsForSelectedElements } from "../groups";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
@@ -16,19 +14,12 @@ export const actionSelectAll = register({
|
||||
{
|
||||
...appState,
|
||||
editingGroupId: null,
|
||||
selectedElementIds: elements.reduce(
|
||||
(map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
!element.locked
|
||||
) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
},
|
||||
{},
|
||||
),
|
||||
selectedElementIds: elements.reduce((map, element) => {
|
||||
if (!element.isDeleted) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
}, {} as any),
|
||||
},
|
||||
getNonDeletedElements(elements),
|
||||
),
|
||||
|
@@ -1,71 +0,0 @@
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { t } from "../i18n";
|
||||
import { CODES } from "../keys";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
|
||||
import { fireEvent, render, screen } from "../tests/test-utils";
|
||||
import { copiedStyles } from "./actionStyles";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
describe("actionStyles", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
});
|
||||
it("should copy & paste styles via keyboard", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
// Change some styles of second rectangle
|
||||
UI.clickLabeledElement("Stroke");
|
||||
UI.clickLabeledElement(t("colors.c92a2a"));
|
||||
UI.clickLabeledElement("Background");
|
||||
UI.clickLabeledElement(t("colors.e64980"));
|
||||
// Fill style
|
||||
fireEvent.click(screen.getByTitle("Cross-hatch"));
|
||||
// Stroke width
|
||||
fireEvent.click(screen.getByTitle("Bold"));
|
||||
// Stroke style
|
||||
fireEvent.click(screen.getByTitle("Dotted"));
|
||||
// Roughness
|
||||
fireEvent.click(screen.getByTitle("Cartoonist"));
|
||||
// Opacity
|
||||
fireEvent.change(screen.getByLabelText("Opacity"), {
|
||||
target: { value: "60" },
|
||||
});
|
||||
|
||||
mouse.reset();
|
||||
|
||||
API.setSelectedElements([h.elements[1]]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
||||
Keyboard.codeDown(CODES.C);
|
||||
});
|
||||
const secondRect = JSON.parse(copiedStyles)[0];
|
||||
expect(secondRect.id).toBe(h.elements[1].id);
|
||||
|
||||
mouse.reset();
|
||||
// Paste styles to first rectangle
|
||||
API.setSelectedElements([h.elements[0]]);
|
||||
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
||||
Keyboard.codeDown(CODES.V);
|
||||
});
|
||||
|
||||
const firstRect = API.getSelectedElement();
|
||||
expect(firstRect.id).toBe(h.elements[0].id);
|
||||
expect(firstRect.strokeColor).toBe("#c92a2a");
|
||||
expect(firstRect.backgroundColor).toBe("#e64980");
|
||||
expect(firstRect.fillStyle).toBe("cross-hatch");
|
||||
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
|
||||
expect(firstRect.strokeStyle).toBe("dotted");
|
||||
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
|
||||
expect(firstRect.opacity).toBe(60);
|
||||
});
|
||||
});
|
@@ -1,42 +1,109 @@
|
||||
import {
|
||||
isTextElement,
|
||||
isExcalidrawElement,
|
||||
redrawTextBoundingBox,
|
||||
getNonDeletedElements,
|
||||
} from "../element";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import { hasBoundTextElement } from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementPossibleProps,
|
||||
} from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import {
|
||||
canChangeSharpness,
|
||||
getSelectedElements,
|
||||
hasBackground,
|
||||
hasStroke,
|
||||
hasText,
|
||||
} from "../scene";
|
||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
||||
|
||||
type AppStateStyles = {
|
||||
[K in AssertSubset<
|
||||
keyof AppState,
|
||||
typeof copyableStyles[number][0]
|
||||
>]: AppState[K];
|
||||
};
|
||||
|
||||
type ElementStyles = {
|
||||
[K in AssertSubset<
|
||||
keyof ExcalidrawElementPossibleProps,
|
||||
typeof copyableStyles[number][1]
|
||||
>]: ExcalidrawElementPossibleProps[K];
|
||||
};
|
||||
|
||||
type ElemelementStylesByType = Record<ExcalidrawElement["type"], ElementStyles>;
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
let COPIED_STYLES: {
|
||||
appStateStyles: Partial<AppStateStyles>;
|
||||
elementStyles: Partial<ElementStyles>;
|
||||
elementStylesByType: Partial<ElemelementStylesByType>;
|
||||
} | null = null;
|
||||
|
||||
/* [AppState prop, ExcalidrawElement prop, predicate] */
|
||||
const copyableStyles = [
|
||||
["currentItemOpacity", "opacity", () => true],
|
||||
["currentItemStrokeColor", "strokeColor", () => true],
|
||||
["currentItemStrokeStyle", "strokeStyle", hasStroke],
|
||||
["currentItemStrokeWidth", "strokeWidth", hasStroke],
|
||||
["currentItemRoughness", "roughness", hasStroke],
|
||||
["currentItemBackgroundColor", "backgroundColor", hasBackground],
|
||||
["currentItemFillStyle", "fillStyle", hasBackground],
|
||||
["currentItemStrokeSharpness", "strokeSharpness", canChangeSharpness],
|
||||
["currentItemLinearStrokeSharpness", "strokeSharpness", isLinearElementType],
|
||||
["currentItemStartArrowhead", "startArrowhead", isLinearElementType],
|
||||
["currentItemEndArrowhead", "endArrowhead", isLinearElementType],
|
||||
["currentItemFontFamily", "fontFamily", hasText],
|
||||
["currentItemFontSize", "fontSize", hasText],
|
||||
["currentItemTextAlign", "textAlign", hasText],
|
||||
] as const;
|
||||
|
||||
const getCommonStyleProps = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): Exclude<typeof COPIED_STYLES, null> => {
|
||||
const appStateStyles = {} as AppStateStyles;
|
||||
const elementStyles = {} as ElementStyles;
|
||||
|
||||
const elementStylesByType = elements.reduce((acc, element) => {
|
||||
// only use the first element of given type
|
||||
if (!acc[element.type]) {
|
||||
acc[element.type] = {} as ElementStyles;
|
||||
copyableStyles.forEach(([appStateProp, prop, predicate]) => {
|
||||
const value = (element as any)[prop];
|
||||
if (value !== undefined && predicate(element.type)) {
|
||||
if (appStateStyles[appStateProp] === undefined) {
|
||||
(appStateStyles as any)[appStateProp] = value;
|
||||
}
|
||||
if (elementStyles[prop] === undefined) {
|
||||
(elementStyles as any)[prop] = value;
|
||||
}
|
||||
(acc as any)[element.type][prop] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, {} as ElemelementStylesByType);
|
||||
|
||||
// clone in case we ever make some of the props into non-primitives
|
||||
return JSON.parse(
|
||||
JSON.stringify({ appStateStyles, elementStyles, elementStylesByType }),
|
||||
);
|
||||
};
|
||||
|
||||
export const actionCopyStyles = register({
|
||||
name: "copyStyles",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const elementsCopied = [];
|
||||
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
||||
elementsCopied.push(element);
|
||||
if (element && hasBoundTextElement(element)) {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
elementsCopied.push(boundTextElement);
|
||||
}
|
||||
if (element) {
|
||||
copiedStyles = JSON.stringify(elementsCopied);
|
||||
}
|
||||
COPIED_STYLES = getCommonStyleProps(
|
||||
getSelectedElements(getNonDeletedElements(elements), appState),
|
||||
);
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
toast: { message: t("toast.copyStyles") },
|
||||
...COPIED_STYLES.appStateStyles,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
@@ -44,69 +111,55 @@ export const actionCopyStyles = register({
|
||||
contextItemLabel: "labels.copyStyles",
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
||||
contextMenuOrder: 0,
|
||||
});
|
||||
|
||||
export const actionPasteStyles = register({
|
||||
name: "pasteStyles",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const elementsCopied = JSON.parse(copiedStyles);
|
||||
const pastedElement = elementsCopied[0];
|
||||
const boundTextElement = elementsCopied[1];
|
||||
if (!isExcalidrawElement(pastedElement)) {
|
||||
if (!COPIED_STYLES) {
|
||||
return { elements, commitToHistory: false };
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const selectedElementIds = selectedElements.map((element) => element.id);
|
||||
const getStyle = <T extends ExcalidrawElement, K extends keyof T>(
|
||||
element: T,
|
||||
prop: K,
|
||||
) => {
|
||||
return (COPIED_STYLES?.elementStylesByType[element.type]?.[
|
||||
prop as keyof ElementStyles
|
||||
] ??
|
||||
COPIED_STYLES?.elementStyles[prop as keyof ElementStyles] ??
|
||||
element[prop]) as T[K];
|
||||
};
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (selectedElementIds.includes(element.id)) {
|
||||
let elementStylesToCopyFrom = pastedElement;
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
elementStylesToCopyFrom = boundTextElement;
|
||||
}
|
||||
if (!elementStylesToCopyFrom) {
|
||||
return element;
|
||||
}
|
||||
let newElement = newElementWith(element, {
|
||||
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
|
||||
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
|
||||
strokeColor: elementStylesToCopyFrom?.strokeColor,
|
||||
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
|
||||
fillStyle: elementStylesToCopyFrom?.fillStyle,
|
||||
opacity: elementStylesToCopyFrom?.opacity,
|
||||
roughness: elementStylesToCopyFrom?.roughness,
|
||||
});
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
newElement = newElementWith(newElement, {
|
||||
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
|
||||
fontFamily:
|
||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||
textAlign:
|
||||
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
const commonProps = {
|
||||
backgroundColor: getStyle(element, "backgroundColor"),
|
||||
strokeWidth: getStyle(element, "strokeWidth"),
|
||||
strokeColor: getStyle(element, "strokeColor"),
|
||||
strokeStyle: getStyle(element, "strokeStyle"),
|
||||
fillStyle: getStyle(element, "fillStyle"),
|
||||
opacity: getStyle(element, "opacity"),
|
||||
roughness: getStyle(element, "roughness"),
|
||||
strokeSharpness: getStyle(element, "strokeSharpness"),
|
||||
};
|
||||
if (isTextElement(element)) {
|
||||
const newElement = newElementWith(element, {
|
||||
...commonProps,
|
||||
fontSize: getStyle(element, "fontSize"),
|
||||
fontFamily: getStyle(element, "fontFamily"),
|
||||
textAlign: getStyle(element, "textAlign"),
|
||||
});
|
||||
let container = null;
|
||||
if (newElement.containerId) {
|
||||
container =
|
||||
selectedElements.find(
|
||||
(element) =>
|
||||
isTextElement(newElement) &&
|
||||
element.id === newElement.containerId,
|
||||
) || null;
|
||||
}
|
||||
redrawTextBoundingBox(newElement, container);
|
||||
}
|
||||
|
||||
if (newElement.type === "arrow") {
|
||||
newElement = newElementWith(newElement, {
|
||||
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
||||
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
||||
redrawTextBoundingBox(newElement);
|
||||
return newElement;
|
||||
} else if (isLinearElement(element)) {
|
||||
return newElementWith(element, {
|
||||
...commonProps,
|
||||
startArrowhead: getStyle(element, "startArrowhead"),
|
||||
endArrowhead: getStyle(element, "endArrowhead"),
|
||||
});
|
||||
}
|
||||
|
||||
return newElement;
|
||||
return newElementWith(element, commonProps);
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
@@ -116,4 +169,5 @@ export const actionPasteStyles = register({
|
||||
contextItemLabel: "labels.pasteStyles",
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||
contextMenuOrder: 1,
|
||||
});
|
||||
|
@@ -1,24 +0,0 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const actionToggleGridMode = register({
|
||||
name: "gridMode",
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.gridSize,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
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,63 +0,0 @@
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleLock = register({
|
||||
name: "toggleLock",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
|
||||
if (!selectedElements.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const operation = getOperation(selectedElements);
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return newElementWith(element, { locked: operation === "lock" });
|
||||
}),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState) => {
|
||||
const selected = getSelectedElements(elements, appState, false);
|
||||
if (selected.length === 1) {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
}
|
||||
|
||||
if (selected.length > 1) {
|
||||
return getOperation(selected) === "lock"
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unexpected zero elements to lock/unlock. This should never happen.",
|
||||
);
|
||||
},
|
||||
keyTest: (event, appState, elements) => {
|
||||
return (
|
||||
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
getSelectedElements(elements, appState, false).length > 0
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const getOperation = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
|
@@ -1,20 +0,0 @@
|
||||
import { register } from "./register";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
trackEvent: { category: "menu" },
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
showStats: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.showStats,
|
||||
contextItemLabel: "stats.title",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
||||
});
|
@@ -1,23 +0,0 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleViewMode = register({
|
||||
name: "viewMode",
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.viewModeEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
viewModeEnabled: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.viewModeEnabled,
|
||||
contextItemLabel: "labels.viewMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
|
||||
});
|