Compare commits

..

1 Commits

Author SHA1 Message Date
kbariotis
0cb67a0bc9 upload images and store them as base64 2020-05-13 21:42:56 +01:00
357 changed files with 42034 additions and 72266 deletions

View File

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

5
.env
View File

@@ -1,5 +0,0 @@
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'

View File

@@ -1 +0,0 @@
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13

View File

@@ -3,5 +3,3 @@ build/
package-lock.json package-lock.json
.vscode/ .vscode/
firebase/ firebase/
dist/
public/workbox

View File

@@ -1,6 +1,30 @@
{ {
"extends": ["@excalidraw/eslint-config", "react-app"], "extends": ["prettier", "react-app"],
"plugins": ["prettier"],
"rules": { "rules": {
"import/no-anonymous-default-export": "off" "curly": "warn",
"no-console": [
"warn",
{
"allow": ["warn", "error", "info"]
}
],
"no-else-return": "warn",
"no-useless-return": "warn",
"prefer-const": [
"warn",
{
"destructuring": "all"
}
],
"prefer-template": "warn",
"prettier/prettier": "warn",
"no-restricted-syntax": [
"warn",
{
"selector": "JSXText[value=/\\w/]",
"message": "Use 't(...)' instead of literal text in JSX"
}
]
} }
} }

1
.gitattributes vendored
View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,28 @@
name: Lint name: Lint
on: pull_request on:
push:
branches:
- master
pull_request:
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v1
- name: Setup Node.js 14.x - name: Setup Node.js 12.x
uses: actions/setup-node@v2 uses: actions/setup-node@v1
with: with:
node-version: 14.x node-version: 12.x
- name: Install and lint - name: Install and lint
run: | run: |
yarn --frozen-lockfile npm ci
yarn test:other npm run test:other
yarn test:code npm run test:code
yarn test:typecheck npm run test:typecheck
env:
CI: true

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
name: New Sentry production release name: New Sentry Production Release
on: on:
push: push:
@@ -6,23 +6,27 @@ on:
- master - master
jobs: jobs:
sentry: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v1.0.0
- name: Setup Node.js 14.x
uses: actions/setup-node@v2 - name: Setup Node.js 12.x
uses: actions/setup-node@v1
with: with:
node-version: 14.x node-version: 12.x
- name: Install and build - name: Install and build
run: | run: |
yarn --frozen-lockfile npm ci
yarn build:app npm run build:app
env: env:
CI: true CI: true
- name: Install Sentry - name: Install Sentry
run: | run: |
curl -sL https://sentry.io/get-cli/ | bash curl -sL https://sentry.io/get-cli/ | bash
- name: Create new Sentry release - name: Create new Sentry release
run: | run: |
export SENTRY_RELEASE=$(sentry-cli releases propose-version) export SENTRY_RELEASE=$(sentry-cli releases propose-version)

View File

@@ -1,17 +1,26 @@
name: Tests name: Tests
on: pull_request on:
push:
branches:
- master
pull_request:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v1
- name: Setup Node.js 14.x
uses: actions/setup-node@v2 - name: Setup Node.js 12.x
uses: actions/setup-node@v1
with: with:
node-version: 14.x node-version: 12.x
- name: Install and test - name: Install and test
run: | run: |
yarn --frozen-lockfile npm ci
yarn test:app npm run test:app
env:
CI: true

21
.gitignore vendored
View File

@@ -1,22 +1,15 @@
.DS_Store
.env.development.local
.env.local
.env.production.local
.env.test.local
.envrc
.eslintcache
.idea
.vercel
.vscode
*.log *.log
*.tgz .DS_Store
.envrc
.now
.vscode
build build
dist firebase/
firebase
logs logs
node_modules node_modules
npm-debug.log* npm-debug.log*
package-lock.json
static static
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
yarn.lock
.idea

View File

@@ -1,7 +1,7 @@
const { CLIEngine } = require("eslint"); const { CLIEngine } = require("eslint");
// see https://github.com/okonet/lint-staged#how-can-i-ignore-files-from-eslintignore- // see https://github.com/okonet/lint-staged#how-can-i-ignore-files-from-eslintignore-
// for explanation // for explanation
const cli = new CLIEngine({}); const cli = new CLIEngine({});
module.exports = { module.exports = {

2
.nvmrc
View File

@@ -1 +1 @@
14 12

View File

3
.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"trailingComma": "all"
}

View File

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

View File

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

View File

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

161
README.md
View File

@@ -1,101 +1,36 @@
<div align="center" style="display:flex;flex-direction:column;"> <div align="center" style="display:flex;flex-direction:column;">
<a href="https://excalidraw.com"> <a href="https://excalidraw.com">
<img width="540" src="./public/og-image-sm.png" alt="Excalidraw logo: Sketch handrawn like diagrams." /> <img src="./public/og-image.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
</a> </a>
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.<br>Collaborative and end-to-end encrypted.</h3> <h3>Excalidraw is a whiteboard tool that lets you easily sketch diagrams with a hand-drawn feel.</h3>
<p> <p>
<a href="https://twitter.com/Excalidraw"> <a href="https://twitter.com/Excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter"> <img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
</a> </a>
<a target="_blank" href="https://crowdin.com/project/excalidraw"> <a title="Crowdin" target="_blank" href="https://crowdin.com/project/excalidraw">
<img src="https://badges.crowdin.net/excalidraw/localized.svg"> <img src="https://badges.crowdin.net/excalidraw/localized.svg">
</a> </a>
</p> </p>
<p>Ask questions or hang out on our <a target="_blank" href="https://discord.gg/UexuTaE">discord.gg/UexuTaE</a>.</p>
</div> </div>
## Try it now ## Try it now
Go to [excalidraw.com](https://excalidraw.com) to start sketching. Go to https://excalidraw.com to start sketching.
Read the latest news and updates on our [blog](https://blog.excalidraw.com). A good start is to see all the updates of [One Year of Excalidraw](https://blog.excalidraw.com/one-year-of-excalidraw/). Read our [blog](https://blog.excalidraw.com) and follow the [guides](https://howto.excalidraw.com) to learn more about Excalidraw and how to use it effectively.
## Supporting Excalidraw ## Run the code
If you like the project, you can become a sponsor at [Open Collective](https://opencollective.com/excalidraw).
[<img src="https://opencollective.com/excalidraw/tiers/sponsors/0/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/0/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/1/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/1/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/2/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/2/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/3/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/3/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/4/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/4/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/5/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/5/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/6/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/6/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/7/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/7/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/8/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/8/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/9/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/9/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/10/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/10/website)
<a href="https://opencollective.com/excalidraw#category-CONTRIBUTE" target="_blank"><img src="https://opencollective.com/excalidraw/tiers/backers.svg?avatarHeight=32"/></a>
Last but not least, we're thankful to these companies for offering their services for free:
[![Vercel](./.github/assets/vercel.svg)](https://vercel.com) [![Sentry](./.github/assets/sentry.svg)](https://sentry.io) [![Crowdin](./.github/assets/crowdin.svg)](https://crowdin.com)
## Documentation
### Shortcuts
You can almost do anything with shortcuts. Click on the help icon on the bottom right corner to see them all.
### Curved lines and arrows
Choose line or arrow and click click click instead of drag.
### Charts
You can easily create charts by copy pasting data from Excel or just plain comma separated text.
### Translating
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
### Create a collaboration session manually
In order to create a session manually, you just need to generate a link of this form:
```
https://excalidraw.com/#room=[0-9a-f]{20},[a-zA-Z0-9_-]{22}
```
#### Example
```
https://excalidraw.com/#room=91bd46ae3aa84dff9d20,pfLqgEoY1c2ioq8LmGwsFA
```
The first set of digits is the room. This is visible from the server thats going to dispatch messages to everyone that knows this number.
The second set of digits is the encryption key. The Excalidraw server doesnt know about it. This is what all the participants use to encrypt/decrypt the messages.
## Shape libraries
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
## Embedding Excalidraw in your App?
Try out [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/excalidraw). This package allows you to easily embed Excalidraw as a React component into your apps.
## Development
### Code Sandbox ### Code Sandbox
- Go to https://codesandbox.io/s/github/excalidraw/excalidraw - Go to https://codesandbox.io/s/github/excalidraw/excalidraw
- You may need to sign in with GitHub and reload the page - You may need to sign in with Github and reload the page
- You can start coding instantly, and even send PRs from there! - You can start coding instantly, and even send PRs from there!
### Local Installation ### Local Installation
These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. These instructions will get you a copy of the project up and running on your local machine for development and testing purposes.
#### Requirements
- [Node.js](https://nodejs.org/en/)
- [Yarn](https://yarnpkg.com/getting-started/install)
- [Git](https://git-scm.com/downloads)
#### Clone the repo #### Clone the repo
```bash ```bash
@@ -104,47 +39,87 @@ git clone https://github.com/excalidraw/excalidraw.git
#### Commands #### Commands
| Command | Description | | Command | Description |
| ------------------ | --------------------------------- | | --------------------- | --------------------------------- |
| `yarn` | Install the dependencies | | `npm install` | Install the dependencies |
| `yarn start` | Run the project | | `npm start` | Run the project |
| `yarn fix` | Reformat all files with Prettier | | `npm run fix` | Reformat all files with Prettier |
| `yarn test` | Run tests | | `npm test` | Run tests |
| `yarn test:update` | Update test snapshots | | `npm run test:update` | Update test snapshots |
| `yarn test:code` | Test for formatting with Prettier | | `npm run test:code` | Test for formatting with Prettier |
### Docker Installation
A production-ready version for deploying to e.g. Kubernetes or OpenShift can be built using Docker.
#### Docker Compose #### Docker Compose
You can use docker-compose to work on Excalidraw locally if you don't want to setup a Node.js env.
```sh ```sh
docker-compose up --build -d docker-compose up --build -d
``` ```
### Self-hosting #### Native Docker
We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self-host your own client under your own domain, on Kubernetes, AWS ECS, etc.
```sh ```sh
docker build -t excalidraw/excalidraw . docker build -t excalidraw/excalidraw .
docker run --rm -dit --name excalidraw -p 5000:80 excalidraw/excalidraw:latest docker run --rm -dit --name excalidraw -p 5000:80 excalidraw/excalidraw:latest
``` ```
The Docker image is free of analytics and other tracking libraries. After building the image and running the container, open <http://localhost:5000> to see the application.
**At the moment, self-hosting your own instance doesn't support sharing or collaboration features.**
We are working towards providing a full-fledged solution for self-hosting your own Excalidraw.
## Contributing ## Contributing
Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change. Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change.
## Notable used tools ## Translating
- [Create React App](https://github.com/facebook/create-react-app) To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
## Excalidraw is built using these awesome tools
- [React](https://reactjs.org)
- [Rough.js](https://roughjs.com) - [Rough.js](https://roughjs.com)
- [TypeScript](https://www.typescriptlang.org) - [TypeScript](https://typescriptlang.org)
- [Vercel](https://vercel.com) - [Vercel](https://vercel.com)
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app. And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
## Testimonials
<a href="https://twitter.com/Lissy_Sykes/status/1213813117177729026"><img width="398" src="https://user-images.githubusercontent.com/197597/71783813-dbf8a600-2fa0-11ea-9c0d-bb3cc45969e6.png"></a>
<a href="https://twitter.com/dan_abramov/status/1213762494428262400"><img width="398" src="https://user-images.githubusercontent.com/197597/71783990-4d395880-2fa3-11ea-9ad7-186138db5003.png"></a>
<a href="https://twitter.com/kyehohenberger/status/1214288572037025792"><img width="423" src="https://user-images.githubusercontent.com/197597/71851802-34f13880-308c-11ea-9416-191099e6349c.png"></a>
<a href="https://twitter.com/lucasazzola/status/1215126440330416128"><img width="429" src="https://user-images.githubusercontent.com/197597/72039003-48e99580-3258-11ea-8daa-85dd055f2a82.png">
<a href="https://twitter.com/jordwalke/status/1214858186789806080"><img width="434" src="https://user-images.githubusercontent.com/197597/72036874-07a1b780-3251-11ea-99e8-6bafd93483a0.png"></a>
## Contributors
### Code Contributors
This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)].
<a href="https://github.com/excalidraw/excalidraw/graphs/contributors"><img src="https://opencollective.com/excalidraw/contributors.svg?width=890&button=false" /></a>
### Financial Contributors
Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/excalidraw/contribute)]
#### Individuals
<a href="https://opencollective.com/excalidraw"><img src="https://opencollective.com/excalidraw/individuals.svg?width=890"></a>
#### Organizations
Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/excalidraw/contribute)]
<a href="https://opencollective.com/excalidraw/organization/0/website"><img src="https://opencollective.com/excalidraw/organization/0/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/1/website"><img src="https://opencollective.com/excalidraw/organization/1/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/2/website"><img src="https://opencollective.com/excalidraw/organization/2/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/3/website"><img src="https://opencollective.com/excalidraw/organization/3/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/4/website"><img src="https://opencollective.com/excalidraw/organization/4/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/5/website"><img src="https://opencollective.com/excalidraw/organization/5/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/6/website"><img src="https://opencollective.com/excalidraw/organization/6/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/7/website"><img src="https://opencollective.com/excalidraw/organization/7/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/8/website"><img src="https://opencollective.com/excalidraw/organization/8/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/9/website"><img src="https://opencollective.com/excalidraw/organization/9/avatar.svg"></a>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,5 +21,12 @@
} }
] ]
} }
],
"redirects": [
{
"source": "/([^.]+)",
"destination": "/",
"statusCode": 301
}
] ]
} }

20311
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -19,53 +19,45 @@
] ]
}, },
"dependencies": { "dependencies": {
"@sentry/browser": "6.2.2", "@sentry/browser": "5.15.5",
"@sentry/integrations": "6.2.1", "@sentry/integrations": "5.15.5",
"@testing-library/jest-dom": "5.11.9", "@testing-library/jest-dom": "5.7.0",
"@testing-library/react": "11.2.5", "@testing-library/react": "10.0.4",
"@types/jest": "26.0.20", "@types/jest": "25.2.1",
"@types/react": "17.0.2", "@types/nanoid": "2.1.0",
"@types/react-dom": "17.0.1", "@types/react": "16.9.35",
"@types/socket.io-client": "1.4.36", "@types/react-dom": "16.9.8",
"browser-fs-access": "0.14.2", "@types/socket.io-client": "1.4.32",
"clsx": "1.1.1", "browser-nativefs": "0.7.3",
"firebase": "8.2.10", "i18next-browser-languagedetector": "4.1.1",
"i18next-browser-languagedetector": "6.0.1",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"nanoid": "3.1.21", "nanoid": "2.1.11",
"open-color": "1.8.0", "node-sass": "4.14.1",
"pako": "1.0.11", "open-color": "1.7.0",
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0",
"points-on-curve": "0.2.0", "points-on-curve": "0.2.0",
"pwacompat": "2.0.17", "pwacompat": "2.0.12",
"react": "17.0.1", "react": "16.13.1",
"react-dom": "17.0.1", "react-dom": "16.13.1",
"react-scripts": "4.0.3", "react-scripts": "3.4.1",
"roughjs": "4.3.1", "roughjs": "4.3.1",
"sass": "1.32.8", "socket.io-client": "2.3.0",
"socket.io-client": "2.3.1", "typescript": "3.8.3"
"typescript": "4.2.3"
}, },
"devDependencies": { "devDependencies": {
"@excalidraw/eslint-config": "1.0.0",
"@excalidraw/prettier-config": "1.0.2",
"@types/lodash.throttle": "4.1.6", "@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.1", "asar": "3.0.3",
"@types/resize-observer-browser": "0.1.5", "eslint": "6.8.0",
"eslint-config-prettier": "8.1.0", "eslint-config-prettier": "6.11.0",
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.1.3",
"firebase-tools": "9.6.1", "husky": "4.2.5",
"husky": "4.3.8", "jest-canvas-mock": "2.2.0",
"jest-canvas-mock": "2.3.1", "lint-staged": "10.2.2",
"lint-staged": "10.5.4", "pepjs": "0.5.2",
"pepjs": "0.5.3", "prettier": "2.0.5",
"prettier": "2.2.1",
"rewire": "5.0.0" "rewire": "5.0.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=12.0.0"
}, },
"homepage": ".", "homepage": ".",
"husky": { "husky": {
@@ -75,34 +67,28 @@
}, },
"jest": { "jest": {
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)" "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
], ]
"resetMocks": false
}, },
"name": "excalidraw",
"prettier": "@excalidraw/prettier-config",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "npm run build:app && npm run build:zip",
"build-node": "node ./scripts/build-node.js", "build-node": "node ./scripts/build-node.js",
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build", "build:app": "REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build", "build:zip": "node ./scripts/build-version.js",
"build:version": "node ./scripts/build-version.js",
"build": "yarn build:app && yarn build:version",
"eject": "react-scripts eject", "eject": "react-scripts eject",
"fix:code": "yarn test:code --fix", "fix": "npm run fix:other && npm run fix:code",
"fix:other": "yarn prettier --write", "fix:code": "npm run test:code -- --fix",
"fix": "yarn fix:other && yarn fix:code", "fix:other": "npm run prettier -- --write",
"locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "react-scripts start", "start": "react-scripts start",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false", "test": "npm run test:app",
"test:app": "react-scripts test --passWithNoTests", "test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", "test:update": "npm run test:app -- --updateSnapshot --watchAll=false",
"test:app": "react-scripts test --env=jsdom --passWithNoTests",
"test:code": "eslint --max-warnings=0 --ignore-path .gitignore --ext .js,.ts,.tsx .",
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache", "test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
"test:other": "yarn prettier --list-different", "test:other": "npm run prettier -- --list-different",
"test:typecheck": "tsc", "test:typecheck": "tsc"
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
"test": "yarn test:app"
} }
} }

BIN
public/FG_Virgil.otf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -1,7 +1,7 @@
/* http://www.eaglefonts.com/fg-virgil-ttf-131249.htm */ /* http://www.eaglefonts.com/fg-virgil-ttf-131249.htm */
@font-face { @font-face {
font-family: "Virgil"; font-family: "Virgil";
src: url("Virgil.woff2"); src: url("FG_Virgil.woff2");
font-display: swap; font-display: swap;
} }

View File

@@ -13,6 +13,18 @@
<meta name="theme-color" content="#000" /> <meta name="theme-color" content="#000" />
<!-- Origin Trial token for the Native File System API v1 https://developers.chrome.com/origintrials/#/view_trial/3868592079911256065 (Chrome 7881) -->
<meta
http-equiv="origin-trial"
content="AoGjY+6r8OQZ5c0AXpK+bbca0pJdCTSHWFqSFNulxiW4OwFBB63kHdDHNo433GeuEOir8IvSovR0LOZLfPnEDAUAAABceyJvcmlnaW4iOiJodHRwczovL3d3dy5leGNhbGlkcmF3LmNvbTo0NDMiLCJmZWF0dXJlIjoiTmF0aXZlRmlsZVN5c3RlbSIsImV4cGlyeSI6MTU4OTMyNzk5OX0="
/>
<!-- Origin Trial token for the Native File System API v2 https://developers.chrome.com/origintrials/#/view_trial/4019462667428167681 (Chrome 8385) -->
<meta
http-equiv="origin-trial"
content="AgMee3sqSZkE0QaZP8f/F9OJj5iSLdnNMRGttIDlOQy552MI4GoL41jyCAHOYsQ8UWM1kPdrb6PVmbSllX/JqwEAAABZeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJOYXRpdmVGaWxlU3lzdGVtMiIsImV4cGlyeSI6MTU5MDU3MzM5MX0="
/>
<!-- General tags --> <!-- General tags -->
<meta <meta
name="description" name="description"
@@ -54,13 +66,10 @@
<!-- OG tags require absolute url for images --> <!-- OG tags require absolute url for images -->
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" /> <meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" /> <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
<link rel="stylesheet" href="fonts.css" type="text/css" />
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<link <link
rel="preload" rel="preload"
href="Virgil.woff2" href="FG_Virgil.woff2"
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin="anonymous" crossorigin="anonymous"
@@ -74,7 +83,7 @@
/> />
<link <link
href="%REACT_APP_SOCKET_SERVER_URL%/socket.io" href="https://excalidraw-socket.herokuapp.com/socket.io"
rel="preconnect" rel="preconnect"
crossorigin="anonymous" crossorigin="anonymous"
/> />
@@ -82,52 +91,12 @@
<link <link
rel="manifest" rel="manifest"
href="manifest.json" href="manifest.json"
style="--pwacompat-splash-font: 24px Virgil" style="--pwacompat-splash-font: 24px Virgil;"
/> />
<link rel="stylesheet" href="fonts.css" type="text/css" />
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
</script>
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
</script>
<% } %>
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
<style> <style>
body {
margin: 0;
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
-webkit-text-size-adjust: 100%;
width: 100vw;
height: 100vh;
}
.visually-hidden {
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; /* added line */
}
.LoadingMessage { .LoadingMessage {
position: absolute; position: fixed;
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
@@ -138,19 +107,40 @@
justify-content: center; justify-content: center;
pointer-events: none; pointer-events: none;
} }
.LoadingMessage span { .LoadingMessage span {
background-color: var(--button-gray-1); background-color: rgba(255, 255, 255, 0.8);
border-radius: 5px; border-radius: 5px;
padding: 0.8em 1.2em; padding: 0.8em 1.2em;
color: var(--popup-text-color);
font-size: 1.3em; font-size: 1.3em;
} }
.visually-hidden {
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; /* added line */
}
</style> </style>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=UA-387204-13"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "UA-387204-13");
</script>
</head> </head>
<body> <body>
<noscript> You need to enable JavaScript to run this app. </noscript> <noscript>
You need to enable JavaScript to run this app.
</noscript>
<header> <header>
<h1 class="visually-hidden">Excalidraw</h1> <h1 class="visually-hidden">Excalidraw</h1>
</header> </header>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -14,30 +14,8 @@
"sizes": "256x256" "sizes": "256x256"
} }
], ],
"start_url": "/", "start_url": ".",
"display": "standalone", "display": "standalone",
"theme_color": "#000000", "theme_color": "#000000",
"background_color": "#ffffff", "background_color": "#ffffff"
"file_handlers": [
{
"action": "/",
"accept": {
"application/vnd.excalidraw+json": [".excalidraw"]
}
}
],
"capture_links": "new_client",
"share_target": {
"action": "/web-share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"files": [
{
"name": "file",
"accept": ["application/vnd.excalidraw+json", "application/json", ".excalidraw"]
}
]
}
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +0,0 @@
const { readdirSync, writeFileSync } = require("fs");
const files = readdirSync(`${__dirname}/../src/locales`);
const flatten = (object) =>
Object.keys(object).reduce(
(initial, current) => ({ ...initial, ...object[current] }),
{},
);
const locales = files.filter(
(file) => file !== "README.md" && file !== "percentages.json",
);
const percentages = {};
for (let index = 0; index < locales.length; index++) {
const currentLocale = locales[index];
const data = flatten(require(`${__dirname}/../src/locales/${currentLocale}`));
const allKeys = Object.keys(data);
const translatedKeys = allKeys.filter((item) => data[item] !== "");
const percentage = (100 * translatedKeys.length) / allKeys.length;
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
}
writeFileSync(
`${__dirname}/../src/locales/percentages.json`,
`${JSON.stringify(percentages, null, 2)}\n`,
"utf8",
);

View File

@@ -5,13 +5,13 @@
// In order to run: // In order to run:
// npm install canvas # please do not check it in // npm install canvas # please do not check it in
// yarn build-node // npm run build-node
// node build/static/js/build-node.js // node build/static/js/build-node.js
// open test.png // open test.png
const rewire = require("rewire"); var rewire = require("rewire");
const defaults = rewire("react-scripts/scripts/build.js"); var defaults = rewire("react-scripts/scripts/build.js");
const config = defaults.__get__("config"); var config = defaults.__get__("config");
// Disable multiple chunks // Disable multiple chunks
config.optimization.runtimeChunk = false; config.optimization.runtimeChunk = false;
@@ -29,7 +29,7 @@ config.entry = "./src/index-node";
// By default, webpack is going to replace the require of the canvas.node file // By default, webpack is going to replace the require of the canvas.node file
// to just a string with the path of the canvas.node file. We need to tell // to just a string with the path of the canvas.node file. We need to tell
// webpack to avoid rewriting that dependency. // webpack to avoid rewriting that dependency.
config.externals = (context, request, callback) => { config.externals = function (context, request, callback) {
if (/\.node$/.test(request)) { if (/\.node$/.test(request)) {
return callback( return callback(
null, null,

View File

@@ -2,60 +2,36 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const versionFile = path.join("build", "version.json"); const asar = require("asar");
const indexFile = path.join("build", "index.html");
const versionDate = (date) => date.toISOString().replace(".000", ""); const zero = (digit) => `0${digit}`.slice(-2);
const commitHash = () => { const versionDate = (date) => {
try { const date_ = `${date.getFullYear()}-${zero(date.getMonth() + 1)}-${zero(
return require("child_process") date.getDate(),
.execSync("git rev-parse --short HEAD") )}`;
.toString() const time = `${zero(date.getHours())}-${zero(date.getMinutes())}-${zero(
.trim(); date.getSeconds(),
} catch { )}`;
return "none"; return `${date_}-${time}`;
}
}; };
const commitDate = (hash) => { const now = new Date();
try {
const unix = require("child_process")
.execSync(`git show -s --format=%ct ${hash}`)
.toString()
.trim();
const date = new Date(parseInt(unix) * 1000);
return versionDate(date);
} catch {
return versionDate(new Date());
}
};
const getFullVersion = () => {
const hash = commitHash();
return `${commitDate(hash)}-${hash}`;
};
const data = JSON.stringify( const data = JSON.stringify(
{ {
version: getFullVersion(), asar: `excalidraw.asar`,
version: versionDate(now),
}, },
undefined, undefined,
2, 2,
); );
fs.writeFileSync(versionFile, data); fs.writeFileSync(path.join("build", "version.json"), data);
// https://stackoverflow.com/a/14181136/8418 (async () => {
fs.readFile(indexFile, "utf8", (error, data) => { const src = "build/";
if (error) { const dest = path.join("build", `excalidraw.asar`);
return console.error(error);
}
const result = data.replace(/{version}/g, getFullVersion());
fs.writeFile(indexFile, result, "utf8", (error) => { await asar.createPackage(src, dest);
if (error) { })();
return console.error(error);
}
});
});

View File

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

View File

@@ -1,21 +0,0 @@
import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import { deepCopyElement } from "../element/newElement";
import { Library } from "../data/library";
export const actionAddToLibrary = register({
name: "addToLibrary",
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
Library.loadLibrary().then((items) => {
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
});
return false;
},
contextItemLabel: "labels.addToLibrary",
});

View File

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

View File

@@ -1,21 +1,17 @@
import React from "react"; import React from "react";
import { getDefaultAppState } from "../appState";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons"; import { getDefaultAppState } from "../appState";
import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import useIsMobile from "../is-mobile"; import { getNormalizedZoom, calculateScrollCenter } from "../scene";
import { CODES, KEYS } from "../keys"; import { KEYS } from "../keys";
import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getNewZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import useIsMobile from "../is-mobile";
import { register } from "./register"; import { register } from "./register";
import { newElementWith } from "../element/mutateElement";
import { AppState, FlooredNumber } from "../types";
import { getCommonBounds } from "../element";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@@ -48,14 +44,7 @@ export const actionClearCanvas = register({
), ),
appState: { appState: {
...getDefaultAppState(), ...getDefaultAppState(),
theme: appState.theme, username: appState.username,
elementLocked: appState.elementLocked,
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
shouldAddWatermark: appState.shouldAddWatermark,
showStats: appState.showStats,
pasteDialog: appState.pasteDialog,
}, },
commitToHistory: true, commitToHistory: true,
}; };
@@ -69,6 +58,10 @@ export const actionClearCanvas = register({
showAriaLabel={useIsMobile()} showAriaLabel={useIsMobile()}
onClick={() => { onClick={() => {
if (window.confirm(t("alerts.clearReset"))) { if (window.confirm(t("alerts.clearReset"))) {
// TODO: Defined globally, since file handles aren't yet serializable.
// Once `FileSystemFileHandle` can be serialized, make this
// part of `AppState`.
(window as any).handle = null;
updateData(null); updateData(null);
} }
}} }}
@@ -76,19 +69,25 @@ export const actionClearCanvas = register({
), ),
}); });
const ZOOM_STEP = 0.1;
const KEY_CODES = {
MINUS: "Minus",
EQUAL: "Equal",
ONE: "Digit1",
ZERO: "Digit0",
NUM_SUBTRACT: "NumpadSubtract",
NUM_ADD: "NumpadAdd",
NUM_ZERO: "Numpad0",
};
export const actionZoomIn = register({ export const actionZoomIn = register({
name: "zoomIn", name: "zoomIn",
perform: (_elements, appState) => { perform: (_elements, appState) => {
const zoom = getNewZoom(
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
appState.zoom,
{ left: appState.offsetLeft, top: appState.offsetTop },
{ x: appState.width / 2, y: appState.height / 2 },
);
return { return {
appState: { appState: {
...appState, ...appState,
zoom, zoom: getNormalizedZoom(appState.zoom + ZOOM_STEP),
}, },
commitToHistory: false, commitToHistory: false,
}; };
@@ -105,24 +104,17 @@ export const actionZoomIn = register({
/> />
), ),
keyTest: (event) => keyTest: (event) =>
(event.code === CODES.EQUAL || event.code === CODES.NUM_ADD) && (event.code === KEY_CODES.EQUAL || event.code === KEY_CODES.NUM_ADD) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey), (event[KEYS.CTRL_OR_CMD] || event.shiftKey),
}); });
export const actionZoomOut = register({ export const actionZoomOut = register({
name: "zoomOut", name: "zoomOut",
perform: (_elements, appState) => { perform: (_elements, appState) => {
const zoom = getNewZoom(
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
appState.zoom,
{ left: appState.offsetLeft, top: appState.offsetTop },
{ x: appState.width / 2, y: appState.height / 2 },
);
return { return {
appState: { appState: {
...appState, ...appState,
zoom, zoom: getNormalizedZoom(appState.zoom - ZOOM_STEP),
}, },
commitToHistory: false, commitToHistory: false,
}; };
@@ -139,7 +131,7 @@ export const actionZoomOut = register({
/> />
), ),
keyTest: (event) => keyTest: (event) =>
(event.code === CODES.MINUS || event.code === CODES.NUM_SUBTRACT) && (event.code === KEY_CODES.MINUS || event.code === KEY_CODES.NUM_SUBTRACT) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey), (event[KEYS.CTRL_OR_CMD] || event.shiftKey),
}); });
@@ -149,15 +141,7 @@ export const actionResetZoom = register({
return { return {
appState: { appState: {
...appState, ...appState,
zoom: getNewZoom( zoom: 1,
1 as NormalizedZoomValue,
appState.zoom,
{ left: appState.offsetLeft, top: appState.offsetTop },
{
x: appState.width / 2,
y: appState.height / 2,
},
),
}, },
commitToHistory: false, commitToHistory: false,
}; };
@@ -174,86 +158,66 @@ export const actionResetZoom = register({
/> />
), ),
keyTest: (event) => keyTest: (event) =>
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) && (event.code === KEY_CODES.ZERO || event.code === KEY_CODES.NUM_ZERO) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey), (event[KEYS.CTRL_OR_CMD] || event.shiftKey),
}); });
const zoomValueToFitBoundsOnViewport = ( const calculateZoom = (
bounds: [number, number, number, number], commonBounds: number[],
viewportDimensions: { width: number; height: number }, currentZoom: number,
) => { {
const [x1, y1, x2, y2] = bounds; scrollX,
const commonBoundsWidth = x2 - x1; scrollY,
const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth; }: {
const commonBoundsHeight = y2 - y1; scrollX: FlooredNumber;
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight; scrollY: FlooredNumber;
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight); },
const zoomAdjustedToSteps = ): number => {
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP; const { innerWidth, innerHeight } = window;
const clampedZoomValueToFitElements = Math.min( const [x, y] = commonBounds;
Math.max(zoomAdjustedToSteps, ZOOM_STEP), const zoomX = -innerWidth / (2 * scrollX + 2 * x - innerWidth);
1, const zoomY = -innerHeight / (2 * scrollY + 2 * y - innerHeight);
); const margin = 0.01;
return clampedZoomValueToFitElements as NormalizedZoomValue; let newZoom;
if (zoomX < zoomY) {
newZoom = zoomX - margin;
} else if (zoomY <= zoomX) {
newZoom = zoomY - margin;
} else {
newZoom = currentZoom;
}
if (newZoom <= 0.1) {
return 0.1;
}
if (newZoom >= 1) {
return 1;
}
return newZoom;
}; };
const zoomToFitElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
zoomToSelection: boolean,
) => {
const nonDeletedElements = getNonDeletedElements(elements);
const selectedElements = getSelectedElements(nonDeletedElements, appState);
const commonBounds =
zoomToSelection && selectedElements.length > 0
? getCommonBounds(selectedElements)
: getCommonBounds(nonDeletedElements);
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
});
const newZoom = getNewZoom(zoomValue, appState.zoom, {
left: appState.offsetLeft,
top: appState.offsetTop,
});
const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
return {
appState: {
...appState,
...centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: newZoom,
}),
zoom: newZoom,
},
commitToHistory: false,
};
};
export const actionZoomToSelected = register({
name: "zoomToSelection",
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
keyTest: (event) =>
event.code === CODES.TWO &&
event.shiftKey &&
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFit = register({ export const actionZoomToFit = register({
name: "zoomToFit", name: "zoomToFit",
perform: (elements, appState) => zoomToFitElements(elements, appState, false), perform: (elements, appState) => {
const nonDeletedElements = elements.filter((element) => !element.isDeleted);
const scrollCenter = calculateScrollCenter(nonDeletedElements);
const commonBounds = getCommonBounds(nonDeletedElements);
const zoom = calculateZoom(commonBounds, appState.zoom, scrollCenter);
return {
appState: {
...appState,
scrollX: scrollCenter.scrollX,
scrollY: scrollCenter.scrollY,
zoom,
},
commitToHistory: false,
};
},
keyTest: (event) => keyTest: (event) =>
event.code === CODES.ONE && event.code === KEY_CODES.ONE &&
event.shiftKey && event.shiftKey &&
!event.altKey && !event.altKey &&
!event[KEYS.CTRL_OR_CMD], !event[KEYS.CTRL_OR_CMD],

View File

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

View File

@@ -1,4 +1,4 @@
import { isSomeElementSelected } from "../scene"; import { deleteSelectedElements, isSomeElementSelected } from "../scene";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import React from "react"; import React from "react";
@@ -6,122 +6,14 @@ import { trash } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
return {
elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
return newElementWith(el, { isDeleted: true });
}
return el;
}),
appState: {
...appState,
selectedElementIds: {},
},
};
};
const handleGroupEditingState = (
appState: AppState,
elements: readonly ExcalidrawElement[],
): AppState => {
if (appState.editingGroupId) {
const siblingElements = getElementsInGroup(
getNonDeletedElements(elements),
appState.editingGroupId!,
);
if (siblingElements.length) {
return {
...appState,
selectedElementIds: { [siblingElements[0].id]: true },
};
}
}
return appState;
};
export const actionDeleteSelected = register({ export const actionDeleteSelected = register({
name: "deleteSelectedElements", name: "deleteSelectedElements",
perform: (elements, appState) => { perform: (elements, appState) => {
if (appState.editingLinearElement) { const {
const {
elementId,
activePointIndex,
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!element) {
return false;
}
if (
// case: no point selected → delete whole element
activePointIndex == null ||
activePointIndex === -1 ||
// case: deleting last remaining point
element.points.length < 2
) {
const nextElements = elements.filter((el) => el.id !== element.id);
const nextAppState = handleGroupEditingState(appState, nextElements);
return {
elements: nextElements,
appState: {
...nextAppState,
editingLinearElement: null,
},
commitToHistory: false,
};
}
// We cannot do this inside `movePoint` because it is also called
// when deleting the uncommitted point (which hasn't caused any binding)
const binding = {
startBindingElement:
activePointIndex === 0 ? null : startBindingElement,
endBindingElement:
activePointIndex === element.points.length - 1
? null
: endBindingElement,
};
LinearElementEditor.movePoint(element, activePointIndex, "delete");
return {
elements,
appState: {
...appState,
editingLinearElement: {
...appState.editingLinearElement,
...binding,
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
},
},
commitToHistory: true,
};
}
let {
elements: nextElements, elements: nextElements,
appState: nextAppState, appState: nextAppState,
} = deleteSelectedElements(elements, appState); } = deleteSelectedElements(elements, appState);
fixBindingsAfterDeletion(
nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]),
);
nextAppState = handleGroupEditingState(nextAppState, nextElements);
return { return {
elements: nextElements, elements: nextElements,
appState: { appState: {
@@ -136,6 +28,7 @@ export const actionDeleteSelected = register({
}; };
}, },
contextItemLabel: "labels.delete", contextItemLabel: "labels.delete",
contextMenuOrder: 3,
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE, keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton

View File

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

View File

@@ -8,63 +8,32 @@ import { ToolButton } from "../components/ToolButton";
import { clone } from "../components/icons"; import { clone } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement } from "../element/mutateElement";
import {
selectGroupsForSelectedElements,
getSelectedGroupForElement,
getElementsInGroup,
} from "../groups";
import { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import { ActionResult } from "./types";
import { GRID_SIZE } from "../constants";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
name: "duplicateSelection", name: "duplicateSelection",
perform: (elements, appState) => { perform: (elements, appState) => {
// duplicate point if selected while editing multi-point element
if (appState.editingLinearElement) {
const { activePointIndex, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!element || activePointIndex === null) {
return false;
}
const { points } = element;
const selectedPoint = points[activePointIndex];
const nextPoint = points[activePointIndex + 1];
mutateElement(element, {
points: [
...points.slice(0, activePointIndex + 1),
nextPoint
? [
(selectedPoint[0] + nextPoint[0]) / 2,
(selectedPoint[1] + nextPoint[1]) / 2,
]
: [selectedPoint[0] + 30, selectedPoint[1] + 30],
...points.slice(activePointIndex + 1),
],
});
return {
appState: {
...appState,
editingLinearElement: {
...appState.editingLinearElement,
activePointIndex: activePointIndex + 1,
},
},
elements,
commitToHistory: true,
};
}
return { return {
...duplicateElements(elements, appState), appState,
elements: elements.reduce(
(acc: Array<ExcalidrawElement>, element: ExcalidrawElement) => {
if (appState.selectedElementIds[element.id]) {
const newElement = duplicateElement(element, {
x: element.x + 10,
y: element.y + 10,
});
appState.selectedElementIds[newElement.id] = true;
delete appState.selectedElementIds[element.id];
return acc.concat([element, newElement]);
}
return acc.concat(element);
},
[],
),
commitToHistory: true, commitToHistory: true,
}; };
}, },
contextItemLabel: "labels.duplicateSelection", contextItemLabel: "labels.duplicateSelection",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === "d",
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
@@ -78,74 +47,3 @@ export const actionDuplicateSelection = register({
/> />
), ),
}); });
const duplicateElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
): Partial<ActionResult> => {
const groupIdMap = new Map();
const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map();
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
const newElement = duplicateElement(
appState.editingGroupId,
groupIdMap,
element,
{
x: element.x + GRID_SIZE / 2,
y: element.y + GRID_SIZE / 2,
},
);
oldIdToDuplicatedId.set(element.id, newElement.id);
oldElements.push(element);
newElements.push(newElement);
return newElement;
};
const finalElements: ExcalidrawElement[] = [];
let index = 0;
while (index < elements.length) {
const element = elements[index];
if (appState.selectedElementIds[element.id]) {
if (element.groupIds.length) {
const groupId = getSelectedGroupForElement(appState, element);
// if group selected, duplicate it atomically
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId);
finalElements.push(
...groupElements,
...groupElements.map((element) =>
duplicateAndOffsetElement(element),
),
);
index = index + groupElements.length;
continue;
}
}
finalElements.push(element, duplicateAndOffsetElement(element));
} else {
finalElements.push(element);
}
index++;
}
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
return {
elements: finalElements,
appState: selectGroupsForSelectedElements(
{
...appState,
selectedGroupIds: {},
selectedElementIds: newElements.reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as any),
},
getNonDeletedElements(finalElements),
),
};
};

View File

@@ -1,29 +1,23 @@
import React from "react"; import React from "react";
import { trackEvent } from "../analytics";
import { load, questionCircle, save, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName"; import { ProjectName } from "../components/ProjectName";
import { saveAsJSON, loadFromJSON } from "../data";
import { load, save } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import "../components/ToolIcon.scss";
import { Tooltip } from "../components/Tooltip";
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data";
import { t } from "../i18n"; import { t } from "../i18n";
import useIsMobile from "../is-mobile"; import useIsMobile from "../is-mobile";
import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { KEYS } from "../keys";
export const actionChangeProjectName = register({ export const actionChangeProjectName = register({
name: "changeProjectName", name: "changeProjectName",
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
trackEvent("change", "title");
return { appState: { ...appState, name: value }, commitToHistory: false }; return { appState: { ...appState, name: value }, commitToHistory: false };
}, },
PanelComponent: ({ appState, updateData, appProps }) => ( PanelComponent: ({ appState, updateData }) => (
<ProjectName <ProjectName
label={t("labels.fileTitle")} label={t("labels.fileTitle")}
value={appState.name || "Unnamed"} value={appState.name || "Unnamed"}
onChange={(name: string) => updateData(name)} onChange={(name: string) => updateData(name)}
isNameEditable={typeof appProps.name === "undefined"}
/> />
), ),
}); });
@@ -48,33 +42,6 @@ export const actionChangeExportBackground = register({
), ),
}); });
export const actionChangeExportEmbedScene = register({
name: "changeExportEmbedScene",
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportEmbedScene: value },
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
<label style={{ display: "flex" }}>
<input
type="checkbox"
checked={appState.exportEmbedScene}
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.exportEmbedScene")}
<Tooltip
label={t("labels.exportEmbedScene_details")}
position="above"
long={true}
>
<div className="TooltipIcon">{questionCircle}</div>
</Tooltip>
</label>
),
});
export const actionChangeShouldAddWatermark = register({ export const actionChangeShouldAddWatermark = register({
name: "changeShouldAddWatermark", name: "changeShouldAddWatermark",
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
@@ -97,34 +64,13 @@ export const actionChangeShouldAddWatermark = register({
export const actionSaveScene = register({ export const actionSaveScene = register({
name: "saveScene", name: "saveScene",
perform: async (elements, appState, value) => { perform: (elements, appState, value) => {
const fileHandleExists = !!appState.fileHandle; saveAsJSON(elements, appState).catch((error) => console.error(error));
try { return { commitToHistory: false };
const { fileHandle } = await saveAsJSON(elements, appState); },
return { keyTest: (event) => {
commitToHistory: false, return event.key === "s" && event[KEYS.CTRL_OR_CMD];
appState: {
...appState,
fileHandle,
toastMessage: fileHandleExists
? fileHandle.name
? t("toast.fileSavedToFilename").replace(
"{filename}",
`"${fileHandle.name}"`,
)
: t("toast.fileSaved")
: null,
},
};
} catch (error) {
if (error?.name !== "AbortError") {
console.error(error);
}
return { commitToHistory: false };
}
}, },
keyTest: (event) =>
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
@@ -137,100 +83,42 @@ export const actionSaveScene = register({
), ),
}); });
export const actionSaveAsScene = register({
name: "saveAsScene",
perform: async (elements, appState, value) => {
try {
const { fileHandle } = await saveAsJSON(elements, {
...appState,
fileHandle: null,
});
return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error) {
if (error?.name !== "AbortError") {
console.error(error);
}
return { commitToHistory: false };
}
},
keyTest: (event) =>
event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={saveAs}
title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")}
showAriaLabel={useIsMobile()}
hidden={
!("chooseFileSystemEntries" in window || "showOpenFilePicker" in window)
}
onClick={() => updateData(null)}
/>
),
});
export const actionLoadScene = register({ export const actionLoadScene = register({
name: "loadScene", name: "loadScene",
perform: async (elements, appState) => { perform: (
try { elements,
const { appState,
elements: loadedElements, { elements: loadedElements, appState: loadedAppState, error },
appState: loadedAppState, ) => {
} = await loadFromJSON(appState); return {
return { elements: loadedElements,
elements: loadedElements, appState: {
appState: loadedAppState, ...loadedAppState,
commitToHistory: true, errorMessage: error,
}; },
} catch (error) { commitToHistory: false,
if (error?.name === "AbortError") { };
return false;
}
return {
elements,
appState: { ...appState, errorMessage: error.message },
commitToHistory: false,
};
}
}, },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData, appState }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={load} icon={load}
title={t("buttons.load")} title={t("buttons.load")}
aria-label={t("buttons.load")} aria-label={t("buttons.load")}
showAriaLabel={useIsMobile()} showAriaLabel={useIsMobile()}
onClick={updateData} onClick={() => {
loadFromJSON()
.then(({ elements, appState }) => {
updateData({ elements: elements, appState: appState });
})
.catch((error) => {
// if user cancels, ignore the error
if (error.name === "AbortError") {
return;
}
updateData({ error: error.message });
});
}}
/> />
), ),
}); });
export const actionExportWithDarkMode = register({
name: "exportWithDarkMode",
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportWithDarkMode: value },
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginTop: "-45px",
marginBottom: "10px",
}}
>
<DarkModeToggle
value={appState.exportWithDarkMode ? "dark" : "light"}
onChange={(theme: Appearence) => {
updateData(theme === "dark");
}}
title={t("labels.toggleExportColorScheme")}
/>
</div>
),
});

View File

@@ -8,47 +8,10 @@ import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { isPathALoop } from "../math"; import { isPathALoop } from "../math";
import { LinearElementEditor } from "../element/linearElementEditor";
import Scene from "../scene/Scene";
import {
maybeBindLinearElement,
bindOrUnbindLinearElement,
} from "../element/binding";
import { isBindingElement } from "../element/typeChecks";
export const actionFinalize = register({ export const actionFinalize = register({
name: "finalize", name: "finalize",
perform: (elements, appState, _, { canvas }) => { perform: (elements, appState) => {
if (appState.editingLinearElement) {
const {
elementId,
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (element) {
if (isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
);
}
return {
elements:
element.points.length < 2 || isInvisiblySmallElement(element)
? elements.filter((el) => el.id !== element.id)
: undefined,
appState: {
...appState,
editingLinearElement: null,
},
commitToHistory: true,
};
}
}
let newElements = elements; let newElements = elements;
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
window.document.activeElement.blur(); window.document.activeElement.blur();
@@ -83,17 +46,16 @@ export const actionFinalize = register({
// If the multi point line closes the loop, // If the multi point line closes the loop,
// set the last point to first point. // set the last point to first point.
// This ensures that loop remains closed at different scales. // This ensures that loop remains closed at different scales.
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
if ( if (
multiPointElement.type === "line" || multiPointElement.type === "line" ||
multiPointElement.type === "draw" multiPointElement.type === "draw"
) { ) {
if (isLoop) { if (isPathALoop(multiPointElement.points)) {
const linePoints = multiPointElement.points; const linePoints = multiPointElement.points;
const firstPoint = linePoints[0]; const firstPoint = linePoints[0];
mutateElement(multiPointElement, { mutateElement(multiPointElement, {
points: linePoints.map((point, index) => points: linePoints.map((point, i) =>
index === linePoints.length - 1 i === linePoints.length - 1
? ([firstPoint[0], firstPoint[1]] as const) ? ([firstPoint[0], firstPoint[1]] as const)
: point, : point,
), ),
@@ -101,64 +63,39 @@ export const actionFinalize = register({
} }
} }
if ( if (!appState.elementLocked) {
isBindingElement(multiPointElement) &&
!isLoop &&
multiPointElement.points.length > 1
) {
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
multiPointElement,
-1,
);
maybeBindLinearElement(
multiPointElement,
appState,
Scene.getScene(multiPointElement)!,
{ x, y },
);
}
if (!appState.elementLocked && appState.elementType !== "draw") {
appState.selectedElementIds[multiPointElement.id] = true; appState.selectedElementIds[multiPointElement.id] = true;
} }
} }
if ( if (!appState.elementLocked || !multiPointElement) {
(!appState.elementLocked && appState.elementType !== "draw") || resetCursor();
!multiPointElement
) {
resetCursor(canvas);
} }
return { return {
elements: newElements, elements: newElements,
appState: { appState: {
...appState, ...appState,
elementType: elementType:
(appState.elementLocked || appState.elementType === "draw") && appState.elementLocked && multiPointElement
multiPointElement
? appState.elementType ? appState.elementType
: "selection", : "selection",
draggingElement: null, draggingElement: null,
multiElement: null, multiElement: null,
editingElement: null, editingElement: null,
startBoundElement: null,
suggestedBindings: [],
selectedElementIds: selectedElementIds:
multiPointElement && multiPointElement && !appState.elementLocked
!appState.elementLocked &&
appState.elementType !== "draw"
? { ? {
...appState.selectedElementIds, ...appState.selectedElementIds,
[multiPointElement.id]: true, [multiPointElement.id]: true,
} }
: appState.selectedElementIds, : appState.selectedElementIds,
}, },
commitToHistory: appState.elementType === "draw", commitToHistory: false,
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && (event.key === KEYS.ESCAPE &&
(appState.editingLinearElement !== null || !appState.draggingElement &&
(!appState.draggingElement && appState.multiElement === null))) || appState.multiElement === null) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (

View File

@@ -1,191 +0,0 @@
import React from "react";
import { CODES, KEYS } from "../keys";
import { t } from "../i18n";
import { getShortcutKey } from "../utils";
import { register } from "./register";
import { UngroupIcon, GroupIcon } from "../components/icons";
import { newElementWith } from "../element/mutateElement";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import {
getSelectedGroupIds,
selectGroup,
selectGroupsForSelectedElements,
getElementsInGroup,
addToGroup,
removeFromSelectedGroups,
isElementInGroup,
} from "../groups";
import { getNonDeletedElements } from "../element";
import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) {
const groupIds = elements[0].groupIds;
for (const groupId of groupIds) {
if (
elements.reduce(
(acc, element) => acc && isElementInGroup(element, groupId),
true,
)
) {
return true;
}
}
}
return false;
};
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
);
};
export const actionGroup = register({
name: "group",
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
if (selectedElements.length < 2) {
// nothing to group
return { appState, elements, commitToHistory: false };
}
// if everything is already grouped into 1 group, there is nothing to do
const selectedGroupIds = getSelectedGroupIds(appState);
if (selectedGroupIds.length === 1) {
const selectedGroupId = selectedGroupIds[0];
const elementIdsInGroup = new Set(
getElementsInGroup(elements, selectedGroupId).map(
(element) => element.id,
),
);
const selectedElementIds = new Set(
selectedElements.map((element) => element.id),
);
const combinedSet = new Set([
...Array.from(elementIdsInGroup),
...Array.from(selectedElementIds),
]);
if (combinedSet.size === elementIdsInGroup.size) {
// no incremental ids in the selected ids
return { appState, elements, commitToHistory: false };
}
}
const newGroupId = randomId();
const updatedElements = elements.map((element) => {
if (!appState.selectedElementIds[element.id]) {
return element;
}
return newElementWith(element, {
groupIds: addToGroup(
element.groupIds,
newGroupId,
appState.editingGroupId,
),
});
});
// keep the z order within the group the same, but move them
// to the z order of the highest element in the layer stack
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex = updatedElements.lastIndexOf(
lastElementInGroup,
);
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = updatedElements
.slice(0, lastGroupElementIndex)
.filter(
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
);
const updatedElementsInOrder = [
...elementsBeforeGroup,
...elementsInGroup,
...elementsAfterGroup,
];
return {
appState: selectGroup(
newGroupId,
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(updatedElementsInOrder),
),
elements: updatedElementsInOrder,
commitToHistory: true,
};
},
contextItemLabel: "labels.group",
contextItemPredicate: (elements, appState) =>
enableActionGroup(elements, appState),
keyTest: (event) =>
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={<GroupIcon theme={appState.theme} />}
onClick={() => updateData(null)}
title={`${t("labels.group")}${getShortcutKey("CtrlOrCmd+G")}`}
aria-label={t("labels.group")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
></ToolButton>
),
});
export const actionUngroup = register({
name: "ungroup",
perform: (elements, appState) => {
const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false };
}
const nextElements = elements.map((element) => {
const nextGroupIds = removeFromSelectedGroups(
element.groupIds,
appState.selectedGroupIds,
);
if (nextGroupIds.length === element.groupIds.length) {
return element;
}
return newElementWith(element, {
groupIds: nextGroupIds,
});
});
return {
appState: selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
),
elements: nextElements,
commitToHistory: true,
};
},
keyTest: (event) =>
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
contextItemLabel: "labels.ungroup",
contextItemPredicate: (elements, appState) =>
getSelectedGroupIds(appState).length > 0,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
type="button"
hidden={getSelectedGroupIds(appState).length === 0}
icon={<UngroupIcon theme={appState.theme} />}
onClick={() => updateData(null)}
title={`${t("labels.ungroup")}${getShortcutKey("CtrlOrCmd+Shift+G")}`}
aria-label={t("labels.ungroup")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
></ToolButton>
),
});

View File

@@ -3,18 +3,20 @@ import React from "react";
import { undo, redo } from "../components/icons"; import { undo, redo } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { SceneHistory, HistoryEntry } from "../history"; import { SceneHistory } from "../history";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { isWindows, KEYS } from "../keys"; import { KEYS } from "../keys";
import { getElementMap } from "../element"; import { getElementMap } from "../element";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
const writeData = ( const writeData = (
prevElements: readonly ExcalidrawElement[], prevElements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
updater: () => HistoryEntry | null, updater: () => {
elements: ExcalidrawElement[];
appState: AppState;
} | null,
): ActionResult => { ): ActionResult => {
const commitToHistory = false; const commitToHistory = false;
if ( if (
@@ -31,44 +33,40 @@ const writeData = (
const prevElementMap = getElementMap(prevElements); const prevElementMap = getElementMap(prevElements);
const nextElements = data.elements; const nextElements = data.elements;
const nextElementMap = getElementMap(nextElements); const nextElementMap = getElementMap(nextElements);
const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
);
const elements = nextElements
.map((nextElement) =>
newElementWith(
prevElementMap[nextElement.id] || nextElement,
nextElement,
),
)
.concat(
deletedElements.map((prevElement) =>
newElementWith(prevElement, { isDeleted: true }),
),
);
fixBindingsAfterDeletion(elements, deletedElements);
return { return {
elements, elements: nextElements
.map((nextElement) =>
newElementWith(
prevElementMap[nextElement.id] || nextElement,
nextElement,
),
)
.concat(
prevElements
.filter(
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
)
.map((prevElement) =>
newElementWith(prevElement, { isDeleted: true }),
),
),
appState: { ...appState, ...data.appState }, appState: { ...appState, ...data.appState },
commitToHistory, commitToHistory,
syncHistory: true,
}; };
} }
return { commitToHistory }; return { commitToHistory };
}; };
const testUndo = (shift: boolean) => (event: KeyboardEvent) =>
event[KEYS.CTRL_OR_CMD] && /z/i.test(event.key) && event.shiftKey === shift;
type ActionCreator = (history: SceneHistory) => Action; type ActionCreator = (history: SceneHistory) => Action;
export const createUndoAction: ActionCreator = (history) => ({ export const createUndoAction: ActionCreator = (history) => ({
name: "undo", name: "undo",
perform: (elements, appState) => perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()), writeData(elements, appState, () => history.undoOnce()),
keyTest: (event) => keyTest: testUndo(false),
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
@@ -84,11 +82,7 @@ export const createRedoAction: ActionCreator = (history) => ({
name: "redo", name: "redo",
perform: (elements, appState) => perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()), writeData(elements, appState, () => history.redoOnce()),
keyTest: (event) => keyTest: testUndo(true),
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"

View File

@@ -5,7 +5,7 @@ import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element"; import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register"; import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils"; import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { CODES, KEYS } from "../keys"; import { KEYS } from "../keys";
import { HelpIcon } from "../components/HelpIcon"; import { HelpIcon } from "../components/HelpIcon";
export const actionToggleCanvasMenu = register({ export const actionToggleCanvasMenu = register({
@@ -65,7 +65,7 @@ export const actionFullScreen = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
keyTest: (event) => event.code === CODES.F && !event[KEYS.CTRL_OR_CMD], keyTest: (event) => event.keyCode === KEYS.F_KEY_CODE,
}); });
export const actionShortcuts = register({ export const actionShortcuts = register({
@@ -74,13 +74,13 @@ export const actionShortcuts = register({
return { return {
appState: { appState: {
...appState, ...appState,
showHelpDialog: !appState.showHelpDialog, showShortcutsDialog: true,
}, },
commitToHistory: false, commitToHistory: false,
}; };
}, },
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<HelpIcon title={t("helpDialog.title")} onClick={updateData} /> <HelpIcon title={t("shortcutsDialog.title")} onClick={updateData} />
), ),
keyTest: (event) => event.key === KEYS.QUESTION_MARK, keyTest: (event) => event.key === KEYS.QUESTION_MARK,
}); });

View File

@@ -1,58 +0,0 @@
import React from "react";
import { getClientColors, getClientInitials } from "../clients";
import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll";
import { Collaborator } from "../types";
import { register } from "./register";
export const actionGoToCollaborator = register({
name: "goToCollaborator",
perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"];
if (!point) {
return { appState, commitToHistory: false };
}
return {
appState: {
...appState,
...centerScrollOn({
scenePoint: point,
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: appState.zoom,
}),
// Close mobile menu
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
},
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData, id }) => {
const clientId = id;
if (!clientId) {
return null;
}
const collaborator = appState.collaborators.get(clientId);
if (!collaborator) {
return null;
}
const { background, stroke } = getClientColors(clientId, appState);
const shortName = getClientInitials(collaborator.username);
return (
<Avatar
color={background}
border={stroke}
onClick={() => updateData(collaborator.pointer)}
>
{shortName}
</Avatar>
);
},
});

View File

@@ -1,53 +1,25 @@
import React from "react"; import React from "react";
import { AppState } from "../../src/types";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ButtonSelect } from "../components/ButtonSelect";
import { ColorPicker } from "../components/ColorPicker";
import { IconPicker } from "../components/IconPicker";
import { import {
ArrowheadArrowIcon,
ArrowheadBarIcon,
ArrowheadDotIcon,
ArrowheadNoneIcon,
EdgeRoundIcon,
EdgeSharpIcon,
FillCrossHatchIcon,
FillHachureIcon,
FillSolidIcon,
SloppinessArchitectIcon,
SloppinessArtistIcon,
SloppinessCartoonistIcon,
StrokeStyleDashedIcon,
StrokeStyleDottedIcon,
StrokeStyleSolidIcon,
StrokeWidthIcon,
} from "../components/icons";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
import {
getNonDeletedElements,
isTextElement,
redrawTextBoundingBox,
} from "../element";
import { newElementWith } from "../element/mutateElement";
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
import {
Arrowhead,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FontFamily,
TextAlign, TextAlign,
} from "../element/types"; } from "../element/types";
import { getLanguage, t } from "../i18n";
import { randomInteger } from "../random";
import { import {
canChangeSharpness,
canHaveArrowheads,
getCommonAttributeOfSelectedElements, getCommonAttributeOfSelectedElements,
getTargetElements,
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import { ButtonSelect } from "../components/ButtonSelect";
import {
isTextElement,
redrawTextBoundingBox,
getNonDeletedElements,
} from "../element";
import { ColorPicker } from "../components/ColorPicker";
import { AppState } from "../../src/types";
import { t } from "../i18n";
import { DEFAULT_FONT } from "../appState";
import { register } from "./register"; import { register } from "./register";
import { newElementWith } from "../element/mutateElement";
const changeProperty = ( const changeProperty = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -164,23 +136,11 @@ export const actionChangeFillStyle = register({
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
<legend>{t("labels.fill")}</legend> <legend>{t("labels.fill")}</legend>
<ButtonIconSelect <ButtonSelect
options={[ options={[
{ { value: "hachure", text: t("labels.hachure") },
value: "hachure", { value: "cross-hatch", text: t("labels.crossHatch") },
text: t("labels.hachure"), { value: "solid", text: t("labels.solid") },
icon: <FillHachureIcon theme={appState.theme} />,
},
{
value: "cross-hatch",
text: t("labels.crossHatch"),
icon: <FillCrossHatchIcon theme={appState.theme} />,
},
{
value: "solid",
text: t("labels.solid"),
icon: <FillSolidIcon theme={appState.theme} />,
},
]} ]}
group="fill" group="fill"
value={getFormValue( value={getFormValue(
@@ -213,24 +173,12 @@ export const actionChangeStrokeWidth = register({
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
<legend>{t("labels.strokeWidth")}</legend> <legend>{t("labels.strokeWidth")}</legend>
<ButtonIconSelect <ButtonSelect
group="stroke-width" group="stroke-width"
options={[ options={[
{ { value: 1, text: t("labels.thin") },
value: 1, { value: 2, text: t("labels.bold") },
text: t("labels.thin"), { value: 4, text: t("labels.extraBold") },
icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={2} />,
},
{
value: 2,
text: t("labels.bold"),
icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={6} />,
},
{
value: 4,
text: t("labels.extraBold"),
icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={10} />,
},
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
@@ -250,7 +198,6 @@ export const actionChangeSloppiness = register({
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
newElementWith(el, { newElementWith(el, {
seed: randomInteger(),
roughness: value, roughness: value,
}), }),
), ),
@@ -261,24 +208,12 @@ export const actionChangeSloppiness = register({
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
<legend>{t("labels.sloppiness")}</legend> <legend>{t("labels.sloppiness")}</legend>
<ButtonIconSelect <ButtonSelect
group="sloppiness" group="sloppiness"
options={[ options={[
{ { value: 0, text: t("labels.architect") },
value: 0, { value: 1, text: t("labels.artist") },
text: t("labels.architect"), { value: 2, text: t("labels.cartoonist") },
icon: <SloppinessArchitectIcon theme={appState.theme} />,
},
{
value: 1,
text: t("labels.artist"),
icon: <SloppinessArtistIcon theme={appState.theme} />,
},
{
value: 2,
text: t("labels.cartoonist"),
icon: <SloppinessCartoonistIcon theme={appState.theme} />,
},
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
@@ -292,53 +227,6 @@ export const actionChangeSloppiness = register({
), ),
}); });
export const actionChangeStrokeStyle = register({
name: "changeStrokeStyle",
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeStyle: value,
}),
),
appState: { ...appState, currentItemStrokeStyle: value },
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.strokeStyle")}</legend>
<ButtonIconSelect
group="strokeStyle"
options={[
{
value: "solid",
text: t("labels.strokeStyle_solid"),
icon: <StrokeStyleSolidIcon theme={appState.theme} />,
},
{
value: "dashed",
text: t("labels.strokeStyle_dashed"),
icon: <StrokeStyleDashedIcon theme={appState.theme} />,
},
{
value: "dotted",
text: t("labels.strokeStyle_dotted"),
icon: <StrokeStyleDottedIcon theme={appState.theme} />,
},
]}
value={getFormValue(
elements,
appState,
(element) => element.strokeStyle,
appState.currentItemStrokeStyle,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
),
});
export const actionChangeOpacity = register({ export const actionChangeOpacity = register({
name: "changeOpacity", name: "changeOpacity",
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
@@ -395,7 +283,7 @@ export const actionChangeFontSize = register({
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) => {
if (isTextElement(el)) { if (isTextElement(el)) {
const element: ExcalidrawTextElement = newElementWith(el, { const element: ExcalidrawTextElement = newElementWith(el, {
fontSize: value, font: `${value}px ${el.font.split("px ")[1]}`,
}); });
redrawTextBoundingBox(element); redrawTextBoundingBox(element);
return element; return element;
@@ -405,7 +293,9 @@ export const actionChangeFontSize = register({
}), }),
appState: { appState: {
...appState, ...appState,
currentItemFontSize: value, currentItemFont: `${value}px ${
appState.currentItemFont.split("px ")[1]
}`,
}, },
commitToHistory: true, commitToHistory: true,
}; };
@@ -424,8 +314,8 @@ export const actionChangeFontSize = register({
value={getFormValue( value={getFormValue(
elements, elements,
appState, appState,
(element) => isTextElement(element) && element.fontSize, (element) => isTextElement(element) && +element.font.split("px ")[0],
appState.currentItemFontSize || DEFAULT_FONT_SIZE, +(appState.currentItemFont || DEFAULT_FONT).split("px ")[0],
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -440,7 +330,7 @@ export const actionChangeFontFamily = register({
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) => {
if (isTextElement(el)) { if (isTextElement(el)) {
const element: ExcalidrawTextElement = newElementWith(el, { const element: ExcalidrawTextElement = newElementWith(el, {
fontFamily: value, font: `${el.font.split("px ")[0]}px ${value}`,
}); });
redrawTextBoundingBox(element); redrawTextBoundingBox(element);
return element; return element;
@@ -450,35 +340,33 @@ export const actionChangeFontFamily = register({
}), }),
appState: { appState: {
...appState, ...appState,
currentItemFontFamily: value, currentItemFont: `${
appState.currentItemFont.split("px ")[0]
}px ${value}`,
}, },
commitToHistory: true, commitToHistory: true,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => (
const options: { value: FontFamily; text: string }[] = [ <fieldset>
{ value: 1, text: t("labels.handDrawn") }, <legend>{t("labels.fontFamily")}</legend>
{ value: 2, text: t("labels.normal") }, <ButtonSelect
{ value: 3, text: t("labels.code") }, group="font-family"
]; options={[
{ value: "Virgil", text: t("labels.handDrawn") },
return ( { value: "Helvetica", text: t("labels.normal") },
<fieldset> { value: "Cascadia", text: t("labels.code") },
<legend>{t("labels.fontFamily")}</legend> ]}
<ButtonSelect<FontFamily | false> value={getFormValue(
group="font-family" elements,
options={options} appState,
value={getFormValue( (element) => isTextElement(element) && element.font.split("px ")[1],
elements, (appState.currentItemFont || DEFAULT_FONT).split("px ")[1],
appState, )}
(element) => isTextElement(element) && element.fontFamily, onChange={(value) => updateData(value)}
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY, />
)} </fieldset>
onChange={(value) => updateData(value)} ),
/>
</fieldset>
);
},
}); });
export const actionChangeTextAlign = register({ export const actionChangeTextAlign = register({
@@ -524,199 +412,3 @@ export const actionChangeTextAlign = register({
</fieldset> </fieldset>
), ),
}); });
export const actionChangeSharpness = register({
name: "changeSharpness",
perform: (elements, appState, value) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
appState,
);
const shouldUpdateForNonLinearElements = targetElements.length
? targetElements.every((el) => !isLinearElement(el))
: !isLinearElementType(appState.elementType);
const shouldUpdateForLinearElements = targetElements.length
? targetElements.every(isLinearElement)
: isLinearElementType(appState.elementType);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeSharpness: value,
}),
),
appState: {
...appState,
currentItemStrokeSharpness: shouldUpdateForNonLinearElements
? value
: appState.currentItemStrokeSharpness,
currentItemLinearStrokeSharpness: shouldUpdateForLinearElements
? value
: appState.currentItemLinearStrokeSharpness,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.edges")}</legend>
<ButtonIconSelect
group="edges"
options={[
{
value: "sharp",
text: t("labels.sharp"),
icon: <EdgeSharpIcon theme={appState.theme} />,
},
{
value: "round",
text: t("labels.round"),
icon: <EdgeRoundIcon theme={appState.theme} />,
},
]}
value={getFormValue(
elements,
appState,
(element) => element.strokeSharpness,
(canChangeSharpness(appState.elementType) &&
(isLinearElementType(appState.elementType)
? appState.currentItemLinearStrokeSharpness
: appState.currentItemStrokeSharpness)) ||
null,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
),
});
export const actionChangeArrowhead = register({
name: "changeArrowhead",
perform: (
elements,
appState,
value: { position: "start" | "end"; type: Arrowhead },
) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (isLinearElement(el)) {
const { position, type } = value;
if (position === "start") {
const element: ExcalidrawLinearElement = newElementWith(el, {
startArrowhead: type,
});
return element;
} else if (position === "end") {
const element: ExcalidrawLinearElement = newElementWith(el, {
endArrowhead: type,
});
return element;
}
}
return el;
}),
appState: {
...appState,
[value.position === "start"
? "currentItemStartArrowhead"
: "currentItemEndArrowhead"]: value.type,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
const isRTL = getLanguage().rtl;
return (
<fieldset>
<legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList">
<IconPicker
label="arrowhead_start"
options={[
{
value: null,
text: t("labels.arrowhead_none"),
icon: <ArrowheadNoneIcon theme={appState.theme} />,
keyBinding: "q",
},
{
value: "arrow",
text: t("labels.arrowhead_arrow"),
icon: (
<ArrowheadArrowIcon theme={appState.theme} flip={!isRTL} />
),
keyBinding: "w",
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
icon: <ArrowheadBarIcon theme={appState.theme} flip={!isRTL} />,
keyBinding: "e",
},
{
value: "dot",
text: t("labels.arrowhead_dot"),
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
keyBinding: "r",
},
]}
value={getFormValue<Arrowhead | null>(
elements,
appState,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead
: appState.currentItemStartArrowhead,
appState.currentItemStartArrowhead,
)}
onChange={(value) => updateData({ position: "start", type: value })}
/>
<IconPicker
label="arrowhead_end"
group="arrowheads"
options={[
{
value: null,
text: t("labels.arrowhead_none"),
keyBinding: "q",
icon: <ArrowheadNoneIcon theme={appState.theme} />,
},
{
value: "arrow",
text: t("labels.arrowhead_arrow"),
keyBinding: "w",
icon: (
<ArrowheadArrowIcon theme={appState.theme} flip={isRTL} />
),
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "e",
icon: <ArrowheadBarIcon theme={appState.theme} flip={isRTL} />,
},
{
value: "dot",
text: t("labels.arrowhead_dot"),
keyBinding: "r",
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
},
]}
value={getFormValue<Arrowhead | null>(
elements,
appState,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead
: appState.currentItemEndArrowhead,
appState.currentItemEndArrowhead,
)}
onChange={(value) => updateData({ position: "end", type: value })}
/>
</div>
</fieldset>
);
},
});

View File

@@ -1,31 +1,22 @@
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { selectGroupsForSelectedElements } from "../groups";
import { getNonDeletedElements } from "../element";
export const actionSelectAll = register({ export const actionSelectAll = register({
name: "selectAll", name: "selectAll",
perform: (elements, appState) => { perform: (elements, appState) => {
if (appState.editingLinearElement) {
return false;
}
return { return {
appState: selectGroupsForSelectedElements( appState: {
{ ...appState,
...appState, selectedElementIds: elements.reduce((map, element) => {
editingGroupId: null, if (!element.isDeleted) {
selectedElementIds: elements.reduce((map, element) => { map[element.id] = true;
if (!element.isDeleted) { }
map[element.id] = true; return map;
} }, {} as any),
return map; },
}, {} as any),
},
getNonDeletedElements(elements),
),
commitToHistory: true, commitToHistory: true,
}; };
}, },
contextItemLabel: "labels.selectAll", contextItemLabel: "labels.selectAll",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === "a",
}); });

View File

@@ -3,18 +3,12 @@ import {
isExcalidrawElement, isExcalidrawElement,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element"; } from "../element";
import { CODES, KEYS } from "../keys"; import { KEYS } from "../keys";
import { t } from "../i18n"; import { DEFAULT_FONT, DEFAULT_TEXT_ALIGN } from "../appState";
import { register } from "./register"; import { register } from "./register";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
} from "../constants";
// `copiedStyles` is exported only for tests. let copiedStyles: string = "{}";
export let copiedStyles: string = "{}";
export const actionCopyStyles = register({ export const actionCopyStyles = register({
name: "copyStyles", name: "copyStyles",
@@ -24,16 +18,13 @@ export const actionCopyStyles = register({
copiedStyles = JSON.stringify(element); copiedStyles = JSON.stringify(element);
} }
return { return {
appState: {
...appState,
toastMessage: t("toast.copyStyles"),
},
commitToHistory: false, commitToHistory: false,
}; };
}, },
contextItemLabel: "labels.copyStyles", contextItemLabel: "labels.copyStyles",
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "C",
contextMenuOrder: 0,
}); });
export const actionPasteStyles = register({ export const actionPasteStyles = register({
@@ -50,15 +41,13 @@ export const actionPasteStyles = register({
backgroundColor: pastedElement?.backgroundColor, backgroundColor: pastedElement?.backgroundColor,
strokeWidth: pastedElement?.strokeWidth, strokeWidth: pastedElement?.strokeWidth,
strokeColor: pastedElement?.strokeColor, strokeColor: pastedElement?.strokeColor,
strokeStyle: pastedElement?.strokeStyle,
fillStyle: pastedElement?.fillStyle, fillStyle: pastedElement?.fillStyle,
opacity: pastedElement?.opacity, opacity: pastedElement?.opacity,
roughness: pastedElement?.roughness, roughness: pastedElement?.roughness,
}); });
if (isTextElement(newElement)) { if (isTextElement(newElement)) {
mutateElement(newElement, { mutateElement(newElement, {
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE, font: pastedElement?.font || DEFAULT_FONT,
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN, textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
}); });
redrawTextBoundingBox(newElement); redrawTextBoundingBox(newElement);
@@ -72,5 +61,6 @@ export const actionPasteStyles = register({
}, },
contextItemLabel: "labels.pasteStyles", contextItemLabel: "labels.pasteStyles",
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "V",
contextMenuOrder: 1,
}); });

View File

@@ -1,22 +0,0 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import { AppState } from "../types";
import { trackEvent } from "../analytics";
export const actionToggleGridMode = register({
name: "gridMode",
perform(elements, appState) {
trackEvent("view", "mode", "grid");
return {
appState: {
...appState,
gridSize: this.checked!(appState) ? null : GRID_SIZE,
},
commitToHistory: false,
};
},
checked: (appState: AppState) => appState.gridSize !== null,
contextItemLabel: "labels.showGrid",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});

View File

@@ -1,16 +0,0 @@
import { register } from "./register";
export const actionToggleStats = register({
name: "stats",
perform(elements, appState) {
return {
appState: {
...appState,
showStats: !this.checked!(appState),
},
commitToHistory: false,
};
},
checked: (appState) => appState.showStats,
contextItemLabel: "stats.title",
});

View File

@@ -1,22 +0,0 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleViewMode = register({
name: "viewMode",
perform(elements, appState) {
trackEvent("view", "mode", "view");
return {
appState: {
...appState,
viewModeEnabled: !this.checked!(appState),
selectedElementIds: {},
},
commitToHistory: false,
};
},
checked: (appState) => appState.viewModeEnabled,
contextItemLabel: "labels.viewMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
});

View File

@@ -1,22 +0,0 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleZenMode = register({
name: "zenMode",
perform(elements, appState) {
trackEvent("view", "mode", "zen");
return {
appState: {
...appState,
zenModeEnabled: !this.checked!(appState),
},
commitToHistory: false,
};
},
checked: (appState) => appState.zenModeEnabled,
contextItemLabel: "buttons.zenMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
});

View File

@@ -5,22 +5,79 @@ import {
moveAllLeft, moveAllLeft,
moveAllRight, moveAllRight,
} from "../zindex"; } from "../zindex";
import { KEYS, isDarwin, CODES } from "../keys"; import { KEYS, isDarwin } from "../keys";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { import {
SendBackwardIcon, sendBackward,
BringToFrontIcon, bringToFront,
SendToBackIcon, sendToBack,
BringForwardIcon, bringForward,
} from "../components/icons"; } from "../components/icons";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
function getElementIndices(
direction: "left" | "right",
elements: readonly ExcalidrawElement[],
appState: AppState,
) {
const selectedIndices: number[] = [];
let deletedIndicesCache: number[] = [];
function cb(element: ExcalidrawElement, index: number) {
if (element.isDeleted) {
// we want to build an array of deleted elements that are preceeding
// a selected element so that we move them together
deletedIndicesCache.push(index);
} else {
if (appState.selectedElementIds[element.id]) {
selectedIndices.push(...deletedIndicesCache, index);
}
// always empty cache of deleted elements after either pushing a group
// of selected/deleted elements, of after encountering non-deleted elem
deletedIndicesCache = [];
}
}
// sending back → select contiguous deleted elements that are to the left of
// selected element(s)
if (direction === "left") {
let i = -1;
const len = elements.length;
while (++i < len) {
cb(elements[i], i);
}
// moving to front → loop from right to left so that we don't need to
// backtrack when gathering deleted elements
} else {
let i = elements.length;
while (--i > -1) {
cb(elements[i], i);
}
}
// sort in case we were gathering indexes from right to left
return selectedIndices.sort();
}
function moveElements(
func: typeof moveOneLeft,
elements: readonly ExcalidrawElement[],
appState: AppState,
) {
const _elements = elements.slice();
const direction =
func === moveOneLeft || func === moveAllLeft ? "left" : "right";
const indices = getElementIndices(direction, _elements, appState);
return func(_elements, indices);
}
export const actionSendBackward = register({ export const actionSendBackward = register({
name: "sendBackward", name: "sendBackward",
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveOneLeft(elements, appState), elements: moveElements(moveOneLeft, elements, appState),
appState, appState,
commitToHistory: true, commitToHistory: true,
}; };
@@ -28,17 +85,15 @@ export const actionSendBackward = register({
contextItemLabel: "labels.sendBackward", contextItemLabel: "labels.sendBackward",
keyPriority: 40, keyPriority: 40,
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] && !event.shiftKey && event.code === "BracketLeft",
!event.shiftKey && PanelComponent: ({ updateData }) => (
event.code === CODES.BRACKET_LEFT,
PanelComponent: ({ updateData, appState }) => (
<button <button
type="button" type="button"
className="zIndexButton" className="zIndexButton"
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.sendBackward")}${getShortcutKey("CtrlOrCmd+[")}`} title={`${t("labels.sendBackward")}${getShortcutKey("CtrlOrCmd+[")}`}
> >
<SendBackwardIcon theme={appState.theme} /> {sendBackward}
</button> </button>
), ),
}); });
@@ -47,7 +102,7 @@ export const actionBringForward = register({
name: "bringForward", name: "bringForward",
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveOneRight(elements, appState), elements: moveElements(moveOneRight, elements, appState),
appState, appState,
commitToHistory: true, commitToHistory: true,
}; };
@@ -55,17 +110,15 @@ export const actionBringForward = register({
contextItemLabel: "labels.bringForward", contextItemLabel: "labels.bringForward",
keyPriority: 40, keyPriority: 40,
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] && !event.shiftKey && event.code === "BracketRight",
!event.shiftKey && PanelComponent: ({ updateData }) => (
event.code === CODES.BRACKET_RIGHT,
PanelComponent: ({ updateData, appState }) => (
<button <button
type="button" type="button"
className="zIndexButton" className="zIndexButton"
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.bringForward")}${getShortcutKey("CtrlOrCmd+]")}`} title={`${t("labels.bringForward")}${getShortcutKey("CtrlOrCmd+]")}`}
> >
<BringForwardIcon theme={appState.theme} /> {bringForward}
</button> </button>
), ),
}); });
@@ -74,21 +127,20 @@ export const actionSendToBack = register({
name: "sendToBack", name: "sendToBack",
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveAllLeft(elements, appState), elements: moveElements(moveAllLeft, elements, appState),
appState, appState,
commitToHistory: true, commitToHistory: true,
}; };
}, },
contextItemLabel: "labels.sendToBack", contextItemLabel: "labels.sendToBack",
keyTest: (event) => keyTest: (event) => {
isDarwin return isDarwin
? event[KEYS.CTRL_OR_CMD] && ? event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === "BracketLeft"
event.altKey &&
event.code === CODES.BRACKET_LEFT
: event[KEYS.CTRL_OR_CMD] && : event[KEYS.CTRL_OR_CMD] &&
event.shiftKey && event.shiftKey &&
event.code === CODES.BRACKET_LEFT, event.code === "BracketLeft";
PanelComponent: ({ updateData, appState }) => ( },
PanelComponent: ({ updateData }) => (
<button <button
type="button" type="button"
className="zIndexButton" className="zIndexButton"
@@ -99,7 +151,7 @@ export const actionSendToBack = register({
: getShortcutKey("CtrlOrCmd+Shift+[") : getShortcutKey("CtrlOrCmd+Shift+[")
}`} }`}
> >
<SendToBackIcon theme={appState.theme} /> {sendToBack}
</button> </button>
), ),
}); });
@@ -108,21 +160,20 @@ export const actionBringToFront = register({
name: "bringToFront", name: "bringToFront",
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveAllRight(elements, appState), elements: moveElements(moveAllRight, elements, appState),
appState, appState,
commitToHistory: true, commitToHistory: true,
}; };
}, },
contextItemLabel: "labels.bringToFront", contextItemLabel: "labels.bringToFront",
keyTest: (event) => keyTest: (event) => {
isDarwin return isDarwin
? event[KEYS.CTRL_OR_CMD] && ? event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === "BracketRight"
event.altKey &&
event.code === CODES.BRACKET_RIGHT
: event[KEYS.CTRL_OR_CMD] && : event[KEYS.CTRL_OR_CMD] &&
event.shiftKey && event.shiftKey &&
event.code === CODES.BRACKET_RIGHT, event.code === "BracketRight";
PanelComponent: ({ updateData, appState }) => ( },
PanelComponent: ({ updateData }) => (
<button <button
type="button" type="button"
className="zIndexButton" className="zIndexButton"
@@ -133,7 +184,7 @@ export const actionBringToFront = register({
: getShortcutKey("CtrlOrCmd+Shift+]") : getShortcutKey("CtrlOrCmd+Shift+]")
}`} }`}
> >
<BringToFrontIcon theme={appState.theme} /> {bringToFront}
</button> </button>
), ),
}); });

View File

@@ -34,7 +34,6 @@ export {
actionChangeProjectName, actionChangeProjectName,
actionChangeExportBackground, actionChangeExportBackground,
actionSaveScene, actionSaveScene,
actionSaveAsScene,
actionLoadScene, actionLoadScene,
} from "./actionExport"; } from "./actionExport";
@@ -45,35 +44,3 @@ export {
actionFullScreen, actionFullScreen,
actionShortcuts, actionShortcuts,
} from "./actionMenu"; } from "./actionMenu";
export { actionGroup, actionUngroup } from "./actionGroup";
export { actionGoToCollaborator } from "./actionNavigate";
export { actionAddToLibrary } from "./actionAddToLibrary";
export {
actionAlignTop,
actionAlignBottom,
actionAlignLeft,
actionAlignRight,
actionAlignVerticallyCentered,
actionAlignHorizontallyCentered,
} from "./actionAlign";
export {
distributeHorizontally,
distributeVertically,
} from "./actionDistribute";
export {
actionCopy,
actionCut,
actionCopyAsPng,
actionCopyAsSvg,
} from "./actionClipboard";
export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats";

View File

@@ -3,44 +3,33 @@ import {
Action, Action,
ActionsManagerInterface, ActionsManagerInterface,
UpdaterFn, UpdaterFn,
ActionFilterFn,
ActionName, ActionName,
ActionResult,
} from "./types"; } from "./types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState, ExcalidrawProps } from "../types"; import { AppState } from "../types";
import { MODES } from "../constants"; import { t } from "../i18n";
import { globalSceneState } from "../scene";
// This is the <App> component, but for now we don't care about anything but its
// `canvas` state.
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
export class ActionManager implements ActionsManagerInterface { export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"]; actions = {} as ActionsManagerInterface["actions"];
updater: (actionResult: ActionResult | Promise<ActionResult>) => void; updater: UpdaterFn;
getAppState: () => AppState;
getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[]; getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
app: App;
constructor( constructor(
updater: UpdaterFn, updater: UpdaterFn,
getAppState: () => AppState, getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[], getElementsIncludingDeleted: () => ReturnType<
app: App, typeof globalSceneState["getElementsIncludingDeleted"]
>,
) { ) {
this.updater = (actionResult) => { this.updater = updater;
if (actionResult && "then" in actionResult) {
actionResult.then((actionResult) => {
return updater(actionResult);
});
} else {
return updater(actionResult);
}
};
this.getAppState = getAppState; this.getAppState = getAppState;
this.getElementsIncludingDeleted = getElementsIncludingDeleted; this.getElementsIncludingDeleted = getElementsIncludingDeleted;
this.app = app;
} }
registerAction(action: Action) { registerAction(action: Action) {
@@ -67,12 +56,6 @@ export class ActionManager implements ActionsManagerInterface {
if (data.length === 0) { if (data.length === 0) {
return false; return false;
} }
const { viewModeEnabled } = this.getAppState();
if (viewModeEnabled) {
if (!Object.values(MODES).includes(data[0].name)) {
return false;
}
}
event.preventDefault(); event.preventDefault();
this.updater( this.updater(
@@ -80,7 +63,6 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
this.getAppState(), this.getAppState(),
null, null,
this.app,
), ),
); );
return true; return true;
@@ -92,16 +74,34 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
this.getAppState(), this.getAppState(),
null, null,
this.app,
), ),
); );
} }
// Id is an attribute that we can use to pass in data like keys. getContextMenuItems(actionFilter: ActionFilterFn = (action) => action) {
// This is needed for dynamically generated action components return Object.values(this.actions)
// like the user list. We can use this key to extract more .filter(actionFilter)
// data from app state. This is an alternative to generic prop hell! .filter((action) => "contextItemLabel" in action)
renderAction = (name: ActionName, id?: string) => { .sort(
(a, b) =>
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
)
.map((action) => ({
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
action: () => {
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
),
);
},
}));
}
renderAction = (name: ActionName) => {
if (this.actions[name] && "PanelComponent" in this.actions[name]) { if (this.actions[name] && "PanelComponent" in this.actions[name]) {
const action = this.actions[name]; const action = this.actions[name];
const PanelComponent = action.PanelComponent!; const PanelComponent = action.PanelComponent!;
@@ -111,7 +111,6 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
this.getAppState(), this.getAppState(),
formState, formState,
this.app,
), ),
); );
}; };
@@ -121,8 +120,6 @@ export class ActionManager implements ActionsManagerInterface {
elements={this.getElementsIncludingDeleted()} elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()} appState={this.getAppState()}
updateData={updateData} updateData={updateData}
id={id}
appProps={this.app.props}
/> />
); );
} }

View File

@@ -2,7 +2,7 @@ import { Action } from "./types";
export let actions: readonly Action[] = []; export let actions: readonly Action[] = [];
export const register = (action: Action): Action => { export function register(action: Action): Action {
actions = actions.concat(action); actions = actions.concat(action);
return action; return action;
}; }

View File

@@ -1,67 +0,0 @@
import { t } from "../i18n";
import { isDarwin } from "../keys";
import { getShortcutKey } from "../utils";
export type ShortcutName =
| "cut"
| "copy"
| "paste"
| "copyStyles"
| "pasteStyles"
| "selectAll"
| "deleteSelectedElements"
| "duplicateSelection"
| "sendBackward"
| "bringForward"
| "sendToBack"
| "bringToFront"
| "copyAsPng"
| "copyAsSvg"
| "group"
| "ungroup"
| "gridMode"
| "zenMode"
| "stats"
| "addToLibrary"
| "viewMode";
const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")],
copy: [getShortcutKey("CtrlOrCmd+C")],
paste: [getShortcutKey("CtrlOrCmd+V")],
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
selectAll: [getShortcutKey("CtrlOrCmd+A")],
deleteSelectedElements: [getShortcutKey("Del")],
duplicateSelection: [
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
],
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
bringForward: [getShortcutKey("CtrlOrCmd+]")],
sendToBack: [
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+[")
: getShortcutKey("CtrlOrCmd+Shift+["),
],
bringToFront: [
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+]")
: getShortcutKey("CtrlOrCmd+Shift+]"),
],
copyAsPng: [getShortcutKey("Shift+Alt+C")],
copyAsSvg: [],
group: [getShortcutKey("CtrlOrCmd+G")],
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
zenMode: [getShortcutKey("Alt+Z")],
stats: [],
addToLibrary: [],
viewMode: [getShortcutKey("Alt+R")],
};
export const getShortcutFromShortcutName = (name: ShortcutName) => {
const shortcuts = shortcutMap[name];
// if multiple shortcuts availiable, take the first one
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
};

View File

@@ -1,33 +1,23 @@
import React from "react"; import React from "react";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState, ExcalidrawProps } from "../types"; import { AppState } from "../types";
/** if false, the action should be prevented */ export type ActionResult = {
export type ActionResult = elements?: readonly ExcalidrawElement[] | null;
| { appState?: AppState | null;
elements?: readonly ExcalidrawElement[] | null; commitToHistory: boolean;
appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null; };
commitToHistory: boolean;
syncHistory?: boolean;
}
| false;
type ActionFn = ( type ActionFn = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>, appState: AppState,
formData: any, formData: any,
app: { canvas: HTMLCanvasElement | null }, ) => ActionResult;
) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void; export type UpdaterFn = (res: ActionResult, commitToHistory?: boolean) => void;
export type ActionFilterFn = (action: Action) => void; export type ActionFilterFn = (action: Action) => void;
export type ActionName = export type ActionName =
| "copy"
| "cut"
| "paste"
| "copyAsPng"
| "copyAsSvg"
| "sendBackward" | "sendBackward"
| "bringForward" | "bringForward"
| "sendToBack" | "sendToBack"
@@ -35,16 +25,11 @@ export type ActionName =
| "copyStyles" | "copyStyles"
| "selectAll" | "selectAll"
| "pasteStyles" | "pasteStyles"
| "gridMode"
| "zenMode"
| "stats"
| "changeStrokeColor" | "changeStrokeColor"
| "changeBackgroundColor" | "changeBackgroundColor"
| "changeFillStyle" | "changeFillStyle"
| "changeStrokeWidth" | "changeStrokeWidth"
| "changeSloppiness" | "changeSloppiness"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeOpacity" | "changeOpacity"
| "changeFontSize" | "changeFontSize"
| "toggleCanvasMenu" | "toggleCanvasMenu"
@@ -54,10 +39,8 @@ export type ActionName =
| "finalize" | "finalize"
| "changeProjectName" | "changeProjectName"
| "changeExportBackground" | "changeExportBackground"
| "changeExportEmbedScene"
| "changeShouldAddWatermark" | "changeShouldAddWatermark"
| "saveScene" | "saveScene"
| "saveAsScene"
| "loadScene" | "loadScene"
| "duplicateSelection" | "duplicateSelection"
| "deleteSelectedElements" | "deleteSelectedElements"
@@ -67,26 +50,10 @@ export type ActionName =
| "zoomOut" | "zoomOut"
| "resetZoom" | "resetZoom"
| "zoomToFit" | "zoomToFit"
| "zoomToSelection"
| "changeFontFamily" | "changeFontFamily"
| "changeTextAlign" | "changeTextAlign"
| "toggleFullScreen" | "toggleFullScreen"
| "toggleShortcuts" | "toggleShortcuts";
| "group"
| "ungroup"
| "goToCollaborator"
| "addToLibrary"
| "changeSharpness"
| "alignTop"
| "alignBottom"
| "alignLeft"
| "alignRight"
| "alignVerticallyCentered"
| "alignHorizontallyCentered"
| "distributeHorizontally"
| "distributeVertically"
| "viewMode"
| "exportWithDarkMode";
export interface Action { export interface Action {
name: ActionName; name: ActionName;
@@ -94,8 +61,6 @@ export interface Action {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
appState: AppState; appState: AppState;
updateData: (formData?: any) => void; updateData: (formData?: any) => void;
appProps: ExcalidrawProps;
id?: string;
}>; }>;
perform: ActionFn; perform: ActionFn;
keyPriority?: number; keyPriority?: number;
@@ -105,16 +70,17 @@ export interface Action {
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => boolean; ) => boolean;
contextItemLabel?: string; contextItemLabel?: string;
contextItemPredicate?: ( contextMenuOrder?: number;
elements: readonly ExcalidrawElement[],
appState: AppState,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
} }
export interface ActionsManagerInterface { export interface ActionsManagerInterface {
actions: Record<ActionName, Action>; actions: {
[actionName in ActionName]: Action;
};
registerAction: (action: Action) => void; registerAction: (action: Action) => void;
handleKeyDown: (event: KeyboardEvent) => boolean; handleKeyDown: (event: KeyboardEvent) => boolean;
getContextMenuItems: (
actionFilter: ActionFilterFn,
) => { label: string; action: () => void }[];
renderAction: (name: ActionName) => React.ReactElement | null; renderAction: (name: ActionName) => React.ReactElement | null;
} }

View File

@@ -1,95 +0,0 @@
import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement";
import { getCommonBounds } from "./element";
interface Box {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export interface Alignment {
position: "start" | "center" | "end";
axis: "x" | "y";
}
export const alignElements = (
selectedElements: ExcalidrawElement[],
alignment: Alignment,
): ExcalidrawElement[] => {
const groups: ExcalidrawElement[][] = getMaximumGroups(selectedElements);
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
return groups.flatMap((group) => {
const translation = calculateTranslation(
group,
selectionBoundingBox,
alignment,
);
return group.map((element) =>
newElementWith(element, {
x: element.x + translation.x,
y: element.y + translation.y,
}),
);
});
};
export const getMaximumGroups = (
elements: ExcalidrawElement[],
): ExcalidrawElement[][] => {
const groups: Map<String, ExcalidrawElement[]> = new Map<
String,
ExcalidrawElement[]
>();
elements.forEach((element: ExcalidrawElement) => {
const groupId =
element.groupIds.length === 0
? element.id
: element.groupIds[element.groupIds.length - 1];
const currentGroupMembers = groups.get(groupId) || [];
groups.set(groupId, [...currentGroupMembers, element]);
});
return Array.from(groups.values());
};
const calculateTranslation = (
group: ExcalidrawElement[],
selectionBoundingBox: Box,
{ axis, position }: Alignment,
): { x: number; y: number } => {
const groupBoundingBox = getCommonBoundingBox(group);
const [min, max]: ["minX" | "minY", "maxX" | "maxY"] =
axis === "x" ? ["minX", "maxX"] : ["minY", "maxY"];
const noTranslation = { x: 0, y: 0 };
if (position === "start") {
return {
...noTranslation,
[axis]: selectionBoundingBox[min] - groupBoundingBox[min],
};
} else if (position === "end") {
return {
...noTranslation,
[axis]: selectionBoundingBox[max] - groupBoundingBox[max],
};
} // else if (position === "center") {
return {
...noTranslation,
[axis]:
(selectionBoundingBox[min] + selectionBoundingBox[max]) / 2 -
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
};
};
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { minX, minY, maxX, maxY };
};

View File

@@ -1,18 +0,0 @@
export const trackEvent =
typeof process !== "undefined" &&
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
typeof window !== "undefined" &&
window.gtag
? (category: string, name: string, label?: string, value?: number) => {
window.gtag("event", name, {
event_category: category,
event_label: label,
value,
});
}
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
? (category: string, name: string, label?: string, value?: number) => {}
: (category: string, name: string, label?: string, value?: number) => {
// Uncomment the next line to track locally
// console.info("Track Event", category, name, label, value);
};

View File

@@ -1,186 +1,96 @@
import oc from "open-color"; import oc from "open-color";
import { import { AppState, FlooredNumber } from "./types";
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
} from "./constants";
import { t } from "./i18n";
import { AppState, NormalizedZoomValue } from "./types";
import { getDateTime } from "./utils"; import { getDateTime } from "./utils";
import { t } from "./i18n";
export const getDefaultAppState = (): Omit< export const DEFAULT_FONT = "20px Virgil";
AppState, export const DEFAULT_TEXT_ALIGN = "left";
"offsetTop" | "offsetLeft"
> => { export function getDefaultAppState(): AppState {
return { return {
theme: "light",
collaborators: new Map(),
currentChartType: "bar",
currentItemBackgroundColor: "transparent",
currentItemEndArrowhead: "arrow",
currentItemFillStyle: "hachure",
currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemLinearStrokeSharpness: "round",
currentItemOpacity: 100,
currentItemRoughness: 1,
currentItemStartArrowhead: null,
currentItemStrokeColor: oc.black,
currentItemStrokeSharpness: "sharp",
currentItemStrokeStyle: "solid",
currentItemStrokeWidth: 1,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
cursorButton: "up",
draggingElement: null,
editingElement: null,
editingGroupId: null,
editingLinearElement: null,
elementLocked: false,
elementType: "selection",
errorMessage: null,
exportBackground: true,
exportEmbedScene: false,
exportWithDarkMode: false,
fileHandle: null,
gridSize: null,
height: window.innerHeight,
isBindingEnabled: true,
isLibraryOpen: false,
isLoading: false, isLoading: false,
errorMessage: null,
draggingElement: null,
resizingElement: null,
multiElement: null,
editingElement: null,
elementType: "selection",
elementLocked: false,
exportBackground: true,
shouldAddWatermark: false,
currentItemStrokeColor: oc.black,
currentItemBackgroundColor: "transparent",
currentItemFillStyle: "hachure",
currentItemStrokeWidth: 1,
currentItemRoughness: 1,
currentItemOpacity: 100,
currentItemFont: DEFAULT_FONT,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
viewBackgroundColor: oc.white,
scrollX: 0 as FlooredNumber,
scrollY: 0 as FlooredNumber,
cursorX: 0,
cursorY: 0,
cursorButton: "up",
scrolledOutside: false,
name: `${t("labels.untitled")}-${getDateTime()}`,
username: "",
isCollaborating: false,
isResizing: false, isResizing: false,
isRotating: false, isRotating: false,
lastPointerDownWith: "mouse",
multiElement: null,
name: `${t("labels.untitled")}-${getDateTime()}`,
openMenu: null,
pasteDialog: { shown: false, data: null },
previousSelectedElementIds: {},
resizingElement: null,
scrolledOutside: false,
scrollX: 0,
scrollY: 0,
selectedElementIds: {},
selectedGroupIds: {},
selectionElement: null, selectionElement: null,
shouldAddWatermark: false, zoom: 1,
openMenu: null,
lastPointerDownWith: "mouse",
selectedElementIds: {},
collaborators: new Map(),
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
showHelpDialog: false, showShortcutsDialog: false,
showStats: false,
startBoundElement: null,
suggestedBindings: [],
toastMessage: null,
viewBackgroundColor: oc.white,
width: window.innerWidth,
zenModeEnabled: false, zenModeEnabled: false,
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
viewModeEnabled: false,
}; };
}; }
/** export function clearAppStateForLocalStorage(appState: AppState) {
* Config containing all AppState keys. Used to determine whether given state const {
* prop should be stripped when exporting to given storage type. draggingElement,
*/ resizingElement,
const APP_STATE_STORAGE_CONF = (< multiElement,
Values extends { editingElement,
/** whether to keep when storing to browser storage (localStorage/IDB) */ selectionElement,
browser: boolean; isResizing,
/** whether to keep when exporting to file/database */ isRotating,
export: boolean; collaborators,
}, isCollaborating,
T extends Record<keyof AppState, Values> isLoading,
>( errorMessage,
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }, showShortcutsDialog,
) => config)({ ...exportedState
theme: { browser: true, export: false }, } = appState;
collaborators: { browser: false, export: false }, return exportedState;
currentChartType: { browser: true, export: false }, }
currentItemBackgroundColor: { browser: true, export: false },
currentItemEndArrowhead: { browser: true, export: false },
currentItemFillStyle: { browser: true, export: false },
currentItemFontFamily: { browser: true, export: false },
currentItemFontSize: { browser: true, export: false },
currentItemLinearStrokeSharpness: { browser: true, export: false },
currentItemOpacity: { browser: true, export: false },
currentItemRoughness: { browser: true, export: false },
currentItemStartArrowhead: { browser: true, export: false },
currentItemStrokeColor: { browser: true, export: false },
currentItemStrokeSharpness: { browser: true, export: false },
currentItemStrokeStyle: { browser: true, export: false },
currentItemStrokeWidth: { browser: true, export: false },
currentItemTextAlign: { browser: true, export: false },
cursorButton: { browser: true, export: false },
draggingElement: { browser: false, export: false },
editingElement: { browser: false, export: false },
editingGroupId: { browser: true, export: false },
editingLinearElement: { browser: false, export: false },
elementLocked: { browser: true, export: false },
elementType: { browser: true, export: false },
errorMessage: { browser: false, export: false },
exportBackground: { browser: true, export: false },
exportEmbedScene: { browser: true, export: false },
exportWithDarkMode: { browser: true, export: false },
fileHandle: { browser: false, export: false },
gridSize: { browser: true, export: true },
height: { browser: false, export: false },
isBindingEnabled: { browser: false, export: false },
isLibraryOpen: { browser: false, export: false },
isLoading: { browser: false, export: false },
isResizing: { browser: false, export: false },
isRotating: { browser: false, export: false },
lastPointerDownWith: { browser: true, export: false },
multiElement: { browser: false, export: false },
name: { browser: true, export: false },
offsetLeft: { browser: false, export: false },
offsetTop: { browser: false, export: false },
openMenu: { browser: true, export: false },
pasteDialog: { browser: false, export: false },
previousSelectedElementIds: { browser: true, export: false },
resizingElement: { browser: false, export: false },
scrolledOutside: { browser: true, export: false },
scrollX: { browser: true, export: false },
scrollY: { browser: true, export: false },
selectedElementIds: { browser: true, export: false },
selectedGroupIds: { browser: true, export: false },
selectionElement: { browser: false, export: false },
shouldAddWatermark: { browser: true, export: false },
shouldCacheIgnoreZoom: { browser: true, export: false },
showHelpDialog: { browser: false, export: false },
showStats: { browser: true, export: false },
startBoundElement: { browser: false, export: false },
suggestedBindings: { browser: false, export: false },
toastMessage: { browser: false, export: false },
viewBackgroundColor: { browser: true, export: true },
width: { browser: false, export: false },
zenModeEnabled: { browser: true, export: false },
zoom: { browser: true, export: false },
viewModeEnabled: { browser: false, export: false },
});
const _clearAppStateForStorage = <ExportType extends "export" | "browser">( export function clearAppStatePropertiesForHistory(
appState: Partial<AppState>, appState: AppState,
exportType: ExportType, ): Partial<AppState> {
) => { return {
type ExportableKeys = { selectedElementIds: appState.selectedElementIds,
[K in keyof typeof APP_STATE_STORAGE_CONF]: typeof APP_STATE_STORAGE_CONF[K][ExportType] extends true exportBackground: appState.exportBackground,
? K shouldAddWatermark: appState.shouldAddWatermark,
: never; currentItemStrokeColor: appState.currentItemStrokeColor,
}[keyof typeof APP_STATE_STORAGE_CONF]; currentItemBackgroundColor: appState.currentItemBackgroundColor,
const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] }; currentItemFillStyle: appState.currentItemFillStyle,
for (const key of Object.keys(appState) as (keyof typeof appState)[]) { currentItemStrokeWidth: appState.currentItemStrokeWidth,
const propConfig = APP_STATE_STORAGE_CONF[key]; currentItemRoughness: appState.currentItemRoughness,
if (propConfig?.[exportType]) { currentItemOpacity: appState.currentItemOpacity,
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445 currentItemFont: appState.currentItemFont,
stateForExport[key] = appState[key]; currentItemTextAlign: appState.currentItemTextAlign,
} viewBackgroundColor: appState.viewBackgroundColor,
} name: appState.name,
return stateForExport; };
}; }
export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => { export function cleanAppStateForExport(appState: AppState) {
return _clearAppStateForStorage(appState, "browser"); return {
}; viewBackgroundColor: appState.viewBackgroundColor,
};
export const cleanAppStateForExport = (appState: Partial<AppState>) => { }
return _clearAppStateForStorage(appState, "export");
};

View File

@@ -1,479 +0,0 @@
import colors from "./colors";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
import { newElement, newLinearElement, newTextElement } from "./element";
import { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random";
export type ChartElements = readonly NonDeletedExcalidrawElement[];
const BAR_WIDTH = 32;
const BAR_GAP = 12;
const BAR_HEIGHT = 256;
const GRID_OPACITY = 50;
export interface Spreadsheet {
title: string | null;
labels: string[] | null;
values: number[];
}
export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
type ParseSpreadsheetResult =
| { type: typeof NOT_SPREADSHEET; reason: string }
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
const tryParseNumber = (s: string): number | null => {
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
if (!match) {
return null;
}
return parseFloat(match[1].replace(/,/g, ""));
};
const isNumericColumn = (lines: string[][], columnIndex: number) =>
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
const numCols = cells[0].length;
if (numCols > 2) {
return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
}
if (numCols === 1) {
if (!isNumericColumn(cells, 0)) {
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
}
const hasHeader = tryParseNumber(cells[0][0]) === null;
const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
tryParseNumber(line[0]),
);
if (values.length < 2) {
return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
}
return {
type: VALID_SPREADSHEET,
spreadsheet: {
title: hasHeader ? cells[0][0] : null,
labels: null,
values: values as number[],
},
};
}
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
if (!isNumericColumn(cells, valueColumnIndex)) {
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
}
const labelColumnIndex = (valueColumnIndex + 1) % 2;
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
const rows = hasHeader ? cells.slice(1) : cells;
if (rows.length < 2) {
return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
}
return {
type: VALID_SPREADSHEET,
spreadsheet: {
title: hasHeader ? cells[0][valueColumnIndex] : null,
labels: rows.map((row) => row[labelColumnIndex]),
values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
},
};
};
const transposeCells = (cells: string[][]) => {
const nextCells: string[][] = [];
for (let col = 0; col < cells[0].length; col++) {
const nextCellRow: string[] = [];
for (let row = 0; row < cells.length; row++) {
nextCellRow.push(cells[row][col]);
}
nextCells.push(nextCellRow);
}
return nextCells;
};
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
// Copy/paste from excel, spreadhseets, tsv, csv.
// For now we only accept 2 columns with an optional header
// Check for tab separated values
let lines = text
.trim()
.split("\n")
.map((line) => line.trim().split("\t"));
// Check for comma separated files
if (lines.length && lines[0].length !== 2) {
lines = text
.trim()
.split("\n")
.map((line) => line.trim().split(","));
}
if (lines.length === 0) {
return { type: NOT_SPREADSHEET, reason: "No values" };
}
const numColsFirstLine = lines[0].length;
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
if (!isSpreadsheet) {
return {
type: NOT_SPREADSHEET,
reason: "All rows don't have same number of columns",
};
}
const result = tryParseCells(lines);
if (result.type !== VALID_SPREADSHEET) {
const transposedResults = tryParseCells(transposeCells(lines));
if (transposedResults.type === VALID_SPREADSHEET) {
return transposedResults;
}
}
return result;
};
const bgColors = colors.elementBackground.slice(
2,
colors.elementBackground.length,
);
// Put all the common properties here so when the whole chart is selected
// the properties dialog shows the correct selected values
const commonProps = {
fillStyle: "hachure",
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: DEFAULT_FONT_SIZE,
opacity: 100,
roughness: 1,
strokeColor: colors.elementStroke[0],
strokeSharpness: "sharp",
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: "middle",
} as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => {
const chartWidth =
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
return { chartWidth, chartHeight };
};
const chartXLabels = (
spreadsheet: Spreadsheet,
x: number,
y: number,
groupId: string,
backgroundColor: string,
): ChartElements => {
return (
spreadsheet.labels?.map((label, index) => {
return newTextElement({
groupIds: [groupId],
backgroundColor,
...commonProps,
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
y: y + BAR_GAP / 2,
width: BAR_WIDTH,
angle: 5.87,
fontSize: 16,
textAlign: "center",
verticalAlign: "top",
});
}) || []
);
};
const chartYLabels = (
spreadsheet: Spreadsheet,
x: number,
y: number,
groupId: string,
backgroundColor: string,
): ChartElements => {
const minYLabel = newTextElement({
groupIds: [groupId],
backgroundColor,
...commonProps,
x: x - BAR_GAP,
y: y - BAR_GAP,
text: "0",
textAlign: "right",
});
const maxYLabel = newTextElement({
groupIds: [groupId],
backgroundColor,
...commonProps,
x: x - BAR_GAP,
y: y - BAR_HEIGHT - minYLabel.height / 2,
text: Math.max(...spreadsheet.values).toLocaleString(),
textAlign: "right",
});
return [minYLabel, maxYLabel];
};
const chartLines = (
spreadsheet: Spreadsheet,
x: number,
y: number,
groupId: string,
backgroundColor: string,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const xLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
width: chartWidth,
points: [
[0, 0],
[chartWidth, 0],
],
});
const yLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
height: chartHeight,
points: [
[0, 0],
[0, -chartHeight],
],
});
const maxLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x,
y: y - BAR_HEIGHT - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
strokeStyle: "dotted",
width: chartWidth,
opacity: GRID_OPACITY,
points: [
[0, 0],
[chartWidth, 0],
],
});
return [xLine, yLine, maxLine];
};
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
const chartBaseElements = (
spreadsheet: Spreadsheet,
x: number,
y: number,
groupId: string,
backgroundColor: string,
debug?: boolean,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const title = spreadsheet.title
? newTextElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
text: spreadsheet.title,
x: x + chartWidth / 2,
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
strokeSharpness: "sharp",
strokeStyle: "solid",
textAlign: "center",
})
: null;
const debugRect = debug
? newElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "rectangle",
x,
y: y - chartHeight,
width: chartWidth,
height: chartHeight,
strokeColor: colors.elementStroke[0],
fillStyle: "solid",
opacity: 6,
})
: null;
return [
...(debugRect ? [debugRect] : []),
...(title ? [title] : []),
...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
...chartLines(spreadsheet, x, y, groupId, backgroundColor),
];
};
const chartTypeBar = (
spreadsheet: Spreadsheet,
x: number,
y: number,
): ChartElements => {
const max = Math.max(...spreadsheet.values);
const groupId = randomId();
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
const bars = spreadsheet.values.map((value, index) => {
const barHeight = (value / max) * BAR_HEIGHT;
return newElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "rectangle",
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
y: y - barHeight - BAR_GAP,
width: BAR_WIDTH,
height: barHeight,
});
});
return [
...bars,
...chartBaseElements(
spreadsheet,
x,
y,
groupId,
backgroundColor,
process.env.NODE_ENV === ENV.DEVELOPMENT,
),
];
};
const chartTypeLine = (
spreadsheet: Spreadsheet,
x: number,
y: number,
): ChartElements => {
const max = Math.max(...spreadsheet.values);
const groupId = randomId();
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
let index = 0;
const points = [];
for (const value of spreadsheet.values) {
const cx = index * (BAR_WIDTH + BAR_GAP);
const cy = -(value / max) * BAR_HEIGHT;
points.push([cx, cy]);
index++;
}
const maxX = Math.max(...points.map((element) => element[0]));
const maxY = Math.max(...points.map((element) => element[1]));
const minX = Math.min(...points.map((element) => element[0]));
const minY = Math.min(...points.map((element) => element[1]));
const line = newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x: x + BAR_GAP + BAR_WIDTH / 2,
y: y - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
height: maxY - minY,
width: maxX - minX,
strokeWidth: 2,
points: points as any,
});
const dots = spreadsheet.values.map((value, index) => {
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
return newElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
fillStyle: "solid",
strokeWidth: 2,
type: "ellipse",
x: x + cx + BAR_WIDTH / 2,
y: y + cy - BAR_GAP * 2,
width: BAR_GAP,
height: BAR_GAP,
});
});
const lines = spreadsheet.values.map((value, index) => {
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
return newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
y: y - cy,
startArrowhead: null,
endArrowhead: null,
height: cy,
strokeStyle: "dotted",
opacity: GRID_OPACITY,
points: [
[0, 0],
[0, cy],
],
});
});
return [
...chartBaseElements(
spreadsheet,
x,
y,
groupId,
backgroundColor,
process.env.NODE_ENV === ENV.DEVELOPMENT,
),
line,
...lines,
...dots,
];
};
export const renderSpreadsheet = (
chartType: string,
spreadsheet: Spreadsheet,
x: number,
y: number,
): ChartElements => {
if (chartType === "line") {
return chartTypeLine(spreadsheet, x, y);
}
return chartTypeBar(spreadsheet, x, y);
};

View File

@@ -1,37 +0,0 @@
import colors from "./colors";
import { AppState } from "./types";
export const getClientColors = (clientId: string, appState: AppState) => {
if (appState?.collaborators) {
const currentUser = appState.collaborators.get(clientId);
if (currentUser?.color) {
return currentUser.color;
}
}
// Naive way of getting an integer out of the clientId
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
// Skip transparent background.
const backgrounds = colors.elementBackground.slice(1);
const strokes = colors.elementStroke.slice(1);
return {
background: backgrounds[sum % backgrounds.length],
stroke: strokes[sum % strokes.length],
};
};
export const getClientInitials = (username?: string | null) => {
if (!username) {
return "?";
}
const names = username.trim().split(" ");
if (names.length < 2) {
return names[0].substring(0, 2).toUpperCase();
}
const firstName = names[0];
const lastName = names[names.length - 1];
return (firstName[0] + lastName[0]).toUpperCase();
};

View File

@@ -5,16 +5,6 @@ import {
import { getSelectedElements } from "./scene"; import { getSelectedElements } from "./scene";
import { AppState } from "./types"; import { AppState } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export"; import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { canvasToBlob } from "./data/blob";
const TYPE_ELEMENTS = "excalidraw/elements";
type ElementsClipboard = {
type: typeof TYPE_ELEMENTS;
created: number;
elements: ExcalidrawElement[];
};
let CLIPBOARD = ""; let CLIPBOARD = "";
let PREFER_APP_CLIPBOARD = false; let PREFER_APP_CLIPBOARD = false;
@@ -31,139 +21,104 @@ export const probablySupportsClipboardBlob =
"ClipboardItem" in window && "ClipboardItem" in window &&
"toBlob" in HTMLCanvasElement.prototype; "toBlob" in HTMLCanvasElement.prototype;
const isElementsClipboard = (contents: any): contents is ElementsClipboard => { export async function copyToAppClipboard(
if (contents?.type === TYPE_ELEMENTS) {
return true;
}
return false;
};
export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) {
const contents: ElementsClipboard = { CLIPBOARD = JSON.stringify(getSelectedElements(elements, appState));
type: TYPE_ELEMENTS,
created: Date.now(),
elements: getSelectedElements(elements, appState),
};
const json = JSON.stringify(contents);
CLIPBOARD = json;
try { try {
// when copying to in-app clipboard, clear system clipboard so that if
// system clip contains text on paste we know it was copied *after* user
// copied elements, and thus we should prefer the text content.
await copyTextToSystemClipboard(null);
PREFER_APP_CLIPBOARD = false; PREFER_APP_CLIPBOARD = false;
await copyTextToSystemClipboard(json); } catch {
} catch (error) { // if clearing system clipboard didn't work, we should prefer in-app
// clipboard even if there's text in system clipboard on paste, because
// we can't be sure of the order of copy operations
PREFER_APP_CLIPBOARD = true; PREFER_APP_CLIPBOARD = true;
console.error(error);
} }
}; }
const getAppClipboard = (): Partial<ElementsClipboard> => { export function getAppClipboard(): {
elements?: readonly ExcalidrawElement[];
} {
if (!CLIPBOARD) { if (!CLIPBOARD) {
return {}; return {};
} }
try { try {
return JSON.parse(CLIPBOARD); const clipboardElements = JSON.parse(CLIPBOARD);
if (
Array.isArray(clipboardElements) &&
clipboardElements.length > 0 &&
clipboardElements[0].type // need to implement a better check here...
) {
return { elements: clipboardElements };
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return {};
} }
};
const parsePotentialSpreadsheet = ( return {};
text: string, }
): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
const result = tryParseSpreadsheet(text);
if (result.type === VALID_SPREADSHEET) {
return { spreadsheet: result.spreadsheet };
}
return null;
};
/** export async function getClipboardContent(
* Retrieves content from system clipboard (either from ClipboardEvent or
* via async clipboard API if supported)
*/
const getSystemClipboard = async (
event: ClipboardEvent | null, event: ClipboardEvent | null,
): Promise<string> => { ): Promise<{
text?: string;
elements?: readonly ExcalidrawElement[];
}> {
try { try {
const text = event const text = event
? event.clipboardData?.getData("text/plain").trim() ? event.clipboardData?.getData("text/plain").trim()
: probablySupportsClipboardReadText && : probablySupportsClipboardReadText &&
(await navigator.clipboard.readText()); (await navigator.clipboard.readText());
return text || ""; if (text && !PREFER_APP_CLIPBOARD && !text.includes(SVG_EXPORT_TAG)) {
} catch { return { text };
return "";
}
};
/**
* Attemps to parse clipboard. Prefers system clipboard.
*/
export const parseClipboard = async (
event: ClipboardEvent | null,
): Promise<{
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
text?: string;
errorMessage?: string;
}> => {
const systemClipboard = await getSystemClipboard(event);
// if system clipboard empty, couldn't be resolved, or contains previously
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
// elements
if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) {
return getAppClipboard();
}
// if system clipboard contains spreadsheet, use it even though it's
// technically possible it's staler than in-app clipboard
const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard);
if (spreadsheetResult) {
return spreadsheetResult;
}
const appClipboardData = getAppClipboard();
try {
const systemClipboardData = JSON.parse(systemClipboard);
// system clipboard elements are newer than in-app clipboard
if (
isElementsClipboard(systemClipboardData) &&
(!appClipboardData?.created ||
appClipboardData.created < systemClipboardData.created)
) {
return { elements: systemClipboardData.elements };
} }
// in-app clipboard is newer than system clipboard } catch (error) {
return appClipboardData; console.error(error);
} catch {
// system clipboard doesn't contain excalidraw elements → return plaintext
// unless we set a flag to prefer in-app clipboard because browser didn't
// support storing to system clipboard on copy
return PREFER_APP_CLIPBOARD && appClipboardData.elements
? appClipboardData
: { text: systemClipboard };
} }
};
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => { return getAppClipboard();
const blob = await canvasToBlob(canvas); }
await navigator.clipboard.write([
new window.ClipboardItem({ "image/png": blob }),
]);
};
export const copyTextToSystemClipboard = async (text: string | null) => { export async function copyCanvasToClipboardAsPng(canvas: HTMLCanvasElement) {
return new Promise((resolve, reject) => {
try {
canvas.toBlob(async function (blob: any) {
try {
await navigator.clipboard.write([
new window.ClipboardItem({ "image/png": blob }),
]);
resolve();
} catch (error) {
reject(error);
}
});
} catch (error) {
reject(error);
}
});
}
export async function copyCanvasToClipboardAsSvg(svgroot: SVGSVGElement) {
try {
await navigator.clipboard.writeText(svgroot.outerHTML);
} catch (error) {
console.error(error);
}
}
export async function copyTextToSystemClipboard(text: string | null) {
let copied = false; let copied = false;
if (probablySupportsClipboardWriteText) { if (probablySupportsClipboardWriteText) {
try { try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document // NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused // not focused
await navigator.clipboard.writeText(text || ""); await navigator.clipboard.writeText(text || "");
copied = true; copied = true;
} catch (error) { } catch (error) {
@@ -172,14 +127,14 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
} }
// Note that execCommand doesn't allow copying empty strings, so if we're // Note that execCommand doesn't allow copying empty strings, so if we're
// clearing clipboard using this API, we must copy at least an empty char // clearing clipboard using this API, we must copy at least an empty char
if (!copied && !copyTextViaExecCommand(text || " ")) { if (!copied && !copyTextViaExecCommand(text || " ")) {
throw new Error("couldn't copy"); throw new Error("couldn't copy");
} }
}; }
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48 // adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
const copyTextViaExecCommand = (text: string) => { function copyTextViaExecCommand(text: string) {
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const textarea = document.createElement("textarea"); const textarea = document.createElement("textarea");
@@ -213,4 +168,4 @@ const copyTextViaExecCommand = (text: string) => {
textarea.remove(); textarea.remove();
return success; return success;
}; }

Some files were not shown because too many files have changed in this diff Show More