mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-08-19 08:17:01 +02:00
Compare commits
2 Commits
v0.11.0
...
persist_fi
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f5f4ec7528 | ||
![]() |
ba705a099a |
5
.env
Normal file
5
.env
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
|
||||||
|
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||||
|
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||||
|
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
|
||||||
|
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
@@ -1,8 +0,0 @@
|
|||||||
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
|
||||||
REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
|
||||||
|
|
||||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
|
||||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
|
||||||
|
|
||||||
REACT_APP_SOCKET_SERVER_URL=http://localhost:3002
|
|
||||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
|
@@ -1,11 +1 @@
|
|||||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
|
||||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
|
||||||
|
|
||||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
|
||||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
|
||||||
|
|
||||||
REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com
|
|
||||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
|
||||||
|
|
||||||
# production-only vars
|
|
||||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||||
|
@@ -5,4 +5,3 @@ package-lock.json
|
|||||||
firebase/
|
firebase/
|
||||||
dist/
|
dist/
|
||||||
public/workbox
|
public/workbox
|
||||||
src/packages/excalidraw/types
|
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": ["@excalidraw/eslint-config", "react-app"],
|
"extends": ["@excalidraw/eslint-config", "react-app"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"import/no-anonymous-default-export": "off",
|
"import/no-anonymous-default-export": "off"
|
||||||
"no-restricted-globals": "off"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
3
.github/dependabot.yml
vendored
3
.github/dependabot.yml
vendored
@@ -10,7 +10,6 @@ updates:
|
|||||||
- lipis
|
- lipis
|
||||||
assignees:
|
assignees:
|
||||||
- lipis
|
- lipis
|
||||||
open-pull-requests-limit: 20
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directory: /src/packages/excalidraw/
|
directory: /src/packages/excalidraw/
|
||||||
@@ -22,7 +21,6 @@ updates:
|
|||||||
- ad1992
|
- ad1992
|
||||||
assignees:
|
assignees:
|
||||||
- ad1992
|
- ad1992
|
||||||
open-pull-requests-limit: 20
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directory: /src/packages/utils/
|
directory: /src/packages/utils/
|
||||||
@@ -34,4 +32,3 @@ updates:
|
|||||||
- ad1992
|
- ad1992
|
||||||
assignees:
|
assignees:
|
||||||
- ad1992
|
- ad1992
|
||||||
open-pull-requests-limit: 20
|
|
||||||
|
1
.github/workflows/autorelease-excalidraw.yml
vendored
1
.github/workflows/autorelease-excalidraw.yml
vendored
@@ -23,5 +23,4 @@ jobs:
|
|||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
- name: Auto release
|
- name: Auto release
|
||||||
run: |
|
run: |
|
||||||
yarn add @actions/core
|
|
||||||
yarn autorelease
|
yarn autorelease
|
||||||
|
55
.github/workflows/autorelease-preview.yml
vendored
55
.github/workflows/autorelease-preview.yml
vendored
@@ -1,55 +0,0 @@
|
|||||||
name: Auto release preview @excalidraw/excalidraw-preview
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created, edited]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
Auto-release-excalidraw-preview:
|
|
||||||
name: Auto release preview
|
|
||||||
if: github.event.comment.body == '@excalibot release package' && github.event.issue.pull_request
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: React to release comment
|
|
||||||
uses: peter-evans/create-or-update-comment@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
|
||||||
comment-id: ${{ github.event.comment.id }}
|
|
||||||
reactions: "+1"
|
|
||||||
- name: Get PR SHA
|
|
||||||
id: sha
|
|
||||||
uses: actions/github-script@v4
|
|
||||||
with:
|
|
||||||
result-encoding: string
|
|
||||||
script: |
|
|
||||||
const { owner, repo, number } = context.issue;
|
|
||||||
const pr = await github.pulls.get({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
pull_number: number,
|
|
||||||
});
|
|
||||||
return pr.data.head.sha
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
ref: ${{ steps.sha.outputs.result }}
|
|
||||||
fetch-depth: 2
|
|
||||||
- name: Setup Node.js 14.x
|
|
||||||
uses: actions/setup-node@v2
|
|
||||||
with:
|
|
||||||
node-version: 14.x
|
|
||||||
- name: Set up publish access
|
|
||||||
run: |
|
|
||||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
|
||||||
env:
|
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
- name: Auto release preview
|
|
||||||
id: "autorelease"
|
|
||||||
run: |
|
|
||||||
yarn add @actions/core
|
|
||||||
yarn autorelease preview ${{ github.event.issue.number }}
|
|
||||||
- name: Post comment post release
|
|
||||||
if: always()
|
|
||||||
uses: peter-evans/create-or-update-comment@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
|
||||||
issue-number: ${{ github.event.issue.number }}
|
|
||||||
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
|
|
2
.github/workflows/publish-docker.yml
vendored
2
.github/workflows/publish-docker.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: docker/build-push-action@v2
|
- uses: docker/build-push-action@v1
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@@ -5,11 +5,9 @@
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.envrc
|
.envrc
|
||||||
.eslintcache
|
.eslintcache
|
||||||
.history
|
|
||||||
.idea
|
.idea
|
||||||
.vercel
|
.vercel
|
||||||
.vscode
|
.vscode
|
||||||
.yarn
|
|
||||||
*.log
|
*.log
|
||||||
*.tgz
|
*.tgz
|
||||||
build
|
build
|
||||||
@@ -23,7 +21,3 @@ static
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
src/packages/excalidraw/types
|
src/packages/excalidraw/types
|
||||||
src/packages/excalidraw/example/public/bundle.js
|
|
||||||
src/packages/excalidraw/example/public/excalidraw-assets-dev
|
|
||||||
src/packages/excalidraw/example/public/excalidraw.development.js
|
|
||||||
|
|
||||||
|
@@ -1,2 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
yarn lint-staged
|
|
@@ -10,7 +10,7 @@ ARG NODE_ENV=production
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN yarn build:app:docker
|
RUN yarn build:app:docker
|
||||||
|
|
||||||
FROM nginx:1.21-alpine
|
FROM nginx:1.17-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
@@ -70,8 +70,6 @@ The first set of digits is the room. This is visible from the server that’s go
|
|||||||
|
|
||||||
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
||||||
|
|
||||||
> Note: Please ensure that the encryption key is 22 characters long.
|
|
||||||
|
|
||||||
## Shape libraries
|
## Shape libraries
|
||||||
|
|
||||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||||
@@ -95,7 +93,7 @@ These instructions will get you a copy of the project up and running on your loc
|
|||||||
#### Requirements
|
#### Requirements
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/en/)
|
- [Node.js](https://nodejs.org/en/)
|
||||||
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
|
- [Yarn](https://yarnpkg.com/getting-started/install)
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
|
||||||
#### Clone the repo
|
#### Clone the repo
|
||||||
@@ -118,10 +116,6 @@ yarn start
|
|||||||
|
|
||||||
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
|
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
|
||||||
|
|
||||||
#### Collaboration
|
|
||||||
|
|
||||||
For collaboration, you will need to set up [collab server](https://github.com/excalidraw/excalidraw-room) in local.
|
|
||||||
|
|
||||||
#### Commands
|
#### Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
56
package.json
56
package.json
@@ -21,26 +21,22 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/browser": "6.2.5",
|
"@sentry/browser": "6.2.5",
|
||||||
"@sentry/integrations": "6.2.5",
|
"@sentry/integrations": "6.2.5",
|
||||||
"@testing-library/jest-dom": "5.16.2",
|
"@testing-library/jest-dom": "5.11.10",
|
||||||
"@testing-library/react": "12.1.2",
|
"@testing-library/react": "11.2.6",
|
||||||
"@tldraw/vec": "1.4.3",
|
"@types/jest": "26.0.22",
|
||||||
"@types/jest": "27.4.0",
|
"@types/react": "17.0.3",
|
||||||
"@types/pica": "5.1.3",
|
"@types/react-dom": "17.0.3",
|
||||||
"@types/react": "17.0.39",
|
|
||||||
"@types/react-dom": "17.0.11",
|
|
||||||
"@types/socket.io-client": "1.4.36",
|
"@types/socket.io-client": "1.4.36",
|
||||||
"browser-fs-access": "0.23.0",
|
"browser-fs-access": "0.16.4",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"fake-indexeddb": "3.1.7",
|
|
||||||
"firebase": "8.3.3",
|
"firebase": "8.3.3",
|
||||||
"i18next-browser-languagedetector": "6.1.2",
|
"i18next-browser-languagedetector": "6.1.0",
|
||||||
"idb-keyval": "6.0.3",
|
"idb-keyval": "5.0.6",
|
||||||
"image-blob-reduce": "3.0.1",
|
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "3.1.32",
|
"nanoid": "3.1.22",
|
||||||
"open-color": "1.9.1",
|
"open-color": "1.8.0",
|
||||||
"pako": "1.0.11",
|
"pako": "1.0.11",
|
||||||
"perfect-freehand": "1.0.16",
|
"perfect-freehand": "0.4.7",
|
||||||
"png-chunk-text": "1.0.0",
|
"png-chunk-text": "1.0.0",
|
||||||
"png-chunks-encode": "1.0.0",
|
"png-chunks-encode": "1.0.0",
|
||||||
"png-chunks-extract": "1.0.0",
|
"png-chunks-extract": "1.0.0",
|
||||||
@@ -49,37 +45,36 @@
|
|||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
"react-scripts": "4.0.3",
|
"react-scripts": "4.0.3",
|
||||||
"roughjs": "4.5.2",
|
"roughjs": "4.4.1",
|
||||||
"sass": "1.49.7",
|
"sass": "1.32.10",
|
||||||
"socket.io-client": "2.3.1",
|
"socket.io-client": "2.3.1",
|
||||||
"typescript": "4.5.5"
|
"typescript": "4.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@excalidraw/eslint-config": "1.0.0",
|
"@excalidraw/eslint-config": "1.0.0",
|
||||||
"@excalidraw/prettier-config": "1.0.2",
|
"@excalidraw/prettier-config": "1.0.2",
|
||||||
"@types/chai": "4.3.0",
|
|
||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.6",
|
||||||
"@types/pako": "1.0.3",
|
"@types/pako": "1.0.1",
|
||||||
"@types/resize-observer-browser": "0.1.6",
|
"@types/resize-observer-browser": "0.1.5",
|
||||||
"chai": "4.3.6",
|
|
||||||
"dotenv": "10.0.0",
|
|
||||||
"eslint-config-prettier": "8.3.0",
|
"eslint-config-prettier": "8.3.0",
|
||||||
"eslint-plugin-prettier": "3.3.1",
|
"eslint-plugin-prettier": "3.3.1",
|
||||||
"firebase-tools": "9.23.0",
|
"firebase-tools": "9.9.0",
|
||||||
"husky": "7.0.4",
|
"husky": "4.3.8",
|
||||||
"jest-canvas-mock": "2.3.1",
|
"jest-canvas-mock": "2.3.1",
|
||||||
"lint-staged": "12.3.3",
|
"lint-staged": "10.5.4",
|
||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.5.1",
|
"prettier": "2.2.1",
|
||||||
"rewire": "5.0.0"
|
"rewire": "5.0.0"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
|
||||||
"@typescript-eslint/typescript-estree": "5.10.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
},
|
},
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
|
"husky": {
|
||||||
|
"hooks": {
|
||||||
|
"pre-commit": "lint-staged"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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-fs-access)/)"
|
||||||
@@ -101,7 +96,6 @@
|
|||||||
"fix": "yarn fix:other && yarn fix:code",
|
"fix": "yarn fix:other && yarn fix:code",
|
||||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||||
"prepare": "husky install",
|
|
||||||
"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:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
||||||
|
@@ -26,6 +26,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"capture_links": "new_client",
|
||||||
"share_target": {
|
"share_target": {
|
||||||
"action": "/web-share-target",
|
"action": "/web-share-target",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const { exec, execSync } = require("child_process");
|
const { exec, execSync } = require("child_process");
|
||||||
const core = require("@actions/core");
|
|
||||||
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||||
@@ -16,62 +15,37 @@ const publish = () => {
|
|||||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||||
console.info("Published 🎉");
|
} catch (e) {
|
||||||
core.setOutput(
|
console.error(e);
|
||||||
"result",
|
|
||||||
`**Preview version has been shipped** :rocket:
|
|
||||||
You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/v/${pkg.version}) for testing!`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
core.setOutput("result", "package couldn't be published :warning:!");
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// get files changed between prev and head commit
|
// get files changed between prev and head commit
|
||||||
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||||
if (error || stderr) {
|
if (error || stderr) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
core.setOutput("result", ":warning: Package couldn't be published!");
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const changedFiles = stdout.trim().split("\n");
|
const changedFiles = stdout.trim().split("\n");
|
||||||
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
||||||
|
|
||||||
const excalidrawPackageFiles = changedFiles.filter((file) => {
|
const excalidrawPackageFiles = changedFiles.filter((file) => {
|
||||||
return (
|
return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
|
||||||
(file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
|
|
||||||
!filesToIgnoreRegex.test(file)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!excalidrawPackageFiles.length) {
|
if (!excalidrawPackageFiles.length) {
|
||||||
console.info("Skipping release as no valid diff found");
|
|
||||||
core.setOutput("result", "Skipping release as no valid diff found");
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// update package.json
|
// update package.json
|
||||||
|
pkg.version = `${pkg.version}-${getShortCommitHash()}`;
|
||||||
pkg.name = "@excalidraw/excalidraw-next";
|
pkg.name = "@excalidraw/excalidraw-next";
|
||||||
let version = `${pkg.version}-${getShortCommitHash()}`;
|
|
||||||
|
|
||||||
// update readme
|
|
||||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
|
||||||
|
|
||||||
const isPreview = process.argv.slice(2)[0] === "preview";
|
|
||||||
if (isPreview) {
|
|
||||||
// use pullNumber-commithash as the version for preview
|
|
||||||
const pullRequestNumber = process.argv.slice(3)[0];
|
|
||||||
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
|
|
||||||
// replace "excalidraw-next" with "excalidraw-preview"
|
|
||||||
pkg.name = "@excalidraw/excalidraw-preview";
|
|
||||||
data = data.replace(/excalidraw-next/g, "excalidraw-preview");
|
|
||||||
data = data.trim();
|
|
||||||
}
|
|
||||||
pkg.version = version;
|
|
||||||
|
|
||||||
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
||||||
|
|
||||||
|
// update readme
|
||||||
|
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||||
console.info("Publish in progress...");
|
|
||||||
publish();
|
publish();
|
||||||
});
|
});
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
const { readdirSync, writeFileSync } = require("fs");
|
const { readdirSync, writeFileSync } = require("fs");
|
||||||
const files = readdirSync(`${__dirname}/../src/locales`);
|
const files = readdirSync(`${__dirname}/../src/locales`);
|
||||||
|
|
||||||
const flatten = (object = {}, result = {}, extraKey = "") => {
|
const flatten = (object) =>
|
||||||
for (const key in object) {
|
Object.keys(object).reduce(
|
||||||
if (typeof object[key] !== "object") {
|
(initial, current) => ({ ...initial, ...object[current] }),
|
||||||
result[extraKey + key] = object[key];
|
{},
|
||||||
} else {
|
);
|
||||||
flatten(object[key], result, `${extraKey}${key}.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const locales = files.filter(
|
const locales = files.filter(
|
||||||
(file) => file !== "README.md" && file !== "percentages.json",
|
(file) => file !== "README.md" && file !== "percentages.json",
|
||||||
@@ -24,8 +19,10 @@ for (let index = 0; index < locales.length; index++) {
|
|||||||
|
|
||||||
const allKeys = Object.keys(data);
|
const allKeys = Object.keys(data);
|
||||||
const translatedKeys = allKeys.filter((item) => data[item] !== "");
|
const translatedKeys = allKeys.filter((item) => data[item] !== "");
|
||||||
const percentage = Math.floor((100 * translatedKeys.length) / allKeys.length);
|
|
||||||
percentages[currentLocale.replace(".json", "")] = percentage;
|
const percentage = (100 * translatedKeys.length) / allKeys.length;
|
||||||
|
|
||||||
|
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
@@ -5,13 +5,10 @@ const THRESSHOLD = 85;
|
|||||||
const crowdinMap = {
|
const crowdinMap = {
|
||||||
"ar-SA": "en-ar",
|
"ar-SA": "en-ar",
|
||||||
"bg-BG": "en-bg",
|
"bg-BG": "en-bg",
|
||||||
"bn-BD": "en-bn",
|
|
||||||
"ca-ES": "en-ca",
|
"ca-ES": "en-ca",
|
||||||
"da-DK": "en-da",
|
|
||||||
"de-DE": "en-de",
|
"de-DE": "en-de",
|
||||||
"el-GR": "en-el",
|
"el-GR": "en-el",
|
||||||
"es-ES": "en-es",
|
"es-ES": "en-es",
|
||||||
"eu-ES": "en-eu",
|
|
||||||
"fa-IR": "en-fa",
|
"fa-IR": "en-fa",
|
||||||
"fi-FI": "en-fi",
|
"fi-FI": "en-fi",
|
||||||
"fr-FR": "en-fr",
|
"fr-FR": "en-fr",
|
||||||
@@ -34,28 +31,20 @@ const crowdinMap = {
|
|||||||
"pt-PT": "en-pt",
|
"pt-PT": "en-pt",
|
||||||
"ro-RO": "en-ro",
|
"ro-RO": "en-ro",
|
||||||
"ru-RU": "en-ru",
|
"ru-RU": "en-ru",
|
||||||
"si-LK": "en-silk",
|
|
||||||
"sk-SK": "en-sk",
|
"sk-SK": "en-sk",
|
||||||
"sv-SE": "en-sv",
|
"sv-SE": "en-sv",
|
||||||
"ta-IN": "en-ta",
|
|
||||||
"tr-TR": "en-tr",
|
"tr-TR": "en-tr",
|
||||||
"uk-UA": "en-uk",
|
"uk-UA": "en-uk",
|
||||||
"zh-CN": "en-zhcn",
|
"zh-CN": "en-zhcn",
|
||||||
"zh-HK": "en-zhhk",
|
|
||||||
"zh-TW": "en-zhtw",
|
"zh-TW": "en-zhtw",
|
||||||
"lt-LT": "en-lt",
|
|
||||||
"lv-LV": "en-lv",
|
"lv-LV": "en-lv",
|
||||||
"cs-CZ": "en-cs",
|
"cs-CZ": "cs-cz",
|
||||||
"kk-KZ": "en-kk",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const flags = {
|
const flags = {
|
||||||
"ar-SA": "🇸🇦",
|
"ar-SA": "🇸🇦",
|
||||||
"bg-BG": "🇧🇬",
|
"bg-BG": "🇧🇬",
|
||||||
"bn-BD": "🇧🇩",
|
|
||||||
"ca-ES": "🏳",
|
"ca-ES": "🏳",
|
||||||
"cs-CZ": "🇨🇿",
|
|
||||||
"da-DK": "🇩🇰",
|
|
||||||
"de-DE": "🇩🇪",
|
"de-DE": "🇩🇪",
|
||||||
"el-GR": "🇬🇷",
|
"el-GR": "🇬🇷",
|
||||||
"es-ES": "🇪🇸",
|
"es-ES": "🇪🇸",
|
||||||
@@ -69,10 +58,7 @@ const flags = {
|
|||||||
"it-IT": "🇮🇹",
|
"it-IT": "🇮🇹",
|
||||||
"ja-JP": "🇯🇵",
|
"ja-JP": "🇯🇵",
|
||||||
"kab-KAB": "🏳",
|
"kab-KAB": "🏳",
|
||||||
"kk-KZ": "🇰🇿",
|
|
||||||
"ko-KR": "🇰🇷",
|
"ko-KR": "🇰🇷",
|
||||||
"lt-LT": "🇱🇹",
|
|
||||||
"lv-LV": "🇱🇻",
|
|
||||||
"my-MM": "🇲🇲",
|
"my-MM": "🇲🇲",
|
||||||
"nb-NO": "🇳🇴",
|
"nb-NO": "🇳🇴",
|
||||||
"nl-NL": "🇳🇱",
|
"nl-NL": "🇳🇱",
|
||||||
@@ -84,28 +70,23 @@ const flags = {
|
|||||||
"pt-PT": "🇵🇹",
|
"pt-PT": "🇵🇹",
|
||||||
"ro-RO": "🇷🇴",
|
"ro-RO": "🇷🇴",
|
||||||
"ru-RU": "🇷🇺",
|
"ru-RU": "🇷🇺",
|
||||||
"si-LK": "🇱🇰",
|
|
||||||
"sk-SK": "🇸🇰",
|
"sk-SK": "🇸🇰",
|
||||||
"sv-SE": "🇸🇪",
|
"sv-SE": "🇸🇪",
|
||||||
"ta-IN": "🇮🇳",
|
|
||||||
"tr-TR": "🇹🇷",
|
"tr-TR": "🇹🇷",
|
||||||
"uk-UA": "🇺🇦",
|
"uk-UA": "🇺🇦",
|
||||||
"zh-CN": "🇨🇳",
|
"zh-CN": "🇨🇳",
|
||||||
"zh-HK": "🇭🇰",
|
|
||||||
"zh-TW": "🇹🇼",
|
"zh-TW": "🇹🇼",
|
||||||
|
"lv-LV": "🇱🇻",
|
||||||
|
"cs-CZ": "🇨🇿",
|
||||||
};
|
};
|
||||||
|
|
||||||
const languages = {
|
const languages = {
|
||||||
"ar-SA": "العربية",
|
"ar-SA": "العربية",
|
||||||
"bg-BG": "Български",
|
"bg-BG": "Български",
|
||||||
"bn-BD": "Bengali",
|
|
||||||
"ca-ES": "Català",
|
"ca-ES": "Català",
|
||||||
"cs-CZ": "Česky",
|
|
||||||
"da-DK": "Dansk",
|
|
||||||
"de-DE": "Deutsch",
|
"de-DE": "Deutsch",
|
||||||
"el-GR": "Ελληνικά",
|
"el-GR": "Ελληνικά",
|
||||||
"es-ES": "Español",
|
"es-ES": "Español",
|
||||||
"eu-ES": "Euskara",
|
|
||||||
"fa-IR": "فارسی",
|
"fa-IR": "فارسی",
|
||||||
"fi-FI": "Suomi",
|
"fi-FI": "Suomi",
|
||||||
"fr-FR": "Français",
|
"fr-FR": "Français",
|
||||||
@@ -116,10 +97,7 @@ const languages = {
|
|||||||
"it-IT": "Italiano",
|
"it-IT": "Italiano",
|
||||||
"ja-JP": "日本語",
|
"ja-JP": "日本語",
|
||||||
"kab-KAB": "Taqbaylit",
|
"kab-KAB": "Taqbaylit",
|
||||||
"kk-KZ": "Қазақ тілі",
|
|
||||||
"ko-KR": "한국어",
|
"ko-KR": "한국어",
|
||||||
"lt-LT": "Lietuvių",
|
|
||||||
"lv-LV": "Latviešu",
|
|
||||||
"my-MM": "Burmese",
|
"my-MM": "Burmese",
|
||||||
"nb-NO": "Norsk bokmål",
|
"nb-NO": "Norsk bokmål",
|
||||||
"nl-NL": "Nederlands",
|
"nl-NL": "Nederlands",
|
||||||
@@ -131,15 +109,14 @@ const languages = {
|
|||||||
"pt-PT": "Português",
|
"pt-PT": "Português",
|
||||||
"ro-RO": "Română",
|
"ro-RO": "Română",
|
||||||
"ru-RU": "Русский",
|
"ru-RU": "Русский",
|
||||||
"si-LK": "සිංහල",
|
|
||||||
"sk-SK": "Slovenčina",
|
"sk-SK": "Slovenčina",
|
||||||
"sv-SE": "Svenska",
|
"sv-SE": "Svenska",
|
||||||
"ta-IN": "Tamil",
|
|
||||||
"tr-TR": "Türkçe",
|
"tr-TR": "Türkçe",
|
||||||
"uk-UA": "Українська",
|
"uk-UA": "Українська",
|
||||||
"zh-CN": "简体中文",
|
"zh-CN": "简体中文",
|
||||||
"zh-HK": "繁體中文 (香港)",
|
|
||||||
"zh-TW": "繁體中文",
|
"zh-TW": "繁體中文",
|
||||||
|
"lv-LV": "Latviešu",
|
||||||
|
"cs-CZ": "Česky",
|
||||||
};
|
};
|
||||||
|
|
||||||
const percentages = fs.readFileSync(
|
const percentages = fs.readFileSync(
|
||||||
|
@@ -1,39 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const util = require("util");
|
|
||||||
const exec = util.promisify(require("child_process").exec);
|
|
||||||
const updateReadme = require("./updateReadme");
|
|
||||||
const updateChangelog = require("./updateChangelog");
|
|
||||||
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
|
||||||
|
|
||||||
const updatePackageVersion = (nextVersion) => {
|
|
||||||
const pkg = require(excalidrawPackage);
|
|
||||||
pkg.version = nextVersion;
|
|
||||||
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
|
||||||
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
|
||||||
};
|
|
||||||
|
|
||||||
const release = async (nextVersion) => {
|
|
||||||
try {
|
|
||||||
updateReadme();
|
|
||||||
await updateChangelog(nextVersion);
|
|
||||||
updatePackageVersion(nextVersion);
|
|
||||||
await exec(`git add -u`);
|
|
||||||
await exec(
|
|
||||||
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
|
||||||
);
|
|
||||||
/* eslint-disable no-console */
|
|
||||||
console.log("Done!");
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextVersion = process.argv.slice(2)[0];
|
|
||||||
if (!nextVersion) {
|
|
||||||
console.error("Pass the next version to release!");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
release(nextVersion);
|
|
@@ -1,104 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const util = require("util");
|
|
||||||
const exec = util.promisify(require("child_process").exec);
|
|
||||||
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
|
||||||
const pkg = require(excalidrawPackage);
|
|
||||||
const lastVersion = pkg.version;
|
|
||||||
const existingChangeLog = fs.readFileSync(
|
|
||||||
`${excalidrawDir}/CHANGELOG.md`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
|
|
||||||
const headerForType = {
|
|
||||||
feat: "Features",
|
|
||||||
fix: "Fixes",
|
|
||||||
style: "Styles",
|
|
||||||
refactor: " Refactor",
|
|
||||||
perf: "Performance",
|
|
||||||
build: "Build",
|
|
||||||
};
|
|
||||||
const badCommits = [];
|
|
||||||
const getCommitHashForLastVersion = async () => {
|
|
||||||
try {
|
|
||||||
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
|
|
||||||
const { stdout } = await exec(
|
|
||||||
`git log --format=format:"%H" --grep=${commitMessage}`,
|
|
||||||
);
|
|
||||||
return stdout;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLibraryCommitsSinceLastRelease = async () => {
|
|
||||||
const commitHash = await getCommitHashForLastVersion();
|
|
||||||
const { stdout } = await exec(
|
|
||||||
`git log --pretty=format:%s ${commitHash}...master`,
|
|
||||||
);
|
|
||||||
const commitsSinceLastRelease = stdout.split("\n");
|
|
||||||
const commitList = {};
|
|
||||||
supportedTypes.forEach((type) => {
|
|
||||||
commitList[type] = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
commitsSinceLastRelease.forEach((commit) => {
|
|
||||||
const indexOfColon = commit.indexOf(":");
|
|
||||||
const type = commit.slice(0, indexOfColon);
|
|
||||||
if (!supportedTypes.includes(type)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const messageWithoutType = commit.slice(indexOfColon + 1).trim();
|
|
||||||
const messageWithCapitalizeFirst =
|
|
||||||
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
|
|
||||||
const prMatch = commit.match(/\(#([0-9]*)\)/);
|
|
||||||
if (prMatch) {
|
|
||||||
const prNumber = prMatch[1];
|
|
||||||
|
|
||||||
// return if the changelog already contains the pr number which would happen for package updates
|
|
||||||
if (existingChangeLog.includes(prNumber)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
|
|
||||||
const messageWithPRLink = messageWithCapitalizeFirst.replace(
|
|
||||||
/\(#[0-9]*\)/,
|
|
||||||
prMarkdown,
|
|
||||||
);
|
|
||||||
commitList[type].push(messageWithPRLink);
|
|
||||||
} else {
|
|
||||||
badCommits.push(commit);
|
|
||||||
commitList[type].push(messageWithCapitalizeFirst);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.info("Bad commits:", badCommits);
|
|
||||||
return commitList;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateChangelog = async (nextVersion) => {
|
|
||||||
const commitList = await getLibraryCommitsSinceLastRelease();
|
|
||||||
let changelogForLibrary =
|
|
||||||
"## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
|
|
||||||
supportedTypes.forEach((type) => {
|
|
||||||
if (commitList[type].length) {
|
|
||||||
changelogForLibrary += `### ${headerForType[type]}\n\n`;
|
|
||||||
const commits = commitList[type];
|
|
||||||
commits.forEach((commit) => {
|
|
||||||
changelogForLibrary += `- ${commit}\n\n`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
changelogForLibrary += "---\n";
|
|
||||||
const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
|
|
||||||
let updatedContent =
|
|
||||||
existingChangeLog.slice(0, lastVersionIndex) +
|
|
||||||
changelogForLibrary +
|
|
||||||
existingChangeLog.slice(lastVersionIndex);
|
|
||||||
const currentDate = new Date().toISOString().slice(0, 10);
|
|
||||||
const newVersion = `## ${nextVersion} (${currentDate})`;
|
|
||||||
updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
|
|
||||||
fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = updateChangelog;
|
|
@@ -1,27 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
const updateReadme = () => {
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
|
||||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
|
||||||
|
|
||||||
// remove note for unstable release
|
|
||||||
data = data.replace(
|
|
||||||
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
// replace "excalidraw-next" with "excalidraw"
|
|
||||||
data = data.replace(/excalidraw-next/g, "excalidraw");
|
|
||||||
data = data.trim();
|
|
||||||
|
|
||||||
const demoIndex = data.indexOf("### Demo");
|
|
||||||
const excalidrawNextNote =
|
|
||||||
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
|
|
||||||
// Add excalidraw next note to try out for unreleased changes
|
|
||||||
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
|
|
||||||
|
|
||||||
// update readme
|
|
||||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = updateReadme;
|
|
@@ -2,8 +2,6 @@ import { register } from "./register";
|
|||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { deepCopyElement } from "../element/newElement";
|
import { deepCopyElement } from "../element/newElement";
|
||||||
import { randomId } from "../random";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
export const actionAddToLibrary = register({
|
export const actionAddToLibrary = register({
|
||||||
name: "addToLibrary",
|
name: "addToLibrary",
|
||||||
@@ -11,49 +9,15 @@ export const actionAddToLibrary = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
if (selectedElements.some((element) => element.type === "image")) {
|
|
||||||
return {
|
|
||||||
commitToHistory: false,
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
errorMessage: "Support for adding images to the library coming soon!",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return app.library
|
app.library.loadLibrary().then((items) => {
|
||||||
.loadLibrary()
|
app.library.saveLibrary([
|
||||||
.then((items) => {
|
|
||||||
return app.library.saveLibrary([
|
|
||||||
{
|
|
||||||
id: randomId(),
|
|
||||||
status: "unpublished",
|
|
||||||
elements: selectedElements.map(deepCopyElement),
|
|
||||||
created: Date.now(),
|
|
||||||
},
|
|
||||||
...items,
|
...items,
|
||||||
|
selectedElements.map(deepCopyElement),
|
||||||
]);
|
]);
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
return {
|
|
||||||
commitToHistory: false,
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
toastMessage: t("toast.addedToLibrary"),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
return {
|
|
||||||
commitToHistory: false,
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
errorMessage: error.message,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.addToLibrary",
|
contextItemLabel: "labels.addToLibrary",
|
||||||
});
|
});
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { alignElements, Alignment } from "../align";
|
import { alignElements, Alignment } from "../align";
|
||||||
import {
|
import {
|
||||||
AlignBottomIcon,
|
AlignBottomIcon,
|
||||||
@@ -8,13 +9,13 @@ import {
|
|||||||
CenterVerticallyIcon,
|
CenterVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getElementMap, getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
@@ -34,11 +35,9 @@ const alignSelectedElements = (
|
|||||||
|
|
||||||
const updatedElements = alignElements(selectedElements, alignment);
|
const updatedElements = alignElements(selectedElements, alignment);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = getElementMap(updatedElements);
|
||||||
|
|
||||||
return elements.map(
|
return elements.map((element) => updatedElementsMap[element.id] || element);
|
||||||
(element) => updatedElementsMap.get(element.id) || element,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actionAlignTop = register({
|
export const actionAlignTop = register({
|
||||||
|
@@ -1,22 +1,22 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { getDefaultAppState } from "../appState";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { zoomIn, zoomOut } from "../components/icons";
|
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||||
import { THEME, ZOOM_STEP } from "../constants";
|
import { ZOOM_STEP } from "../constants";
|
||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { useIsMobile } from "../components/App";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getNewZoom } from "../scene/zoom";
|
||||||
import { AppState, NormalizedZoomValue } from "../types";
|
import { AppState, NormalizedZoomValue } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { getDefaultAppState } from "../appState";
|
|
||||||
import ClearCanvas from "../components/ClearCanvas";
|
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
@@ -47,48 +47,54 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
|
|
||||||
export const actionClearCanvas = register({
|
export const actionClearCanvas = register({
|
||||||
name: "clearCanvas",
|
name: "clearCanvas",
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState: AppState) => {
|
||||||
app.imageCache.clear();
|
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) =>
|
elements: elements.map((element) =>
|
||||||
newElementWith(element, { isDeleted: true }),
|
newElementWith(element, { isDeleted: true }),
|
||||||
),
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...getDefaultAppState(),
|
...getDefaultAppState(),
|
||||||
files: {},
|
|
||||||
theme: appState.theme,
|
theme: appState.theme,
|
||||||
elementLocked: appState.elementLocked,
|
elementLocked: appState.elementLocked,
|
||||||
penMode: appState.penMode,
|
|
||||||
penDetected: appState.penDetected,
|
|
||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
exportEmbedScene: appState.exportEmbedScene,
|
exportEmbedScene: appState.exportEmbedScene,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
showStats: appState.showStats,
|
showStats: appState.showStats,
|
||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
elementType:
|
|
||||||
appState.elementType === "image" ? "selection" : appState.elementType,
|
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
PanelComponent: ({ updateData }) => (
|
||||||
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={trash}
|
||||||
|
title={t("buttons.clearReset")}
|
||||||
|
aria-label={t("buttons.clearReset")}
|
||||||
|
showAriaLabel={useIsMobile()}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(t("alerts.clearReset"))) {
|
||||||
|
updateData(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-testid="clear-canvas-button"
|
||||||
|
/>
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionZoomIn = register({
|
export const actionZoomIn = register({
|
||||||
name: "zoomIn",
|
name: "zoomIn",
|
||||||
perform: (_elements, appState, _, app) => {
|
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,
|
||||||
...getStateForZoom(
|
zoom,
|
||||||
{
|
|
||||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
|
||||||
viewportY: appState.height / 2 + appState.offsetTop,
|
|
||||||
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
|
||||||
},
|
|
||||||
appState,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -102,7 +108,6 @@ export const actionZoomIn = register({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
size="small"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -112,18 +117,18 @@ export const actionZoomIn = register({
|
|||||||
|
|
||||||
export const actionZoomOut = register({
|
export const actionZoomOut = register({
|
||||||
name: "zoomOut",
|
name: "zoomOut",
|
||||||
perform: (_elements, appState, _, app) => {
|
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,
|
||||||
...getStateForZoom(
|
zoom,
|
||||||
{
|
|
||||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
|
||||||
viewportY: appState.height / 2 + appState.offsetTop,
|
|
||||||
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
|
||||||
},
|
|
||||||
appState,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -137,7 +142,6 @@ export const actionZoomOut = register({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
size="small"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -147,37 +151,33 @@ export const actionZoomOut = register({
|
|||||||
|
|
||||||
export const actionResetZoom = register({
|
export const actionResetZoom = register({
|
||||||
name: "resetZoom",
|
name: "resetZoom",
|
||||||
perform: (_elements, appState, _, app) => {
|
perform: (_elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
...getStateForZoom(
|
zoom: getNewZoom(
|
||||||
|
1 as NormalizedZoomValue,
|
||||||
|
appState.zoom,
|
||||||
|
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||||
{
|
{
|
||||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
x: appState.width / 2,
|
||||||
viewportY: appState.height / 2 + appState.offsetTop,
|
y: appState.height / 2,
|
||||||
nextZoom: getNormalizedZoom(1),
|
|
||||||
},
|
},
|
||||||
appState,
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
|
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
className="reset-zoom-button"
|
icon={resetZoom}
|
||||||
title={t("buttons.resetZoom")}
|
title={t("buttons.resetZoom")}
|
||||||
aria-label={t("buttons.resetZoom")}
|
aria-label={t("buttons.resetZoom")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
size="small"
|
/>
|
||||||
>
|
|
||||||
{(appState.zoom.value * 100).toFixed(0)}%
|
|
||||||
</ToolButton>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||||
@@ -216,12 +216,14 @@ const zoomToFitElements = (
|
|||||||
? getCommonBounds(selectedElements)
|
? getCommonBounds(selectedElements)
|
||||||
: getCommonBounds(nonDeletedElements);
|
: getCommonBounds(nonDeletedElements);
|
||||||
|
|
||||||
const newZoom = {
|
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||||
value: zoomValueToFitBoundsOnViewport(commonBounds, {
|
|
||||||
width: appState.width,
|
width: appState.width,
|
||||||
height: appState.height,
|
height: appState.height,
|
||||||
}),
|
});
|
||||||
};
|
const newZoom = getNewZoom(zoomValue, appState.zoom, {
|
||||||
|
left: appState.offsetLeft,
|
||||||
|
top: appState.offsetTop,
|
||||||
|
});
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = commonBounds;
|
const [x1, y1, x2, y2] = commonBounds;
|
||||||
const centerX = (x1 + x2) / 2;
|
const centerX = (x1 + x2) / 2;
|
||||||
@@ -269,8 +271,7 @@ export const actionToggleTheme = register({
|
|||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
theme:
|
theme: value || (appState.theme === "light" ? "dark" : "light"),
|
||||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
|
@@ -9,8 +9,8 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
export const actionCopy = register({
|
export const actionCopy = register({
|
||||||
name: "copy",
|
name: "copy",
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState) => {
|
||||||
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
copyToClipboard(getNonDeletedElements(elements), appState);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
@@ -25,7 +25,7 @@ export const actionCut = register({
|
|||||||
name: "cut",
|
name: "cut",
|
||||||
perform: (elements, appState, data, app) => {
|
perform: (elements, appState, data, app) => {
|
||||||
actionCopy.perform(elements, appState, data, app);
|
actionCopy.perform(elements, appState, data, app);
|
||||||
return actionDeleteSelected.perform(elements, appState);
|
return actionDeleteSelected.perform(elements, appState, data, app);
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.cut",
|
contextItemLabel: "labels.cut",
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
|
||||||
@@ -42,7 +42,6 @@ export const actionCopyAsSvg = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
@@ -51,13 +50,12 @@ export const actionCopyAsSvg = register({
|
|||||||
? selectedElements
|
? selectedElements
|
||||||
: getNonDeletedElements(elements),
|
: getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
app.files,
|
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@@ -82,7 +80,6 @@ export const actionCopyAsPng = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
@@ -91,7 +88,6 @@ export const actionCopyAsPng = register({
|
|||||||
? selectedElements
|
? selectedElements
|
||||||
: getNonDeletedElements(elements),
|
: getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
app.files,
|
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -108,7 +104,7 @@ export const actionCopyAsPng = register({
|
|||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { isSomeElementSelected } from "../scene";
|
import { 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 { trash } from "../components/icons";
|
import { trash } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
@@ -11,7 +12,6 @@ import { newElementWith } from "../element/mutateElement";
|
|||||||
import { getElementsInGroup } from "../groups";
|
import { getElementsInGroup } from "../groups";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -22,12 +22,6 @@ const deleteSelectedElements = (
|
|||||||
if (appState.selectedElementIds[el.id]) {
|
if (appState.selectedElementIds[el.id]) {
|
||||||
return newElementWith(el, { isDeleted: true });
|
return newElementWith(el, { isDeleted: true });
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
isBoundToContainer(el) &&
|
|
||||||
appState.selectedElementIds[el.containerId]
|
|
||||||
) {
|
|
||||||
return newElementWith(el, { isDeleted: true });
|
|
||||||
}
|
|
||||||
return el;
|
return el;
|
||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
@@ -62,7 +56,7 @@ export const actionDeleteSelected = register({
|
|||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const {
|
||||||
elementId,
|
elementId,
|
||||||
selectedPointsIndices,
|
activePointIndex,
|
||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
} = appState.editingLinearElement;
|
} = appState.editingLinearElement;
|
||||||
@@ -72,7 +66,8 @@ export const actionDeleteSelected = register({
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
// case: no point selected → delete whole element
|
// case: no point selected → delete whole element
|
||||||
selectedPointsIndices == null ||
|
activePointIndex == null ||
|
||||||
|
activePointIndex === -1 ||
|
||||||
// case: deleting last remaining point
|
// case: deleting last remaining point
|
||||||
element.points.length < 2
|
element.points.length < 2
|
||||||
) {
|
) {
|
||||||
@@ -92,17 +87,15 @@ export const actionDeleteSelected = register({
|
|||||||
// We cannot do this inside `movePoint` because it is also called
|
// We cannot do this inside `movePoint` because it is also called
|
||||||
// when deleting the uncommitted point (which hasn't caused any binding)
|
// when deleting the uncommitted point (which hasn't caused any binding)
|
||||||
const binding = {
|
const binding = {
|
||||||
startBindingElement: selectedPointsIndices?.includes(0)
|
startBindingElement:
|
||||||
? null
|
activePointIndex === 0 ? null : startBindingElement,
|
||||||
: startBindingElement,
|
endBindingElement:
|
||||||
endBindingElement: selectedPointsIndices?.includes(
|
activePointIndex === element.points.length - 1
|
||||||
element.points.length - 1,
|
|
||||||
)
|
|
||||||
? null
|
? null
|
||||||
: endBindingElement,
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
LinearElementEditor.movePoint(element, activePointIndex, "delete");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
@@ -111,17 +104,17 @@ export const actionDeleteSelected = register({
|
|||||||
editingLinearElement: {
|
editingLinearElement: {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
...binding,
|
...binding,
|
||||||
selectedPointsIndices:
|
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
|
||||||
selectedPointsIndices?.[0] > 0
|
|
||||||
? [selectedPointsIndices[0] - 1]
|
|
||||||
: [0],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let { elements: nextElements, appState: nextAppState } =
|
|
||||||
deleteSelectedElements(elements, appState);
|
let {
|
||||||
|
elements: nextElements,
|
||||||
|
appState: nextAppState,
|
||||||
|
} = deleteSelectedElements(elements, appState);
|
||||||
fixBindingsAfterDeletion(
|
fixBindingsAfterDeletion(
|
||||||
nextElements,
|
nextElements,
|
||||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||||
|
@@ -1,16 +1,17 @@
|
|||||||
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
DistributeHorizontallyIcon,
|
DistributeHorizontallyIcon,
|
||||||
DistributeVerticallyIcon,
|
DistributeVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { distributeElements, Distribution } from "../disitrubte";
|
import { distributeElements, Distribution } from "../disitrubte";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getElementMap, getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CODES } from "../keys";
|
import { CODES } from "../keys";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
@@ -30,11 +31,9 @@ const distributeSelectedElements = (
|
|||||||
|
|
||||||
const updatedElements = distributeElements(selectedElements, distribution);
|
const updatedElements = distributeElements(selectedElements, distribution);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = getElementMap(updatedElements);
|
||||||
|
|
||||||
return elements.map(
|
return elements.map((element) => updatedElementsMap[element.id] || element);
|
||||||
(element) => updatedElementsMap.get(element.id) || element,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const distributeHorizontally = register({
|
export const distributeHorizontally = register({
|
||||||
|
@@ -1,13 +1,15 @@
|
|||||||
|
import React from "react";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { clone } from "../components/icons";
|
import { clone } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import {
|
import {
|
||||||
selectGroupsForSelectedElements,
|
selectGroupsForSelectedElements,
|
||||||
getSelectedGroupForElement,
|
getSelectedGroupForElement,
|
||||||
@@ -17,23 +19,41 @@ import { AppState } from "../types";
|
|||||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||||
import { ActionResult } from "./types";
|
import { ActionResult } from "./types";
|
||||||
import { GRID_SIZE } from "../constants";
|
import { GRID_SIZE } from "../constants";
|
||||||
import { bindTextToShapeAfterDuplication } from "../element/textElement";
|
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
// duplicate selected point(s) if editing a line
|
// duplicate point if selected while editing multi-point element
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
|
const { activePointIndex, elementId } = appState.editingLinearElement;
|
||||||
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
if (!ret) {
|
if (!element || activePointIndex === null) {
|
||||||
return false;
|
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 {
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingLinearElement: {
|
||||||
|
...appState.editingLinearElement,
|
||||||
|
activePointIndex: activePointIndex + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
elements,
|
elements,
|
||||||
appState: ret.appState,
|
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -87,12 +107,9 @@ const duplicateElements = (
|
|||||||
const finalElements: ExcalidrawElement[] = [];
|
const finalElements: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
const selectedElementIds = arrayToMap(
|
|
||||||
getSelectedElements(elements, appState, true),
|
|
||||||
);
|
|
||||||
while (index < elements.length) {
|
while (index < elements.length) {
|
||||||
const element = elements[index];
|
const element = elements[index];
|
||||||
if (selectedElementIds.get(element.id)) {
|
if (appState.selectedElementIds[element.id]) {
|
||||||
if (element.groupIds.length) {
|
if (element.groupIds.length) {
|
||||||
const groupId = getSelectedGroupForElement(appState, element);
|
const groupId = getSelectedGroupForElement(appState, element);
|
||||||
// if group selected, duplicate it atomically
|
// if group selected, duplicate it atomically
|
||||||
@@ -114,11 +131,7 @@ const duplicateElements = (
|
|||||||
}
|
}
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
bindTextToShapeAfterDuplication(
|
|
||||||
finalElements,
|
|
||||||
oldElements,
|
|
||||||
oldIdToDuplicatedId,
|
|
||||||
);
|
|
||||||
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -128,9 +141,7 @@ const duplicateElements = (
|
|||||||
...appState,
|
...appState,
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectedElementIds: newElements.reduce((acc, element) => {
|
selectedElementIds: newElements.reduce((acc, element) => {
|
||||||
if (!isBoundToContainer(element)) {
|
|
||||||
acc[element.id] = true;
|
acc[element.id] = true;
|
||||||
}
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as any),
|
}, {} as any),
|
||||||
},
|
},
|
||||||
|
@@ -1,25 +1,24 @@
|
|||||||
|
import React from "react";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { load, questionCircle, saveAs } from "../components/icons";
|
import { load, questionCircle, saveAs } from "../components/icons";
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import "../components/ToolIcon.scss";
|
import "../components/ToolIcon.scss";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
|
||||||
import { loadFromJSON, saveAsJSON } from "../data";
|
import { loadFromJSON, saveAsJSON } from "../data";
|
||||||
import { resaveAsImageWithScene } from "../data/resave";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { supported as fsSupported } from "browser-fs-access";
|
||||||
import { CheckboxItem } from "../components/CheckboxItem";
|
import { CheckboxItem } from "../components/CheckboxItem";
|
||||||
import { getExportSize } from "../scene/export";
|
import { getExportSize } from "../scene/export";
|
||||||
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
|
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, IDB_KEYS } from "../constants";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ActiveFile } from "../components/ActiveFile";
|
import { ActiveFile } from "../components/ActiveFile";
|
||||||
import { isImageFileHandle } from "../data/blob";
|
import * as idb from "idb-keyval";
|
||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
|
||||||
import { Theme } from "../element/types";
|
|
||||||
|
|
||||||
export const actionChangeProjectName = register({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
@@ -70,7 +69,7 @@ export const actionChangeExportScale = register({
|
|||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
key={s}
|
key={s}
|
||||||
size="small"
|
size="s"
|
||||||
type="radio"
|
type="radio"
|
||||||
icon={`${s}x`}
|
icon={`${s}x`}
|
||||||
name="export-canvas-scale"
|
name="export-canvas-scale"
|
||||||
@@ -120,7 +119,7 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
>
|
>
|
||||||
{t("labels.exportEmbedScene")}
|
{t("labels.exportEmbedScene")}
|
||||||
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
||||||
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
|
<div className="Tooltip-icon">{questionCircle}</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</CheckboxItem>
|
</CheckboxItem>
|
||||||
),
|
),
|
||||||
@@ -128,21 +127,17 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
|
|
||||||
export const actionSaveToActiveFile = register({
|
export const actionSaveToActiveFile = register({
|
||||||
name: "saveToActiveFile",
|
name: "saveToActiveFile",
|
||||||
perform: async (elements, appState, value, app) => {
|
perform: async (elements, appState, value) => {
|
||||||
const fileHandleExists = !!appState.fileHandle;
|
const fileHandleExists = !!appState.fileHandle;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||||
? await resaveAsImageWithScene(elements, appState, app.files)
|
|
||||||
: await saveAsJSON(elements, appState, app.files);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
fileHandle,
|
fileHandle,
|
||||||
toastMessage: fileHandleExists
|
toastMessage: fileHandleExists
|
||||||
? fileHandle?.name
|
? fileHandle.name
|
||||||
? t("toast.fileSavedToFilename").replace(
|
? t("toast.fileSavedToFilename").replace(
|
||||||
"{filename}",
|
"{filename}",
|
||||||
`"${fileHandle.name}"`,
|
`"${fileHandle.name}"`,
|
||||||
@@ -151,13 +146,26 @@ export const actionSaveToActiveFile = register({
|
|||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
} else {
|
|
||||||
console.warn(error);
|
|
||||||
}
|
}
|
||||||
return { commitToHistory: false };
|
|
||||||
|
if (fileHandleExists && error.name === "AbortError") {
|
||||||
|
try {
|
||||||
|
await idb.del(IDB_KEYS.fileHandle);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
appState: { ...appState, fileHandle: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -172,22 +180,23 @@ export const actionSaveToActiveFile = register({
|
|||||||
|
|
||||||
export const actionSaveFileToDisk = register({
|
export const actionSaveFileToDisk = register({
|
||||||
name: "saveFileToDisk",
|
name: "saveFileToDisk",
|
||||||
perform: async (elements, appState, value, app) => {
|
perform: async (elements, appState, value) => {
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(
|
const { fileHandle } = await saveAsJSON(elements, {
|
||||||
elements,
|
|
||||||
{
|
|
||||||
...appState,
|
...appState,
|
||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
},
|
});
|
||||||
app.files,
|
try {
|
||||||
);
|
if (fileHandle) {
|
||||||
|
await idb.set(IDB_KEYS.fileHandle, fileHandle);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
} else {
|
|
||||||
console.warn(error);
|
|
||||||
}
|
}
|
||||||
return { commitToHistory: false };
|
return { commitToHistory: false };
|
||||||
}
|
}
|
||||||
@@ -201,7 +210,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
title={t("buttons.saveAs")}
|
title={t("buttons.saveAs")}
|
||||||
aria-label={t("buttons.saveAs")}
|
aria-label={t("buttons.saveAs")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
hidden={!nativeFileSystemSupported}
|
hidden={!fsSupported}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
data-testid="save-as-button"
|
data-testid="save-as-button"
|
||||||
/>
|
/>
|
||||||
@@ -210,28 +219,24 @@ export const actionSaveFileToDisk = register({
|
|||||||
|
|
||||||
export const actionLoadScene = register({
|
export const actionLoadScene = register({
|
||||||
name: "loadScene",
|
name: "loadScene",
|
||||||
perform: async (elements, appState, _, app) => {
|
perform: async (elements, appState) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
files,
|
} = await loadFromJSON(appState);
|
||||||
} = await loadFromJSON(appState, elements);
|
|
||||||
return {
|
return {
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
files,
|
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
if (error?.name === "AbortError") {
|
if (error?.name === "AbortError") {
|
||||||
console.warn(error);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState: { ...appState, errorMessage: error.message },
|
appState: { ...appState, errorMessage: error.message },
|
||||||
files: app.files,
|
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -268,9 +273,9 @@ export const actionExportWithDarkMode = register({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DarkModeToggle
|
<DarkModeToggle
|
||||||
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
|
value={appState.exportWithDarkMode ? "dark" : "light"}
|
||||||
onChange={(theme: Theme) => {
|
onChange={(theme: Appearence) => {
|
||||||
updateData(theme === THEME.DARK);
|
updateData(theme === "dark");
|
||||||
}}
|
}}
|
||||||
title={t("labels.toggleExportColorScheme")}
|
title={t("labels.toggleExportColorScheme")}
|
||||||
/>
|
/>
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isInvisiblySmallElement } from "../element";
|
import { isInvisiblySmallElement } from "../element";
|
||||||
import { resetCursor } from "../utils";
|
import { resetCursor } from "../utils";
|
||||||
|
import React from "react";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -19,8 +20,11 @@ export const actionFinalize = register({
|
|||||||
name: "finalize",
|
name: "finalize",
|
||||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const { elementId, startBindingElement, endBindingElement } =
|
const {
|
||||||
appState.editingLinearElement;
|
elementId,
|
||||||
|
startBindingElement,
|
||||||
|
endBindingElement,
|
||||||
|
} = appState.editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -46,11 +50,6 @@ export const actionFinalize = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
|
|
||||||
if (appState.pendingImageElement) {
|
|
||||||
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
focusContainer();
|
focusContainer();
|
||||||
}
|
}
|
||||||
@@ -154,7 +153,6 @@ export const actionFinalize = register({
|
|||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
pendingImageElement: null,
|
|
||||||
},
|
},
|
||||||
commitToHistory: appState.elementType === "freedraw",
|
commitToHistory: appState.elementType === "freedraw",
|
||||||
};
|
};
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getElementMap, getNonDeletedElements } from "../element";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
||||||
@@ -9,7 +9,6 @@ import { getTransformHandles } from "../element/transformHandles";
|
|||||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
||||||
import { updateBoundElements } from "../element/binding";
|
import { updateBoundElements } from "../element/binding";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { arrayToMap } from "../utils";
|
|
||||||
|
|
||||||
const enableActionFlipHorizontal = (
|
const enableActionFlipHorizontal = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -84,11 +83,9 @@ const flipSelectedElements = (
|
|||||||
flipDirection,
|
flipDirection,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = getElementMap(updatedElements);
|
||||||
|
|
||||||
return elements.map(
|
return elements.map((element) => updatedElementsMap[element.id] || element);
|
||||||
(element) => updatedElementsMap.get(element.id) || element,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const flipElements = (
|
const flipElements = (
|
||||||
@@ -96,13 +93,13 @@ const flipElements = (
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
elements.forEach((element) => {
|
for (let i = 0; i < elements.length; i++) {
|
||||||
flipElement(element, appState);
|
flipElement(elements[i], appState);
|
||||||
// If vertical flip, rotate an extra 180
|
// If vertical flip, rotate an extra 180
|
||||||
if (flipDirection === "vertical") {
|
if (flipDirection === "vertical") {
|
||||||
rotateElement(element, Math.PI);
|
rotateElement(elements[i], Math.PI);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
return elements;
|
return elements;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,9 +142,10 @@ const flipElement = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLinearElement(element)) {
|
if (isLinearElement(element)) {
|
||||||
for (let index = 1; index < element.points.length; index++) {
|
for (let i = 1; i < element.points.length; i++) {
|
||||||
LinearElementEditor.movePoints(element, [
|
LinearElementEditor.movePoint(element, i, [
|
||||||
{ index, point: [-element.points[index][0], element.points[index][1]] },
|
-element.points[i][0],
|
||||||
|
element.points[i][1],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
LinearElementEditor.normalizePoints(element);
|
LinearElementEditor.normalizePoints(element);
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { UngroupIcon, GroupIcon } from "../components/icons";
|
import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
@@ -17,9 +18,8 @@ import {
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
|
||||||
|
|
||||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||||
if (elements.length >= 2) {
|
if (elements.length >= 2) {
|
||||||
@@ -45,7 +45,6 @@ const enableActionGroup = (
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||||
@@ -58,7 +57,6 @@ export const actionGroup = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
if (selectedElements.length < 2) {
|
if (selectedElements.length < 2) {
|
||||||
// nothing to group
|
// nothing to group
|
||||||
@@ -86,9 +84,8 @@ export const actionGroup = register({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const newGroupId = randomId();
|
const newGroupId = randomId();
|
||||||
const selectElementIds = arrayToMap(selectedElements);
|
|
||||||
const updatedElements = elements.map((element) => {
|
const updatedElements = elements.map((element) => {
|
||||||
if (!selectElementIds.get(element.id)) {
|
if (!appState.selectedElementIds[element.id]) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
return newElementWith(element, {
|
return newElementWith(element, {
|
||||||
@@ -103,8 +100,9 @@ export const actionGroup = register({
|
|||||||
// to the z order of the highest element in the layer stack
|
// to the z order of the highest element in the layer stack
|
||||||
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
||||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||||
const lastGroupElementIndex =
|
const lastGroupElementIndex = updatedElements.lastIndexOf(
|
||||||
updatedElements.lastIndexOf(lastElementInGroup);
|
lastElementInGroup,
|
||||||
|
);
|
||||||
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
||||||
const elementsBeforeGroup = updatedElements
|
const elementsBeforeGroup = updatedElements
|
||||||
.slice(0, lastGroupElementIndex)
|
.slice(0, lastGroupElementIndex)
|
||||||
@@ -152,12 +150,7 @@ export const actionUngroup = register({
|
|||||||
if (groupIds.length === 0) {
|
if (groupIds.length === 0) {
|
||||||
return { appState, elements, commitToHistory: false };
|
return { appState, elements, commitToHistory: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
|
|
||||||
const nextElements = elements.map((element) => {
|
const nextElements = elements.map((element) => {
|
||||||
if (isBoundToContainer(element)) {
|
|
||||||
boundTextElementIds.push(element.id);
|
|
||||||
}
|
|
||||||
const nextGroupIds = removeFromSelectedGroups(
|
const nextGroupIds = removeFromSelectedGroups(
|
||||||
element.groupIds,
|
element.groupIds,
|
||||||
appState.selectedGroupIds,
|
appState.selectedGroupIds,
|
||||||
@@ -169,19 +162,11 @@ export const actionUngroup = register({
|
|||||||
groupIds: nextGroupIds,
|
groupIds: nextGroupIds,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
const updateAppState = selectGroupsForSelectedElements(
|
appState: selectGroupsForSelectedElements(
|
||||||
{ ...appState, selectedGroupIds: {} },
|
{ ...appState, selectedGroupIds: {} },
|
||||||
getNonDeletedElements(nextElements),
|
getNonDeletedElements(nextElements),
|
||||||
);
|
),
|
||||||
|
|
||||||
// remove binded text elements from selection
|
|
||||||
boundTextElementIds.forEach(
|
|
||||||
(id) => (updateAppState.selectedElementIds[id] = false),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
appState: updateAppState,
|
|
||||||
|
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { Action, ActionResult } from "./types";
|
import { Action, ActionResult } from "./types";
|
||||||
|
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";
|
||||||
@@ -6,9 +7,9 @@ import History, { HistoryEntry } 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 { isWindows, KEYS } from "../keys";
|
||||||
|
import { getElementMap } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
import { arrayToMap } from "../utils";
|
|
||||||
|
|
||||||
const writeData = (
|
const writeData = (
|
||||||
prevElements: readonly ExcalidrawElement[],
|
prevElements: readonly ExcalidrawElement[],
|
||||||
@@ -27,17 +28,17 @@ const writeData = (
|
|||||||
return { commitToHistory };
|
return { commitToHistory };
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevElementMap = arrayToMap(prevElements);
|
const prevElementMap = getElementMap(prevElements);
|
||||||
const nextElements = data.elements;
|
const nextElements = data.elements;
|
||||||
const nextElementMap = arrayToMap(nextElements);
|
const nextElementMap = getElementMap(nextElements);
|
||||||
|
|
||||||
const deletedElements = prevElements.filter(
|
const deletedElements = prevElements.filter(
|
||||||
(prevElement) => !nextElementMap.has(prevElement.id),
|
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
||||||
);
|
);
|
||||||
const elements = nextElements
|
const elements = nextElements
|
||||||
.map((nextElement) =>
|
.map((nextElement) =>
|
||||||
newElementWith(
|
newElementWith(
|
||||||
prevElementMap.get(nextElement.id) || nextElement,
|
prevElementMap[nextElement.id] || nextElement,
|
||||||
nextElement,
|
nextElement,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -68,13 +69,12 @@ export const createUndoAction: ActionCreator = (history) => ({
|
|||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.key.toLowerCase() === KEYS.Z &&
|
event.key.toLowerCase() === KEYS.Z &&
|
||||||
!event.shiftKey,
|
!event.shiftKey,
|
||||||
PanelComponent: ({ updateData, data }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={undo}
|
icon={undo}
|
||||||
aria-label={t("buttons.undo")}
|
aria-label={t("buttons.undo")}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
size={data?.size || "medium"}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
commitToHistory: () => false,
|
commitToHistory: () => false,
|
||||||
@@ -89,13 +89,12 @@ export const createRedoAction: ActionCreator = (history) => ({
|
|||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event.key.toLowerCase() === KEYS.Z) ||
|
event.key.toLowerCase() === KEYS.Z) ||
|
||||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||||
PanelComponent: ({ updateData, data }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={redo}
|
icon={redo}
|
||||||
aria-label={t("buttons.redo")}
|
aria-label={t("buttons.redo")}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
size={data?.size || "medium"}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
commitToHistory: () => false,
|
commitToHistory: () => false,
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { menu, palette } from "../components/icons";
|
import { menu, palette } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { getClientColors, getClientInitials } from "../clients";
|
import { getClientColors, getClientInitials } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
@@ -29,8 +30,8 @@ export const actionGoToCollaborator = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, data }) => {
|
PanelComponent: ({ appState, updateData, id }) => {
|
||||||
const clientId: string | undefined = data?.id;
|
const clientId = id;
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { AppState } from "../../src/types";
|
import { AppState } from "../../src/types";
|
||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
@@ -6,7 +7,6 @@ import {
|
|||||||
ArrowheadArrowIcon,
|
ArrowheadArrowIcon,
|
||||||
ArrowheadBarIcon,
|
ArrowheadBarIcon,
|
||||||
ArrowheadDotIcon,
|
ArrowheadDotIcon,
|
||||||
ArrowheadTriangleIcon,
|
|
||||||
ArrowheadNoneIcon,
|
ArrowheadNoneIcon,
|
||||||
EdgeRoundIcon,
|
EdgeRoundIcon,
|
||||||
EdgeSharpIcon,
|
EdgeSharpIcon,
|
||||||
@@ -41,16 +41,8 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import {
|
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
||||||
getBoundTextElement,
|
|
||||||
getContainerElement,
|
|
||||||
} from "../element/textElement";
|
|
||||||
import {
|
|
||||||
isBoundToContainer,
|
|
||||||
isLinearElement,
|
|
||||||
isLinearElementType,
|
|
||||||
} from "../element/typeChecks";
|
|
||||||
import {
|
import {
|
||||||
Arrowhead,
|
Arrowhead,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -60,34 +52,24 @@ import {
|
|||||||
TextAlign,
|
TextAlign,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getLanguage, t } from "../i18n";
|
import { getLanguage, t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
|
||||||
import { randomInteger } from "../random";
|
import { randomInteger } from "../random";
|
||||||
import {
|
import {
|
||||||
canChangeSharpness,
|
canChangeSharpness,
|
||||||
canHaveArrowheads,
|
canHaveArrowheads,
|
||||||
getCommonAttributeOfSelectedElements,
|
getCommonAttributeOfSelectedElements,
|
||||||
getSelectedElements,
|
|
||||||
getTargetElements,
|
getTargetElements,
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { hasStrokeColor } from "../scene/comparisons";
|
|
||||||
import { arrayToMap } from "../utils";
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
|
||||||
|
|
||||||
const changeProperty = (
|
const changeProperty = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
callback: (element: ExcalidrawElement) => ExcalidrawElement,
|
callback: (element: ExcalidrawElement) => ExcalidrawElement,
|
||||||
includeBoundText = false,
|
|
||||||
) => {
|
) => {
|
||||||
const selectedElementIds = arrayToMap(
|
|
||||||
getSelectedElements(elements, appState, includeBoundText),
|
|
||||||
);
|
|
||||||
return elements.map((element) => {
|
return elements.map((element) => {
|
||||||
if (
|
if (
|
||||||
selectedElementIds.get(element.id) ||
|
appState.selectedElementIds[element.id] ||
|
||||||
element.id === appState.editingElement?.id
|
element.id === appState.editingElement?.id
|
||||||
) {
|
) {
|
||||||
return callback(element);
|
return callback(element);
|
||||||
@@ -117,96 +99,15 @@ const getFormValue = function <T>(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const offsetElementAfterFontResize = (
|
|
||||||
prevElement: ExcalidrawTextElement,
|
|
||||||
nextElement: ExcalidrawTextElement,
|
|
||||||
) => {
|
|
||||||
if (isBoundToContainer(nextElement)) {
|
|
||||||
return nextElement;
|
|
||||||
}
|
|
||||||
return mutateElement(
|
|
||||||
nextElement,
|
|
||||||
{
|
|
||||||
x:
|
|
||||||
prevElement.textAlign === "left"
|
|
||||||
? prevElement.x
|
|
||||||
: prevElement.x +
|
|
||||||
(prevElement.width - nextElement.width) /
|
|
||||||
(prevElement.textAlign === "center" ? 2 : 1),
|
|
||||||
// centering vertically is non-standard, but for Excalidraw I think
|
|
||||||
// it makes sense
|
|
||||||
y: prevElement.y + (prevElement.height - nextElement.height) / 2,
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const changeFontSize = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
getNewFontSize: (element: ExcalidrawTextElement) => number,
|
|
||||||
fallbackValue?: ExcalidrawTextElement["fontSize"],
|
|
||||||
) => {
|
|
||||||
const newFontSizes = new Set<number>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements: changeProperty(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(oldElement) => {
|
|
||||||
if (isTextElement(oldElement)) {
|
|
||||||
const newFontSize = getNewFontSize(oldElement);
|
|
||||||
newFontSizes.add(newFontSize);
|
|
||||||
|
|
||||||
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
|
||||||
fontSize: newFontSize,
|
|
||||||
});
|
|
||||||
redrawTextBoundingBox(
|
|
||||||
newElement,
|
|
||||||
getContainerElement(oldElement),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
|
|
||||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
|
||||||
|
|
||||||
return newElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
return oldElement;
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
// update state only if we've set all select text elements to
|
|
||||||
// the same font size
|
|
||||||
currentItemFontSize:
|
|
||||||
newFontSizes.size === 1
|
|
||||||
? [...newFontSizes][0]
|
|
||||||
: fallbackValue ?? appState.currentItemFontSize,
|
|
||||||
},
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const actionChangeStrokeColor = register({
|
export const actionChangeStrokeColor = register({
|
||||||
name: "changeStrokeColor",
|
name: "changeStrokeColor",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
...(value.currentItemStrokeColor && {
|
...(value.currentItemStrokeColor && {
|
||||||
elements: changeProperty(
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
elements,
|
newElementWith(el, {
|
||||||
appState,
|
|
||||||
(el) => {
|
|
||||||
return hasStrokeColor(el.type)
|
|
||||||
? newElementWith(el, {
|
|
||||||
strokeColor: value.currentItemStrokeColor,
|
strokeColor: value.currentItemStrokeColor,
|
||||||
})
|
}),
|
||||||
: el;
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
@@ -521,7 +422,24 @@ export const actionChangeOpacity = register({
|
|||||||
export const actionChangeFontSize = register({
|
export const actionChangeFontSize = register({
|
||||||
name: "changeFontSize",
|
name: "changeFontSize",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return changeFontSize(elements, appState, () => value, value);
|
return {
|
||||||
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
|
if (isTextElement(el)) {
|
||||||
|
const element: ExcalidrawTextElement = newElementWith(el, {
|
||||||
|
fontSize: value,
|
||||||
|
});
|
||||||
|
redrawTextBoundingBox(element);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}),
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
currentItemFontSize: value,
|
||||||
|
},
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -533,40 +451,27 @@ export const actionChangeFontSize = register({
|
|||||||
value: 16,
|
value: 16,
|
||||||
text: t("labels.small"),
|
text: t("labels.small"),
|
||||||
icon: <FontSizeSmallIcon theme={appState.theme} />,
|
icon: <FontSizeSmallIcon theme={appState.theme} />,
|
||||||
testId: "fontSize-small",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 20,
|
value: 20,
|
||||||
text: t("labels.medium"),
|
text: t("labels.medium"),
|
||||||
icon: <FontSizeMediumIcon theme={appState.theme} />,
|
icon: <FontSizeMediumIcon theme={appState.theme} />,
|
||||||
testId: "fontSize-medium",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 28,
|
value: 28,
|
||||||
text: t("labels.large"),
|
text: t("labels.large"),
|
||||||
icon: <FontSizeLargeIcon theme={appState.theme} />,
|
icon: <FontSizeLargeIcon theme={appState.theme} />,
|
||||||
testId: "fontSize-large",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 36,
|
value: 36,
|
||||||
text: t("labels.veryLarge"),
|
text: t("labels.veryLarge"),
|
||||||
icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
|
icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
|
||||||
testId: "fontSize-veryLarge",
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(element) => {
|
(element) => isTextElement(element) && element.fontSize,
|
||||||
if (isTextElement(element)) {
|
|
||||||
return element.fontSize;
|
|
||||||
}
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
|
||||||
if (boundTextElement) {
|
|
||||||
return boundTextElement.fontSize;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
@@ -575,71 +480,21 @@ export const actionChangeFontSize = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionDecreaseFontSize = register({
|
|
||||||
name: "decreaseFontSize",
|
|
||||||
perform: (elements, appState, value) => {
|
|
||||||
return changeFontSize(elements, appState, (element) =>
|
|
||||||
Math.round(
|
|
||||||
// get previous value before relative increase (doesn't work fully
|
|
||||||
// due to rounding and float precision issues)
|
|
||||||
(1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
keyTest: (event) => {
|
|
||||||
return (
|
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
|
||||||
event.shiftKey &&
|
|
||||||
// KEYS.COMMA needed for MacOS
|
|
||||||
(event.key === KEYS.CHEVRON_LEFT || event.key === KEYS.COMMA)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionIncreaseFontSize = register({
|
|
||||||
name: "increaseFontSize",
|
|
||||||
perform: (elements, appState, value) => {
|
|
||||||
return changeFontSize(elements, appState, (element) =>
|
|
||||||
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
keyTest: (event) => {
|
|
||||||
return (
|
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
|
||||||
event.shiftKey &&
|
|
||||||
// KEYS.PERIOD needed for MacOS
|
|
||||||
(event.key === KEYS.CHEVRON_RIGHT || event.key === KEYS.PERIOD)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionChangeFontFamily = register({
|
export const actionChangeFontFamily = register({
|
||||||
name: "changeFontFamily",
|
name: "changeFontFamily",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
elements,
|
if (isTextElement(el)) {
|
||||||
appState,
|
const element: ExcalidrawTextElement = newElementWith(el, {
|
||||||
(oldElement) => {
|
|
||||||
if (isTextElement(oldElement)) {
|
|
||||||
const newElement: ExcalidrawTextElement = newElementWith(
|
|
||||||
oldElement,
|
|
||||||
{
|
|
||||||
fontFamily: value,
|
fontFamily: value,
|
||||||
},
|
});
|
||||||
);
|
redrawTextBoundingBox(element);
|
||||||
redrawTextBoundingBox(
|
return element;
|
||||||
newElement,
|
|
||||||
getContainerElement(oldElement),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
return newElement;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return oldElement;
|
return el;
|
||||||
},
|
}),
|
||||||
true,
|
|
||||||
),
|
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemFontFamily: value,
|
currentItemFontFamily: value,
|
||||||
@@ -679,16 +534,7 @@ export const actionChangeFontFamily = register({
|
|||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(element) => {
|
(element) => isTextElement(element) && element.fontFamily,
|
||||||
if (isTextElement(element)) {
|
|
||||||
return element.fontFamily;
|
|
||||||
}
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
|
||||||
if (boundTextElement) {
|
|
||||||
return boundTextElement.fontFamily;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
@@ -702,29 +548,17 @@ export const actionChangeTextAlign = register({
|
|||||||
name: "changeTextAlign",
|
name: "changeTextAlign",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
elements,
|
if (isTextElement(el)) {
|
||||||
appState,
|
const element: ExcalidrawTextElement = newElementWith(el, {
|
||||||
(oldElement) => {
|
|
||||||
if (isTextElement(oldElement)) {
|
|
||||||
const newElement: ExcalidrawTextElement = newElementWith(
|
|
||||||
oldElement,
|
|
||||||
{
|
|
||||||
textAlign: value,
|
textAlign: value,
|
||||||
},
|
});
|
||||||
);
|
redrawTextBoundingBox(element);
|
||||||
redrawTextBoundingBox(
|
return element;
|
||||||
newElement,
|
|
||||||
getContainerElement(oldElement),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
return newElement;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return oldElement;
|
return el;
|
||||||
},
|
}),
|
||||||
true,
|
|
||||||
),
|
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemTextAlign: value,
|
currentItemTextAlign: value,
|
||||||
@@ -757,16 +591,7 @@ export const actionChangeTextAlign = register({
|
|||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(element) => {
|
(element) => isTextElement(element) && element.textAlign,
|
||||||
if (isTextElement(element)) {
|
|
||||||
return element.textAlign;
|
|
||||||
}
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
|
||||||
if (boundTextElement) {
|
|
||||||
return boundTextElement.textAlign;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
appState.currentItemTextAlign,
|
appState.currentItemTextAlign,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
@@ -911,14 +736,6 @@ export const actionChangeArrowhead = register({
|
|||||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
|
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
|
||||||
keyBinding: "r",
|
keyBinding: "r",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "triangle",
|
|
||||||
text: t("labels.arrowhead_triangle"),
|
|
||||||
icon: (
|
|
||||||
<ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
|
|
||||||
),
|
|
||||||
keyBinding: "t",
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
value={getFormValue<Arrowhead | null>(
|
value={getFormValue<Arrowhead | null>(
|
||||||
elements,
|
elements,
|
||||||
@@ -961,14 +778,6 @@ export const actionChangeArrowhead = register({
|
|||||||
keyBinding: "r",
|
keyBinding: "r",
|
||||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
|
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "triangle",
|
|
||||||
text: t("labels.arrowhead_triangle"),
|
|
||||||
icon: (
|
|
||||||
<ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} />
|
|
||||||
),
|
|
||||||
keyBinding: "t",
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
value={getFormValue<Arrowhead | null>(
|
value={getFormValue<Arrowhead | null>(
|
||||||
elements,
|
elements,
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { selectGroupsForSelectedElements } from "../groups";
|
import { selectGroupsForSelectedElements } from "../groups";
|
||||||
import { getNonDeletedElements, isTextElement } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
@@ -15,10 +15,7 @@ export const actionSelectAll = register({
|
|||||||
...appState,
|
...appState,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
selectedElementIds: elements.reduce((map, element) => {
|
selectedElementIds: elements.reduce((map, element) => {
|
||||||
if (
|
if (!element.isDeleted) {
|
||||||
!element.isDeleted &&
|
|
||||||
!(isTextElement(element) && element.containerId)
|
|
||||||
) {
|
|
||||||
map[element.id] = true;
|
map[element.id] = true;
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
|
@@ -12,7 +12,6 @@ import {
|
|||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getContainerElement } from "../element/textElement";
|
|
||||||
|
|
||||||
// `copiedStyles` is exported only for tests.
|
// `copiedStyles` is exported only for tests.
|
||||||
export let copiedStyles: string = "{}";
|
export let copiedStyles: string = "{}";
|
||||||
@@ -56,18 +55,13 @@ export const actionPasteStyles = register({
|
|||||||
opacity: pastedElement?.opacity,
|
opacity: pastedElement?.opacity,
|
||||||
roughness: pastedElement?.roughness,
|
roughness: pastedElement?.roughness,
|
||||||
});
|
});
|
||||||
if (isTextElement(newElement) && isTextElement(element)) {
|
if (isTextElement(newElement)) {
|
||||||
mutateElement(newElement, {
|
mutateElement(newElement, {
|
||||||
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
||||||
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
});
|
});
|
||||||
|
redrawTextBoundingBox(newElement);
|
||||||
redrawTextBoundingBox(
|
|
||||||
element,
|
|
||||||
getContainerElement(element),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
|
@@ -10,6 +10,7 @@ export const actionToggleViewMode = register({
|
|||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
viewModeEnabled: !this.checked!(appState),
|
viewModeEnabled: !this.checked!(appState),
|
||||||
|
selectedElementIds: {},
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
|
@@ -1,44 +0,0 @@
|
|||||||
import { getNonDeletedElements } from "../element";
|
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
import { getBoundTextElement, measureText } from "../element/textElement";
|
|
||||||
import { ExcalidrawTextElement } from "../element/types";
|
|
||||||
import { getSelectedElements } from "../scene";
|
|
||||||
import { getFontString } from "../utils";
|
|
||||||
import { register } from "./register";
|
|
||||||
|
|
||||||
export const actionUnbindText = register({
|
|
||||||
name: "unbindText",
|
|
||||||
contextItemLabel: "labels.unbindText",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
selectedElements.forEach((element) => {
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
|
||||||
if (boundTextElement) {
|
|
||||||
const { width, height, baseline } = measureText(
|
|
||||||
boundTextElement.originalText,
|
|
||||||
getFontString(boundTextElement),
|
|
||||||
);
|
|
||||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
|
||||||
containerId: null,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
baseline,
|
|
||||||
text: boundTextElement.originalText,
|
|
||||||
});
|
|
||||||
mutateElement(element, {
|
|
||||||
boundElements: element.boundElements?.filter(
|
|
||||||
(ele) => ele.id !== boundTextElement.id,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
@@ -80,5 +80,3 @@ export { actionToggleGridMode } from "./actionToggleGridMode";
|
|||||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||||
|
|
||||||
export { actionToggleStats } from "./actionToggleStats";
|
export { actionToggleStats } from "./actionToggleStats";
|
||||||
export { actionUnbindText } from "./actionUnbindText";
|
|
||||||
export { actionLink } from "../element/Hyperlink";
|
|
||||||
|
@@ -5,11 +5,20 @@ import {
|
|||||||
UpdaterFn,
|
UpdaterFn,
|
||||||
ActionName,
|
ActionName,
|
||||||
ActionResult,
|
ActionResult,
|
||||||
PanelComponentProps,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppProps, AppState } from "../types";
|
||||||
import { MODES } from "../constants";
|
import { MODES } from "../constants";
|
||||||
|
import Library from "../data/library";
|
||||||
|
|
||||||
|
// This is the <App> component, but for now we don't care about anything but its
|
||||||
|
// `canvas` state.
|
||||||
|
type App = {
|
||||||
|
canvas: HTMLCanvasElement | null;
|
||||||
|
focusContainer: () => void;
|
||||||
|
props: AppProps;
|
||||||
|
library: Library;
|
||||||
|
};
|
||||||
|
|
||||||
export class ActionManager implements ActionsManagerInterface {
|
export class ActionManager implements ActionsManagerInterface {
|
||||||
actions = {} as ActionsManagerInterface["actions"];
|
actions = {} as ActionsManagerInterface["actions"];
|
||||||
@@ -18,13 +27,13 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
|
|
||||||
getAppState: () => Readonly<AppState>;
|
getAppState: () => Readonly<AppState>;
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||||
app: AppClassProperties;
|
app: App;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
updater: UpdaterFn,
|
updater: UpdaterFn,
|
||||||
getAppState: () => AppState,
|
getAppState: () => AppState,
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||||
app: AppClassProperties,
|
app: App,
|
||||||
) {
|
) {
|
||||||
this.updater = (actionResult) => {
|
this.updater = (actionResult) => {
|
||||||
if (actionResult && "then" in actionResult) {
|
if (actionResult && "then" in actionResult) {
|
||||||
@@ -98,10 +107,11 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Id is an attribute that we can use to pass in data like keys.
|
||||||
* @param data additional data sent to the PanelComponent
|
// This is needed for dynamically generated action components
|
||||||
*/
|
// like the user list. We can use this key to extract more
|
||||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
// data from app state. This is an alternative to generic prop hell!
|
||||||
|
renderAction = (name: ActionName, id?: string) => {
|
||||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -129,8 +139,8 @@ 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}
|
appProps={this.app.props}
|
||||||
data={data}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -2,9 +2,7 @@ import { Action } from "./types";
|
|||||||
|
|
||||||
export let actions: readonly Action[] = [];
|
export let actions: readonly Action[] = [];
|
||||||
|
|
||||||
export const register = <T extends Action>(action: T) => {
|
export const register = (action: Action): Action => {
|
||||||
actions = actions.concat(action);
|
actions = actions.concat(action);
|
||||||
return action as T & {
|
return action;
|
||||||
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
@@ -25,8 +25,7 @@ export type ShortcutName =
|
|||||||
| "addToLibrary"
|
| "addToLibrary"
|
||||||
| "viewMode"
|
| "viewMode"
|
||||||
| "flipHorizontal"
|
| "flipHorizontal"
|
||||||
| "flipVertical"
|
| "flipVertical";
|
||||||
| "link";
|
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||||
@@ -63,7 +62,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
flipHorizontal: [getShortcutKey("Shift+H")],
|
flipHorizontal: [getShortcutKey("Shift+H")],
|
||||||
flipVertical: [getShortcutKey("Shift+V")],
|
flipVertical: [getShortcutKey("Shift+V")],
|
||||||
viewMode: [getShortcutKey("Alt+R")],
|
viewMode: [getShortcutKey("Alt+R")],
|
||||||
link: [getShortcutKey("CtrlOrCmd+K")],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||||
|
@@ -1,12 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import {
|
import { AppState, ExcalidrawProps } from "../types";
|
||||||
AppClassProperties,
|
import Library from "../data/library";
|
||||||
AppState,
|
|
||||||
ExcalidrawProps,
|
|
||||||
BinaryFiles,
|
|
||||||
} from "../types";
|
|
||||||
import { ToolButtonSize } from "../components/ToolButton";
|
|
||||||
|
|
||||||
/** if false, the action should be prevented */
|
/** if false, the action should be prevented */
|
||||||
export type ActionResult =
|
export type ActionResult =
|
||||||
@@ -16,18 +11,22 @@ export type ActionResult =
|
|||||||
AppState,
|
AppState,
|
||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
> | null;
|
> | null;
|
||||||
files?: BinaryFiles | null;
|
|
||||||
commitToHistory: boolean;
|
commitToHistory: boolean;
|
||||||
syncHistory?: boolean;
|
syncHistory?: boolean;
|
||||||
replaceFiles?: boolean;
|
|
||||||
}
|
}
|
||||||
| false;
|
| false;
|
||||||
|
|
||||||
|
type AppAPI = {
|
||||||
|
canvas: HTMLCanvasElement | null;
|
||||||
|
focusContainer(): void;
|
||||||
|
library: Library;
|
||||||
|
};
|
||||||
|
|
||||||
type ActionFn = (
|
type ActionFn = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
formData: any,
|
formData: any,
|
||||||
app: AppClassProperties,
|
app: AppAPI,
|
||||||
) => ActionResult | Promise<ActionResult>;
|
) => ActionResult | Promise<ActionResult>;
|
||||||
|
|
||||||
export type UpdaterFn = (res: ActionResult) => void;
|
export type UpdaterFn = (res: ActionResult) => void;
|
||||||
@@ -101,23 +100,17 @@ export type ActionName =
|
|||||||
| "flipVertical"
|
| "flipVertical"
|
||||||
| "viewMode"
|
| "viewMode"
|
||||||
| "exportWithDarkMode"
|
| "exportWithDarkMode"
|
||||||
| "toggleTheme"
|
| "toggleTheme";
|
||||||
| "increaseFontSize"
|
|
||||||
| "decreaseFontSize"
|
|
||||||
| "unbindText"
|
|
||||||
| "link";
|
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export interface Action {
|
||||||
|
name: ActionName;
|
||||||
|
PanelComponent?: React.FC<{
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
appProps: ExcalidrawProps;
|
appProps: ExcalidrawProps;
|
||||||
data?: Partial<{ id: string; size: ToolButtonSize }>;
|
id?: string;
|
||||||
};
|
}>;
|
||||||
|
|
||||||
export interface Action {
|
|
||||||
name: ActionName;
|
|
||||||
PanelComponent?: React.FC<PanelComponentProps>;
|
|
||||||
perform: ActionFn;
|
perform: ActionFn;
|
||||||
keyPriority?: number;
|
keyPriority?: number;
|
||||||
keyTest?: (
|
keyTest?: (
|
||||||
@@ -125,12 +118,7 @@ export interface Action {
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => boolean;
|
) => boolean;
|
||||||
contextItemLabel?:
|
contextItemLabel?: string;
|
||||||
| string
|
|
||||||
| ((
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: Readonly<AppState>,
|
|
||||||
) => string);
|
|
||||||
contextItemPredicate?: (
|
contextItemPredicate?: (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
37
src/align.ts
37
src/align.ts
@@ -1,7 +1,13 @@
|
|||||||
import { ExcalidrawElement } from "./element/types";
|
import { ExcalidrawElement } from "./element/types";
|
||||||
import { newElementWith } from "./element/mutateElement";
|
import { newElementWith } from "./element/mutateElement";
|
||||||
import { Box, getCommonBoundingBox } from "./element/bounds";
|
import { getCommonBounds } from "./element";
|
||||||
import { getMaximumGroups } from "./groups";
|
|
||||||
|
interface Box {
|
||||||
|
minX: number;
|
||||||
|
minY: number;
|
||||||
|
maxX: number;
|
||||||
|
maxY: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Alignment {
|
export interface Alignment {
|
||||||
position: "start" | "center" | "end";
|
position: "start" | "center" | "end";
|
||||||
@@ -31,6 +37,28 @@ export const alignElements = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = (
|
const calculateTranslation = (
|
||||||
group: ExcalidrawElement[],
|
group: ExcalidrawElement[],
|
||||||
selectionBoundingBox: Box,
|
selectionBoundingBox: Box,
|
||||||
@@ -60,3 +88,8 @@ const calculateTranslation = (
|
|||||||
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
|
||||||
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
|
return { minX, minY, maxX, maxY };
|
||||||
|
};
|
||||||
|
170
src/appState.ts
170
src/appState.ts
@@ -4,7 +4,6 @@ import {
|
|||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
EXPORT_SCALES,
|
EXPORT_SCALES,
|
||||||
THEME,
|
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
import { AppState, NormalizedZoomValue } from "./types";
|
import { AppState, NormalizedZoomValue } from "./types";
|
||||||
@@ -19,7 +18,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
> => {
|
> => {
|
||||||
return {
|
return {
|
||||||
theme: THEME.LIGHT,
|
theme: "light",
|
||||||
collaborators: new Map(),
|
collaborators: new Map(),
|
||||||
currentChartType: "bar",
|
currentChartType: "bar",
|
||||||
currentItemBackgroundColor: "transparent",
|
currentItemBackgroundColor: "transparent",
|
||||||
@@ -43,8 +42,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
elementLocked: false,
|
elementLocked: false,
|
||||||
elementType: "selection",
|
elementType: "selection",
|
||||||
penMode: false,
|
|
||||||
penDetected: false,
|
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
exportBackground: true,
|
exportBackground: true,
|
||||||
exportScale: defaultExportScale,
|
exportScale: defaultExportScale,
|
||||||
@@ -79,12 +76,8 @@ export const getDefaultAppState = (): Omit<
|
|||||||
toastMessage: null,
|
toastMessage: null,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: {
|
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||||
value: 1 as NormalizedZoomValue,
|
|
||||||
},
|
|
||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
pendingImageElement: null,
|
|
||||||
showHyperlinkPopup: false,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,89 +91,78 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
browser: boolean;
|
browser: boolean;
|
||||||
/** whether to keep when exporting to file/database */
|
/** whether to keep when exporting to file/database */
|
||||||
export: boolean;
|
export: boolean;
|
||||||
/** server (shareLink/collab/...) */
|
|
||||||
server: boolean;
|
|
||||||
},
|
},
|
||||||
T extends Record<keyof AppState, Values>,
|
T extends Record<keyof AppState, Values>
|
||||||
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
|
>(
|
||||||
config)({
|
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
||||||
theme: { browser: true, export: false, server: false },
|
) => config)({
|
||||||
collaborators: { browser: false, export: false, server: false },
|
theme: { browser: true, export: false },
|
||||||
currentChartType: { browser: true, export: false, server: false },
|
collaborators: { browser: false, export: false },
|
||||||
currentItemBackgroundColor: { browser: true, export: false, server: false },
|
currentChartType: { browser: true, export: false },
|
||||||
currentItemEndArrowhead: { browser: true, export: false, server: false },
|
currentItemBackgroundColor: { browser: true, export: false },
|
||||||
currentItemFillStyle: { browser: true, export: false, server: false },
|
currentItemEndArrowhead: { browser: true, export: false },
|
||||||
currentItemFontFamily: { browser: true, export: false, server: false },
|
currentItemFillStyle: { browser: true, export: false },
|
||||||
currentItemFontSize: { browser: true, export: false, server: false },
|
currentItemFontFamily: { browser: true, export: false },
|
||||||
currentItemLinearStrokeSharpness: {
|
currentItemFontSize: { browser: true, export: false },
|
||||||
browser: true,
|
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
||||||
export: false,
|
currentItemOpacity: { browser: true, export: false },
|
||||||
server: false,
|
currentItemRoughness: { browser: true, export: false },
|
||||||
},
|
currentItemStartArrowhead: { browser: true, export: false },
|
||||||
currentItemOpacity: { browser: true, export: false, server: false },
|
currentItemStrokeColor: { browser: true, export: false },
|
||||||
currentItemRoughness: { browser: true, export: false, server: false },
|
currentItemStrokeSharpness: { browser: true, export: false },
|
||||||
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
currentItemStrokeStyle: { browser: true, export: false },
|
||||||
currentItemStrokeColor: { browser: true, export: false, server: false },
|
currentItemStrokeWidth: { browser: true, export: false },
|
||||||
currentItemStrokeSharpness: { browser: true, export: false, server: false },
|
currentItemTextAlign: { browser: true, export: false },
|
||||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
cursorButton: { browser: true, export: false },
|
||||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
draggingElement: { browser: false, export: false },
|
||||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
editingElement: { browser: false, export: false },
|
||||||
cursorButton: { browser: true, export: false, server: false },
|
editingGroupId: { browser: true, export: false },
|
||||||
draggingElement: { browser: false, export: false, server: false },
|
editingLinearElement: { browser: false, export: false },
|
||||||
editingElement: { browser: false, export: false, server: false },
|
elementLocked: { browser: true, export: false },
|
||||||
editingGroupId: { browser: true, export: false, server: false },
|
elementType: { browser: true, export: false },
|
||||||
editingLinearElement: { browser: false, export: false, server: false },
|
errorMessage: { browser: false, export: false },
|
||||||
elementLocked: { browser: true, export: false, server: false },
|
exportBackground: { browser: true, export: false },
|
||||||
elementType: { browser: true, export: false, server: false },
|
exportEmbedScene: { browser: true, export: false },
|
||||||
penMode: { browser: false, export: false, server: false },
|
exportScale: { browser: true, export: false },
|
||||||
penDetected: { browser: false, export: false, server: false },
|
exportWithDarkMode: { browser: true, export: false },
|
||||||
errorMessage: { browser: false, export: false, server: false },
|
fileHandle: { browser: false, export: false },
|
||||||
exportBackground: { browser: true, export: false, server: false },
|
gridSize: { browser: true, export: true },
|
||||||
exportEmbedScene: { browser: true, export: false, server: false },
|
height: { browser: false, export: false },
|
||||||
exportScale: { browser: true, export: false, server: false },
|
isBindingEnabled: { browser: false, export: false },
|
||||||
exportWithDarkMode: { browser: true, export: false, server: false },
|
isLibraryOpen: { browser: false, export: false },
|
||||||
fileHandle: { browser: false, export: false, server: false },
|
isLoading: { browser: false, export: false },
|
||||||
gridSize: { browser: true, export: true, server: true },
|
isResizing: { browser: false, export: false },
|
||||||
height: { browser: false, export: false, server: false },
|
isRotating: { browser: false, export: false },
|
||||||
isBindingEnabled: { browser: false, export: false, server: false },
|
lastPointerDownWith: { browser: true, export: false },
|
||||||
isLibraryOpen: { browser: false, export: false, server: false },
|
multiElement: { browser: false, export: false },
|
||||||
isLoading: { browser: false, export: false, server: false },
|
name: { browser: true, export: false },
|
||||||
isResizing: { browser: false, export: false, server: false },
|
offsetLeft: { browser: false, export: false },
|
||||||
isRotating: { browser: false, export: false, server: false },
|
offsetTop: { browser: false, export: false },
|
||||||
lastPointerDownWith: { browser: true, export: false, server: false },
|
openMenu: { browser: true, export: false },
|
||||||
multiElement: { browser: false, export: false, server: false },
|
openPopup: { browser: false, export: false },
|
||||||
name: { browser: true, export: false, server: false },
|
pasteDialog: { browser: false, export: false },
|
||||||
offsetLeft: { browser: false, export: false, server: false },
|
previousSelectedElementIds: { browser: true, export: false },
|
||||||
offsetTop: { browser: false, export: false, server: false },
|
resizingElement: { browser: false, export: false },
|
||||||
openMenu: { browser: true, export: false, server: false },
|
scrolledOutside: { browser: true, export: false },
|
||||||
openPopup: { browser: false, export: false, server: false },
|
scrollX: { browser: true, export: false },
|
||||||
pasteDialog: { browser: false, export: false, server: false },
|
scrollY: { browser: true, export: false },
|
||||||
previousSelectedElementIds: { browser: true, export: false, server: false },
|
selectedElementIds: { browser: true, export: false },
|
||||||
resizingElement: { browser: false, export: false, server: false },
|
selectedGroupIds: { browser: true, export: false },
|
||||||
scrolledOutside: { browser: true, export: false, server: false },
|
selectionElement: { browser: false, export: false },
|
||||||
scrollX: { browser: true, export: false, server: false },
|
shouldCacheIgnoreZoom: { browser: true, export: false },
|
||||||
scrollY: { browser: true, export: false, server: false },
|
showHelpDialog: { browser: false, export: false },
|
||||||
selectedElementIds: { browser: true, export: false, server: false },
|
showStats: { browser: true, export: false },
|
||||||
selectedGroupIds: { browser: true, export: false, server: false },
|
startBoundElement: { browser: false, export: false },
|
||||||
selectionElement: { browser: false, export: false, server: false },
|
suggestedBindings: { browser: false, export: false },
|
||||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
toastMessage: { browser: false, export: false },
|
||||||
showHelpDialog: { browser: false, export: false, server: false },
|
viewBackgroundColor: { browser: true, export: true },
|
||||||
showStats: { browser: true, export: false, server: false },
|
width: { browser: false, export: false },
|
||||||
startBoundElement: { browser: false, export: false, server: false },
|
zenModeEnabled: { browser: true, export: false },
|
||||||
suggestedBindings: { browser: false, export: false, server: false },
|
zoom: { browser: true, export: false },
|
||||||
toastMessage: { browser: false, export: false, server: false },
|
viewModeEnabled: { browser: false, export: false },
|
||||||
viewBackgroundColor: { browser: true, export: true, server: true },
|
|
||||||
width: { browser: false, export: false, server: false },
|
|
||||||
zenModeEnabled: { browser: true, export: false, server: false },
|
|
||||||
zoom: { browser: true, export: false, server: false },
|
|
||||||
viewModeEnabled: { browser: false, export: false, server: false },
|
|
||||||
pendingImageElement: { browser: false, export: false, server: false },
|
|
||||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
||||||
ExportType extends "export" | "browser" | "server",
|
|
||||||
>(
|
|
||||||
appState: Partial<AppState>,
|
appState: Partial<AppState>,
|
||||||
exportType: ExportType,
|
exportType: ExportType,
|
||||||
) => {
|
) => {
|
||||||
@@ -193,10 +175,8 @@ const _clearAppStateForStorage = <
|
|||||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
||||||
const propConfig = APP_STATE_STORAGE_CONF[key];
|
const propConfig = APP_STATE_STORAGE_CONF[key];
|
||||||
if (propConfig?.[exportType]) {
|
if (propConfig?.[exportType]) {
|
||||||
const nextValue = appState[key];
|
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
||||||
|
stateForExport[key] = appState[key];
|
||||||
// https://github.com/microsoft/TypeScript/issues/31445
|
|
||||||
(stateForExport as any)[key] = nextValue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return stateForExport;
|
return stateForExport;
|
||||||
@@ -209,7 +189,3 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
|
|||||||
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
||||||
return _clearAppStateForStorage(appState, "export");
|
return _clearAppStateForStorage(appState, "export");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
|
|
||||||
return _clearAppStateForStorage(appState, "server");
|
|
||||||
};
|
|
||||||
|
@@ -3,22 +3,19 @@ import {
|
|||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { getSelectedElements } from "./scene";
|
import { getSelectedElements } from "./scene";
|
||||||
import { AppState, BinaryFiles } 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 { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
import { EXPORT_DATA_TYPES } from "./constants";
|
||||||
import { isInitializedImageElement } from "./element/typeChecks";
|
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
elements: ExcalidrawElement[];
|
elements: ExcalidrawElement[];
|
||||||
files: BinaryFiles | undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ClipboardData {
|
export interface ClipboardData {
|
||||||
spreadsheet?: Spreadsheet;
|
spreadsheet?: Spreadsheet;
|
||||||
elements?: readonly ExcalidrawElement[];
|
elements?: readonly ExcalidrawElement[];
|
||||||
files?: BinaryFiles;
|
|
||||||
text?: string;
|
text?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
@@ -40,7 +37,7 @@ export const probablySupportsClipboardBlob =
|
|||||||
|
|
||||||
const clipboardContainsElements = (
|
const clipboardContainsElements = (
|
||||||
contents: any,
|
contents: any,
|
||||||
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
|
): contents is { elements: ExcalidrawElement[] } => {
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
EXPORT_DATA_TYPES.excalidraw,
|
EXPORT_DATA_TYPES.excalidraw,
|
||||||
@@ -56,26 +53,17 @@ const clipboardContainsElements = (
|
|||||||
export const copyToClipboard = async (
|
export const copyToClipboard = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
|
||||||
) => {
|
) => {
|
||||||
// select binded text elements when copying
|
|
||||||
const selectedElements = getSelectedElements(elements, appState, true);
|
|
||||||
const contents: ElementsClipboard = {
|
const contents: ElementsClipboard = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||||
elements: selectedElements,
|
elements: getSelectedElements(elements, appState),
|
||||||
files: selectedElements.reduce((acc, element) => {
|
|
||||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
|
||||||
acc[element.fileId] = files[element.fileId];
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {} as BinaryFiles),
|
|
||||||
};
|
};
|
||||||
const json = JSON.stringify(contents);
|
const json = JSON.stringify(contents);
|
||||||
CLIPBOARD = json;
|
CLIPBOARD = json;
|
||||||
try {
|
try {
|
||||||
PREFER_APP_CLIPBOARD = false;
|
PREFER_APP_CLIPBOARD = false;
|
||||||
await copyTextToSystemClipboard(json);
|
await copyTextToSystemClipboard(json);
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
PREFER_APP_CLIPBOARD = true;
|
PREFER_APP_CLIPBOARD = true;
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
@@ -88,7 +76,7 @@ const getAppClipboard = (): Partial<ElementsClipboard> => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(CLIPBOARD);
|
return JSON.parse(CLIPBOARD);
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -150,10 +138,7 @@ export const parseClipboard = async (
|
|||||||
try {
|
try {
|
||||||
const systemClipboardData = JSON.parse(systemClipboard);
|
const systemClipboardData = JSON.parse(systemClipboard);
|
||||||
if (clipboardContainsElements(systemClipboardData)) {
|
if (clipboardContainsElements(systemClipboardData)) {
|
||||||
return {
|
return { elements: systemClipboardData.elements };
|
||||||
elements: systemClipboardData.elements,
|
|
||||||
files: systemClipboardData.files,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return appClipboardData;
|
return appClipboardData;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -168,7 +153,7 @@ export const parseClipboard = async (
|
|||||||
|
|
||||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
||||||
await navigator.clipboard.write([
|
await navigator.clipboard.write([
|
||||||
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
|
new window.ClipboardItem({ "image/png": blob }),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -180,7 +165,7 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
|
|||||||
// not focused
|
// not focused
|
||||||
await navigator.clipboard.writeText(text || "");
|
await navigator.clipboard.writeText(text || "");
|
||||||
copied = true;
|
copied = true;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,7 +205,7 @@ const copyTextViaExecCommand = (text: string) => {
|
|||||||
textarea.setSelectionRange(0, textarea.value.length);
|
textarea.setSelectionRange(0, textarea.value.length);
|
||||||
|
|
||||||
success = document.execCommand("copy");
|
success = document.execCommand("copy");
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +18,6 @@ import { AppState, Zoom } from "../types";
|
|||||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { hasStrokeColor } from "../scene/comparisons";
|
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
@@ -49,22 +48,9 @@ export const SelectedShapeActions = ({
|
|||||||
hasBackground(elementType) ||
|
hasBackground(elementType) ||
|
||||||
targetElements.some((element) => hasBackground(element.type));
|
targetElements.some((element) => hasBackground(element.type));
|
||||||
|
|
||||||
let commonSelectedType: string | null = targetElements[0]?.type || null;
|
|
||||||
|
|
||||||
for (const element of targetElements) {
|
|
||||||
if (element.type !== commonSelectedType) {
|
|
||||||
commonSelectedType = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
{((hasStrokeColor(elementType) &&
|
{renderAction("changeStrokeColor")}
|
||||||
elementType !== "image" &&
|
|
||||||
commonSelectedType !== "image") ||
|
|
||||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
|
||||||
renderAction("changeStrokeColor")}
|
|
||||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||||
{showFillIcons && renderAction("changeFillStyle")}
|
{showFillIcons && renderAction("changeFillStyle")}
|
||||||
|
|
||||||
@@ -150,15 +136,14 @@ export const SelectedShapeActions = ({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
{!isEditing && targetElements.length > 0 && (
|
{!isMobile && !isEditing && targetElements.length > 0 && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.actions")}</legend>
|
<legend>{t("labels.actions")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
{!isMobile && renderAction("duplicateSelection")}
|
{renderAction("duplicateSelection")}
|
||||||
{!isMobile && renderAction("deleteSelectedElements")}
|
{renderAction("deleteSelectedElements")}
|
||||||
{renderAction("group")}
|
{renderAction("group")}
|
||||||
{renderAction("ungroup")}
|
{renderAction("ungroup")}
|
||||||
{targetElements.length === 1 && renderAction("link")}
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
@@ -170,20 +155,18 @@ export const ShapesSwitcher = ({
|
|||||||
canvas,
|
canvas,
|
||||||
elementType,
|
elementType,
|
||||||
setAppState,
|
setAppState,
|
||||||
onImageAction,
|
|
||||||
}: {
|
}: {
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
elementType: ExcalidrawElement["type"];
|
elementType: ExcalidrawElement["type"];
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
|
||||||
}) => (
|
}) => (
|
||||||
<>
|
<>
|
||||||
{SHAPES.map(({ value, icon, key }, index) => {
|
{SHAPES.map(({ value, icon, key }, index) => {
|
||||||
const label = t(`toolBar.${value}`);
|
const label = t(`toolBar.${value}`);
|
||||||
const letter = key && (typeof key === "string" ? key : key[0]);
|
const letter = typeof key === "string" ? key : key[0];
|
||||||
const shortcut = letter
|
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
|
||||||
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
|
index + 1
|
||||||
: `${index + 1}`;
|
}`;
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
className="Shape"
|
className="Shape"
|
||||||
@@ -197,16 +180,14 @@ export const ShapesSwitcher = ({
|
|||||||
aria-label={capitalizeString(label)}
|
aria-label={capitalizeString(label)}
|
||||||
aria-keyshortcuts={shortcut}
|
aria-keyshortcuts={shortcut}
|
||||||
data-testid={value}
|
data-testid={value}
|
||||||
onChange={({ pointerType }) => {
|
onChange={() => {
|
||||||
setAppState({
|
setAppState({
|
||||||
elementType: value,
|
elementType: value,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
});
|
});
|
||||||
setCursorForShape(canvas, value);
|
setCursorForShape(canvas, value);
|
||||||
if (value === "image") {
|
setAppState({});
|
||||||
onImageAction({ pointerType });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -223,9 +204,12 @@ export const ZoomActions = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<Stack.Col gap={1}>
|
<Stack.Col gap={1}>
|
||||||
<Stack.Row gap={1} align="center">
|
<Stack.Row gap={1} align="center">
|
||||||
{renderAction("zoomOut")}
|
|
||||||
{renderAction("zoomIn")}
|
{renderAction("zoomIn")}
|
||||||
|
{renderAction("zoomOut")}
|
||||||
{renderAction("resetZoom")}
|
{renderAction("resetZoom")}
|
||||||
|
<div style={{ marginInlineStart: 4 }}>
|
||||||
|
{(zoom.value * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
);
|
);
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import Stack from "../components/Stack";
|
import Stack from "../components/Stack";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { save, file } from "../components/icons";
|
import { save, file } from "../components/icons";
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||||
@@ -7,7 +8,7 @@ export const ButtonIconSelect = <T extends Object>({
|
|||||||
onChange,
|
onChange,
|
||||||
group,
|
group,
|
||||||
}: {
|
}: {
|
||||||
options: { value: T; text: string; icon: JSX.Element; testId?: string }[];
|
options: { value: T; text: string; icon: JSX.Element }[];
|
||||||
value: T | null;
|
value: T | null;
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
group: string;
|
group: string;
|
||||||
@@ -24,7 +25,6 @@ export const ButtonIconSelect = <T extends Object>({
|
|||||||
name={group}
|
name={group}
|
||||||
onChange={() => onChange(option.value)}
|
onChange={() => onChange(option.value)}
|
||||||
checked={value === option.value}
|
checked={value === option.value}
|
||||||
data-testid={option.testId}
|
|
||||||
/>
|
/>
|
||||||
{option.icon}
|
{option.icon}
|
||||||
</label>
|
</label>
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const ButtonSelect = <T extends Object>({
|
export const ButtonSelect = <T extends Object>({
|
||||||
|
@@ -48,10 +48,6 @@
|
|||||||
.ToolIcon__label {
|
.ToolIcon__label {
|
||||||
color: $oc-white;
|
color: $oc-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Spinner {
|
|
||||||
--spinner-color: #fff;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,22 +3,15 @@ import OpenColor from "open-color";
|
|||||||
import "./Card.scss";
|
import "./Card.scss";
|
||||||
|
|
||||||
export const Card: React.FC<{
|
export const Card: React.FC<{
|
||||||
color: keyof OpenColor | "primary";
|
color: keyof OpenColor;
|
||||||
}> = ({ children, color }) => {
|
}> = ({ children, color }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="Card"
|
className="Card"
|
||||||
style={{
|
style={{
|
||||||
["--card-color" as any]:
|
["--card-color" as any]: OpenColor[color][7],
|
||||||
color === "primary" ? "var(--color-primary)" : OpenColor[color][7],
|
["--card-color-darker" as any]: OpenColor[color][8],
|
||||||
["--card-color-darker" as any]:
|
["--card-color-darkest" as any]: OpenColor[color][9],
|
||||||
color === "primary"
|
|
||||||
? "var(--color-primary-darker)"
|
|
||||||
: OpenColor[color][8],
|
|
||||||
["--card-color-darkest" as any]:
|
|
||||||
color === "primary"
|
|
||||||
? "var(--color-primary-darkest)"
|
|
||||||
: OpenColor[color][9],
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@@ -81,7 +81,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.excalidraw-tooltip-icon {
|
.Tooltip-icon {
|
||||||
width: 1em;
|
width: 1em;
|
||||||
height: 1em;
|
height: 1em;
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { checkIcon } from "./icons";
|
import { checkIcon } from "./icons";
|
||||||
|
|
||||||
@@ -6,19 +5,16 @@ import "./CheckboxItem.scss";
|
|||||||
|
|
||||||
export const CheckboxItem: React.FC<{
|
export const CheckboxItem: React.FC<{
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (checked: boolean, event: React.MouseEvent) => void;
|
onChange: (checked: boolean) => void;
|
||||||
className?: string;
|
}> = ({ children, checked, onChange }) => {
|
||||||
}> = ({ children, checked, onChange, className }) => {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("Checkbox", className, { "is-checked": checked })}
|
className={clsx("Checkbox", { "is-checked": checked })}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
onChange(!checked, event);
|
onChange(!checked);
|
||||||
(
|
((event.currentTarget as HTMLDivElement).querySelector(
|
||||||
(event.currentTarget as HTMLDivElement).querySelector(
|
|
||||||
".Checkbox-box",
|
".Checkbox-box",
|
||||||
) as HTMLButtonElement
|
) as HTMLButtonElement).focus();
|
||||||
).focus();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
||||||
|
@@ -1,43 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { useIsMobile } from "./App";
|
|
||||||
import { trash } from "./icons";
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
|
|
||||||
import ConfirmDialog from "./ConfirmDialog";
|
|
||||||
|
|
||||||
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
|
||||||
const toggleDialog = () => {
|
|
||||||
setShowDialog(!showDialog);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
icon={trash}
|
|
||||||
title={t("buttons.clearReset")}
|
|
||||||
aria-label={t("buttons.clearReset")}
|
|
||||||
showAriaLabel={useIsMobile()}
|
|
||||||
onClick={toggleDialog}
|
|
||||||
data-testid="clear-canvas-button"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showDialog && (
|
|
||||||
<ConfirmDialog
|
|
||||||
onConfirm={() => {
|
|
||||||
onConfirm();
|
|
||||||
toggleDialog();
|
|
||||||
}}
|
|
||||||
onCancel={toggleDialog}
|
|
||||||
title={t("clearCanvasDialog.title")}
|
|
||||||
>
|
|
||||||
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
|
|
||||||
</ConfirmDialog>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ClearCanvas;
|
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
import { isTransparent } from "../utils";
|
|
||||||
|
|
||||||
import "./ColorPicker.scss";
|
import "./ColorPicker.scss";
|
||||||
import { isArrowKey, KEYS } from "../keys";
|
import { isArrowKey, KEYS } from "../keys";
|
||||||
@@ -15,7 +14,7 @@ const isValidColor = (color: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getColor = (color: string): string | null => {
|
const getColor = (color: string): string | null => {
|
||||||
if (isTransparent(color)) {
|
if (color === "transparent") {
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,19 +137,15 @@ const Picker = ({
|
|||||||
}}
|
}}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{colors.map((_color, i) => {
|
{colors.map((_color, i) => (
|
||||||
const _colorWithoutHash = _color.replace("#", "");
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
className="color-picker-swatch"
|
className="color-picker-swatch"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
(event.currentTarget as HTMLButtonElement).focus();
|
(event.currentTarget as HTMLButtonElement).focus();
|
||||||
onChange(_color);
|
onChange(_color);
|
||||||
}}
|
}}
|
||||||
title={`${t(`colors.${_colorWithoutHash}`)}${
|
title={`${_color} — ${keyBindings[i].toUpperCase()}`}
|
||||||
!isTransparent(_color) ? ` (${_color})` : ""
|
aria-label={_color}
|
||||||
} — ${keyBindings[i].toUpperCase()}`}
|
|
||||||
aria-label={t(`colors.${_colorWithoutHash}`)}
|
|
||||||
aria-keyshortcuts={keyBindings[i]}
|
aria-keyshortcuts={keyBindings[i]}
|
||||||
style={{ color: _color }}
|
style={{ color: _color }}
|
||||||
key={_color}
|
key={_color}
|
||||||
@@ -166,13 +161,12 @@ const Picker = ({
|
|||||||
onChange(_color);
|
onChange(_color);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isTransparent(_color) ? (
|
{_color === "transparent" ? (
|
||||||
<div className="color-picker-transparent"></div>
|
<div className="color-picker-transparent"></div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
{showInput && (
|
{showInput && (
|
||||||
<ColorInput
|
<ColorInput
|
||||||
color={color}
|
color={color}
|
||||||
|
@@ -1,37 +0,0 @@
|
|||||||
@import "../css/variables.module";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.confirm-dialog {
|
|
||||||
&-buttons {
|
|
||||||
display: flex;
|
|
||||||
padding: 0.2rem 0;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
.ToolIcon__icon {
|
|
||||||
min-width: 2.5rem;
|
|
||||||
width: auto;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon_type_button {
|
|
||||||
margin-left: 0.8rem;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__content {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--confirm.ToolIcon_type_button {
|
|
||||||
background-color: $oc-red-6;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $oc-red-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__icon {
|
|
||||||
color: $oc-white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,52 +0,0 @@
|
|||||||
import { t } from "../i18n";
|
|
||||||
import { Dialog, DialogProps } from "./Dialog";
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
|
|
||||||
import "./ConfirmDialog.scss";
|
|
||||||
|
|
||||||
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
confirmText?: string;
|
|
||||||
cancelText?: string;
|
|
||||||
}
|
|
||||||
const ConfirmDialog = (props: Props) => {
|
|
||||||
const {
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
children,
|
|
||||||
confirmText = t("buttons.confirm"),
|
|
||||||
cancelText = t("buttons.cancel"),
|
|
||||||
className = "",
|
|
||||||
...rest
|
|
||||||
} = props;
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
onCloseRequest={onCancel}
|
|
||||||
small={true}
|
|
||||||
{...rest}
|
|
||||||
className={`confirm-dialog ${className}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<div className="confirm-dialog-buttons">
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
title={cancelText}
|
|
||||||
aria-label={cancelText}
|
|
||||||
label={cancelText}
|
|
||||||
onClick={onCancel}
|
|
||||||
className="confirm-dialog--cancel"
|
|
||||||
/>
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
title={confirmText}
|
|
||||||
aria-label={confirmText}
|
|
||||||
label={confirmText}
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="confirm-dialog--confirm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default ConfirmDialog;
|
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { render, unmountComponentAtNode } from "react-dom";
|
import { render, unmountComponentAtNode } from "react-dom";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
@@ -11,7 +12,6 @@ import {
|
|||||||
import { Action } from "../actions/types";
|
import { Action } from "../actions/types";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
|
||||||
|
|
||||||
export type ContextMenuOption = "separator" | Action;
|
export type ContextMenuOption = "separator" | Action;
|
||||||
|
|
||||||
@@ -22,7 +22,6 @@ type ContextMenuProps = {
|
|||||||
left: number;
|
left: number;
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
appState: Readonly<AppState>;
|
appState: Readonly<AppState>;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContextMenu = ({
|
const ContextMenu = ({
|
||||||
@@ -32,7 +31,6 @@ const ContextMenu = ({
|
|||||||
left,
|
left,
|
||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
elements,
|
|
||||||
}: ContextMenuProps) => {
|
}: ContextMenuProps) => {
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@@ -40,10 +38,6 @@ const ContextMenu = ({
|
|||||||
top={top}
|
top={top}
|
||||||
left={left}
|
left={left}
|
||||||
fitInViewport={true}
|
fitInViewport={true}
|
||||||
offsetLeft={appState.offsetLeft}
|
|
||||||
offsetTop={appState.offsetTop}
|
|
||||||
viewportWidth={appState.width}
|
|
||||||
viewportHeight={appState.height}
|
|
||||||
>
|
>
|
||||||
<ul
|
<ul
|
||||||
className="context-menu"
|
className="context-menu"
|
||||||
@@ -55,14 +49,9 @@ const ContextMenu = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actionName = option.name;
|
const actionName = option.name;
|
||||||
let label = "";
|
const label = option.contextItemLabel
|
||||||
if (option.contextItemLabel) {
|
? t(option.contextItemLabel)
|
||||||
if (typeof option.contextItemLabel === "function") {
|
: "";
|
||||||
label = t(option.contextItemLabel(elements, appState));
|
|
||||||
} else {
|
|
||||||
label = t(option.contextItemLabel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
|
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
|
||||||
<button
|
<button
|
||||||
@@ -109,7 +98,6 @@ type ContextMenuParams = {
|
|||||||
actionManager: ContextMenuProps["actionManager"];
|
actionManager: ContextMenuProps["actionManager"];
|
||||||
appState: Readonly<AppState>;
|
appState: Readonly<AppState>;
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = (container: HTMLElement) => {
|
const handleClose = (container: HTMLElement) => {
|
||||||
@@ -138,7 +126,6 @@ export default {
|
|||||||
onCloseRequest={() => handleClose(params.container)}
|
onCloseRequest={() => handleClose(params.container)}
|
||||||
actionManager={params.actionManager}
|
actionManager={params.actionManager}
|
||||||
appState={params.appState}
|
appState={params.appState}
|
||||||
elements={params.elements}
|
|
||||||
/>,
|
/>,
|
||||||
getContextMenuNode(params.container),
|
getContextMenuNode(params.container),
|
||||||
);
|
);
|
||||||
|
@@ -1,15 +1,16 @@
|
|||||||
import "./ToolIcon.scss";
|
import "./ToolIcon.scss";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { THEME } from "../constants";
|
|
||||||
import { Theme } from "../element/types";
|
export type Appearence = "light" | "dark";
|
||||||
|
|
||||||
// We chose to use only explicit toggle and not a third option for system value,
|
// We chose to use only explicit toggle and not a third option for system value,
|
||||||
// but this could be added in the future.
|
// but this could be added in the future.
|
||||||
export const DarkModeToggle = (props: {
|
export const DarkModeToggle = (props: {
|
||||||
value: Theme;
|
value: Appearence;
|
||||||
onChange: (value: Theme) => void;
|
onChange: (value: Appearence) => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const title =
|
const title =
|
||||||
@@ -19,12 +20,10 @@ export const DarkModeToggle = (props: {
|
|||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="icon"
|
type="icon"
|
||||||
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
|
icon={props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
||||||
title={title}
|
title={title}
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
onClick={() =>
|
onClick={() => props.onChange(props.value === "dark" ? "light" : "dark")}
|
||||||
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
|
|
||||||
}
|
|
||||||
data-testid="toggle-dark-mode"
|
data-testid="toggle-dark-mode"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@@ -10,7 +10,7 @@ import { Island } from "./Island";
|
|||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
|
||||||
export interface DialogProps {
|
export const Dialog = (props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
small?: boolean;
|
small?: boolean;
|
||||||
@@ -18,10 +18,7 @@ export interface DialogProps {
|
|||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
theme?: AppState["theme"];
|
theme?: AppState["theme"];
|
||||||
closeOnClickOutside?: boolean;
|
}) => {
|
||||||
}
|
|
||||||
|
|
||||||
export const Dialog = (props: DialogProps) => {
|
|
||||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||||
const [lastActiveElement] = useState(document.activeElement);
|
const [lastActiveElement] = useState(document.activeElement);
|
||||||
const { id } = useExcalidrawContainer();
|
const { id } = useExcalidrawContainer();
|
||||||
@@ -84,7 +81,6 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
maxWidth={props.small ? 550 : 800}
|
maxWidth={props.small ? 550 : 800}
|
||||||
onCloseRequest={onClose}
|
onCloseRequest={onClose}
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
closeOnClickOutside={props.closeOnClickOutside}
|
|
||||||
>
|
>
|
||||||
<Island ref={setIslandNode}>
|
<Island ref={setIslandNode}>
|
||||||
<h2 id={`${id}-dialog-title`} className="Dialog__title">
|
<h2 id={`${id}-dialog-title`} className="Dialog__title">
|
||||||
|
@@ -154,11 +154,9 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("toolBar.freedraw")}
|
label={t("toolBar.freedraw")}
|
||||||
shortcuts={["Shift + P", "X", "7"]}
|
shortcuts={["Shift+P", "7"]}
|
||||||
/>
|
/>
|
||||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||||
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
|
|
||||||
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
|
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("helpDialog.editSelectedShape")}
|
label={t("helpDialog.editSelectedShape")}
|
||||||
shortcuts={[
|
shortcuts={[
|
||||||
@@ -205,10 +203,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("helpDialog.preventBinding")}
|
label={t("helpDialog.preventBinding")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
|
||||||
label={t("toolBar.link")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
|
|
||||||
/>
|
|
||||||
</ShortcutIsland>
|
</ShortcutIsland>
|
||||||
<ShortcutIsland caption={t("helpDialog.view")}>
|
<ShortcutIsland caption={t("helpDialog.view")}>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
@@ -264,18 +258,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("labels.multiSelect")}
|
label={t("labels.multiSelect")}
|
||||||
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
|
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
|
||||||
label={t("helpDialog.deepSelect")}
|
|
||||||
shortcuts={[
|
|
||||||
getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("helpDialog.deepBoxSelect")}
|
|
||||||
shortcuts={[
|
|
||||||
getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("labels.moveCanvas")}
|
label={t("labels.moveCanvas")}
|
||||||
shortcuts={[
|
shortcuts={[
|
||||||
@@ -398,14 +380,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("labels.showBackground")}
|
label={t("labels.showBackground")}
|
||||||
shortcuts={[getShortcutKey("G")]}
|
shortcuts={[getShortcutKey("G")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
|
||||||
label={t("labels.decreaseFontSize")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.increaseFontSize")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]}
|
|
||||||
/>
|
|
||||||
</ShortcutIsland>
|
</ShortcutIsland>
|
||||||
</Column>
|
</Column>
|
||||||
</Columns>
|
</Columns>
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { questionCircle } from "../components/icons";
|
import { questionCircle } from "../components/icons";
|
||||||
|
|
||||||
type HelpIconProps = {
|
type HelpIconProps = {
|
||||||
|
@@ -1,27 +1,21 @@
|
|||||||
|
import React from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
|
||||||
import "./HintViewer.scss";
|
import "./HintViewer.scss";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import {
|
import { isLinearElement, isTextElement } from "../element/typeChecks";
|
||||||
isImageElement,
|
|
||||||
isLinearElement,
|
|
||||||
isTextBindableContainer,
|
|
||||||
isTextElement,
|
|
||||||
} from "../element/typeChecks";
|
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
|
|
||||||
interface HintViewerProps {
|
interface Hint {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
isMobile: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
const getHints = ({ appState, elements }: Hint) => {
|
||||||
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||||
const multiMode = appState.multiElement !== null;
|
const multiMode = appState.multiElement !== null;
|
||||||
|
|
||||||
if (elementType === "arrow" || elementType === "line") {
|
if (elementType === "arrow" || elementType === "line") {
|
||||||
if (!multiMode) {
|
if (!multiMode) {
|
||||||
return t("hints.linearElement");
|
return t("hints.linearElement");
|
||||||
@@ -37,12 +31,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
|||||||
return t("hints.text");
|
return t("hints.text");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.elementType === "image" && appState.pendingImageElement) {
|
|
||||||
return t("hints.placeImage");
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isResizing &&
|
isResizing &&
|
||||||
lastPointerDownWith === "mouse" &&
|
lastPointerDownWith === "mouse" &&
|
||||||
@@ -52,15 +41,22 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
|||||||
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||||
return t("hints.lockAngle");
|
return t("hints.lockAngle");
|
||||||
}
|
}
|
||||||
return isImageElement(targetElement)
|
return t("hints.resize");
|
||||||
? t("hints.resizeImage")
|
|
||||||
: t("hints.resize");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRotating && lastPointerDownWith === "mouse") {
|
if (isRotating && lastPointerDownWith === "mouse") {
|
||||||
return t("hints.rotate");
|
return t("hints.rotate");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||||
|
if (appState.editingLinearElement) {
|
||||||
|
return appState.editingLinearElement.activePointIndex
|
||||||
|
? t("hints.lineEditor_pointSelected")
|
||||||
|
: t("hints.lineEditor_nothingSelected");
|
||||||
|
}
|
||||||
|
return t("hints.lineEditor_info");
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
||||||
return t("hints.text_selected");
|
return t("hints.text_selected");
|
||||||
}
|
}
|
||||||
@@ -69,45 +65,13 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
|||||||
return t("hints.text_editing");
|
return t("hints.text_editing");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elementType === "selection") {
|
|
||||||
if (
|
|
||||||
appState.draggingElement?.type === "selection" &&
|
|
||||||
!appState.editingElement &&
|
|
||||||
!appState.editingLinearElement
|
|
||||||
) {
|
|
||||||
return t("hints.deepBoxSelect");
|
|
||||||
}
|
|
||||||
if (!selectedElements.length && !isMobile) {
|
|
||||||
return t("hints.canvasPanning");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedElements.length === 1) {
|
|
||||||
if (isLinearElement(selectedElements[0])) {
|
|
||||||
if (appState.editingLinearElement) {
|
|
||||||
return appState.editingLinearElement.selectedPointsIndices
|
|
||||||
? t("hints.lineEditor_pointSelected")
|
|
||||||
: t("hints.lineEditor_nothingSelected");
|
|
||||||
}
|
|
||||||
return t("hints.lineEditor_info");
|
|
||||||
}
|
|
||||||
if (isTextBindableContainer(selectedElements[0])) {
|
|
||||||
return t("hints.bindTextToElement");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HintViewer = ({
|
export const HintViewer = ({ appState, elements }: Hint) => {
|
||||||
appState,
|
|
||||||
elements,
|
|
||||||
isMobile,
|
|
||||||
}: HintViewerProps) => {
|
|
||||||
let hint = getHints({
|
let hint = getHints({
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
isMobile,
|
|
||||||
});
|
});
|
||||||
if (!hint) {
|
if (!hint) {
|
||||||
return null;
|
return null;
|
||||||
|
@@ -22,7 +22,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus {
|
||||||
outline: transparent;
|
outline: transparent;
|
||||||
background-color: var(--button-gray-2);
|
background-color: var(--button-gray-2);
|
||||||
& svg {
|
& svg {
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
.picker-content {
|
.picker-content {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, auto);
|
grid-auto-flow: column;
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
|
@@ -9,16 +9,16 @@ import { t } from "../i18n";
|
|||||||
import { useIsMobile } from "./App";
|
import { useIsMobile } from "./App";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { exportToCanvas } from "../scene/export";
|
import { exportToCanvas } from "../scene/export";
|
||||||
import { AppState, BinaryFiles } from "../types";
|
import { AppState } from "../types";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { clipboard, exportImage } from "./icons";
|
import { clipboard, exportImage } from "./icons";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import "./ExportDialog.scss";
|
import "./ExportDialog.scss";
|
||||||
|
import { supported as fsSupported } from "browser-fs-access";
|
||||||
import OpenColor from "open-color";
|
import OpenColor from "open-color";
|
||||||
import { CheckboxItem } from "./CheckboxItem";
|
import { CheckboxItem } from "./CheckboxItem";
|
||||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
|
||||||
|
|
||||||
const supportsContextFilters =
|
const supportsContextFilters =
|
||||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||||
@@ -79,7 +79,6 @@ const ExportButton: React.FC<{
|
|||||||
const ImageExportModal = ({
|
const ImageExportModal = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
files,
|
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||||
actionManager,
|
actionManager,
|
||||||
onExportToPng,
|
onExportToPng,
|
||||||
@@ -88,7 +87,6 @@ const ImageExportModal = ({
|
|||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
files: BinaryFiles;
|
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionsManagerInterface;
|
||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
@@ -102,7 +100,7 @@ const ImageExportModal = ({
|
|||||||
const { exportBackground, viewBackgroundColor } = appState;
|
const { exportBackground, viewBackgroundColor } = appState;
|
||||||
|
|
||||||
const exportedElements = exportSelected
|
const exportedElements = exportSelected
|
||||||
? getSelectedElements(elements, appState, true)
|
? getSelectedElements(elements, appState)
|
||||||
: elements;
|
: elements;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -114,25 +112,29 @@ const ImageExportModal = ({
|
|||||||
if (!previewNode) {
|
if (!previewNode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
exportToCanvas(exportedElements, appState, files, {
|
try {
|
||||||
|
const canvas = exportToCanvas(exportedElements, appState, {
|
||||||
exportBackground,
|
exportBackground,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
exportPadding,
|
exportPadding,
|
||||||
})
|
});
|
||||||
.then((canvas) => {
|
|
||||||
// if converting to blob fails, there's some problem that will
|
// if converting to blob fails, there's some problem that will
|
||||||
// likely prevent preview and export (e.g. canvas too big)
|
// likely prevent preview and export (e.g. canvas too big)
|
||||||
return canvasToBlob(canvas).then(() => {
|
canvasToBlob(canvas)
|
||||||
|
.then(() => {
|
||||||
renderPreview(canvas, previewNode);
|
renderPreview(canvas, previewNode);
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
renderPreview(new CanvasError(), previewNode);
|
renderPreview(new CanvasError(), previewNode);
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
renderPreview(new CanvasError(), previewNode);
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
appState,
|
appState,
|
||||||
files,
|
|
||||||
exportedElements,
|
exportedElements,
|
||||||
exportBackground,
|
exportBackground,
|
||||||
exportPadding,
|
exportPadding,
|
||||||
@@ -180,8 +182,7 @@ const ImageExportModal = ({
|
|||||||
margin: ".6em 0",
|
margin: ".6em 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!nativeFileSystemSupported &&
|
{!fsSupported && actionManager.renderAction("changeProjectName")}
|
||||||
actionManager.renderAction("changeProjectName")}
|
|
||||||
</div>
|
</div>
|
||||||
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
|
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
|
||||||
<ExportButton
|
<ExportButton
|
||||||
@@ -218,7 +219,6 @@ const ImageExportModal = ({
|
|||||||
export const ImageExportDialog = ({
|
export const ImageExportDialog = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
files,
|
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||||
actionManager,
|
actionManager,
|
||||||
onExportToPng,
|
onExportToPng,
|
||||||
@@ -227,7 +227,6 @@ export const ImageExportDialog = ({
|
|||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
files: BinaryFiles;
|
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionsManagerInterface;
|
||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
@@ -258,7 +257,6 @@ export const ImageExportDialog = ({
|
|||||||
<ImageExportModal
|
<ImageExportModal
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
files={files}
|
|
||||||
exportPadding={exportPadding}
|
exportPadding={exportPadding}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
onExportToPng={onExportToPng}
|
onExportToPng={onExportToPng}
|
||||||
|
@@ -1,25 +1,30 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
children: React.ReactElement;
|
|
||||||
}
|
}
|
||||||
|
interface State {
|
||||||
export const InitializeApp = (props: Props) => {
|
isLoading: boolean;
|
||||||
const [loading, setLoading] = useState(true);
|
}
|
||||||
|
export class InitializeApp extends React.Component<Props, State> {
|
||||||
useEffect(() => {
|
public state: { isLoading: boolean } = {
|
||||||
const updateLang = async () => {
|
isLoading: true,
|
||||||
await setLanguage(currentLang);
|
|
||||||
};
|
};
|
||||||
const currentLang =
|
|
||||||
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
|
||||||
updateLang();
|
|
||||||
setLoading(false);
|
|
||||||
}, [props.langCode]);
|
|
||||||
|
|
||||||
return loading ? <LoadingMessage /> : props.children;
|
async componentDidMount() {
|
||||||
};
|
const currentLang =
|
||||||
|
languages.find((lang) => lang.code === this.props.langCode) ||
|
||||||
|
defaultLang;
|
||||||
|
await setLanguage(currentLang);
|
||||||
|
this.setState({
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
--padding: 0;
|
--padding: 0;
|
||||||
background-color: var(--island-bg-color);
|
background-color: var(--island-bg-color);
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: 4px;
|
||||||
padding: calc(var(--padding) * var(--space-factor));
|
padding: calc(var(--padding) * var(--space-factor));
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: box-shadow 0.5s ease-in-out;
|
transition: box-shadow 0.5s ease-in-out;
|
||||||
|
@@ -3,7 +3,7 @@ import { ActionsManagerInterface } from "../actions/types";
|
|||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "./App";
|
import { useIsMobile } from "./App";
|
||||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
import { AppState, ExportOpts } from "../types";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
@@ -11,7 +11,7 @@ import { actionSaveFileToDisk } from "../actions/actionExport";
|
|||||||
import { Card } from "./Card";
|
import { Card } from "./Card";
|
||||||
|
|
||||||
import "./ExportDialog.scss";
|
import "./ExportDialog.scss";
|
||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
import { supported as fsSupported } from "browser-fs-access";
|
||||||
|
|
||||||
export type ExportCB = (
|
export type ExportCB = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
@@ -21,13 +21,11 @@ export type ExportCB = (
|
|||||||
const JSONExportModal = ({
|
const JSONExportModal = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
files,
|
|
||||||
actionManager,
|
actionManager,
|
||||||
exportOpts,
|
exportOpts,
|
||||||
canvas,
|
canvas,
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
files: BinaryFiles;
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionsManagerInterface;
|
||||||
onCloseRequest: () => void;
|
onCloseRequest: () => void;
|
||||||
@@ -44,8 +42,7 @@ const JSONExportModal = ({
|
|||||||
<h2>{t("exportDialog.disk_title")}</h2>
|
<h2>{t("exportDialog.disk_title")}</h2>
|
||||||
<div className="Card-details">
|
<div className="Card-details">
|
||||||
{t("exportDialog.disk_details")}
|
{t("exportDialog.disk_details")}
|
||||||
{!nativeFileSystemSupported &&
|
{!fsSupported && actionManager.renderAction("changeProjectName")}
|
||||||
actionManager.renderAction("changeProjectName")}
|
|
||||||
</div>
|
</div>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
className="Card-button"
|
className="Card-button"
|
||||||
@@ -70,14 +67,12 @@ const JSONExportModal = ({
|
|||||||
title={t("exportDialog.link_button")}
|
title={t("exportDialog.link_button")}
|
||||||
aria-label={t("exportDialog.link_button")}
|
aria-label={t("exportDialog.link_button")}
|
||||||
showAriaLabel={true}
|
showAriaLabel={true}
|
||||||
onClick={() =>
|
onClick={() => onExportToBackend(elements, appState, canvas)}
|
||||||
onExportToBackend(elements, appState, files, canvas)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{exportOpts.renderCustomUI &&
|
{exportOpts.renderCustomUI &&
|
||||||
exportOpts.renderCustomUI(elements, appState, files, canvas)}
|
exportOpts.renderCustomUI(elements, appState, canvas)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -86,14 +81,12 @@ const JSONExportModal = ({
|
|||||||
export const JSONExportDialog = ({
|
export const JSONExportDialog = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
files,
|
|
||||||
actionManager,
|
actionManager,
|
||||||
exportOpts,
|
exportOpts,
|
||||||
canvas,
|
canvas,
|
||||||
}: {
|
}: {
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
files: BinaryFiles;
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionsManagerInterface;
|
||||||
exportOpts: ExportOpts;
|
exportOpts: ExportOpts;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
@@ -122,7 +115,6 @@ export const JSONExportDialog = ({
|
|||||||
<JSONExportModal
|
<JSONExportModal
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
files={files}
|
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
onCloseRequest={handleClose}
|
onCloseRequest={handleClose}
|
||||||
exportOpts={exportOpts}
|
exportOpts={exportOpts}
|
||||||
|
@@ -1,6 +1,42 @@
|
|||||||
@import "open-color/open-color";
|
@import "open-color/open-color";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
|
.layer-ui__library {
|
||||||
|
margin: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.layer-ui__library-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
margin: 2px 0;
|
||||||
|
|
||||||
|
button {
|
||||||
|
// 2px from the left to account for focus border of left-most button
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||||
|
padding-inline-end: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-ui__library-message {
|
||||||
|
padding: 10px 20px;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-ui__library-items {
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.layer-ui__wrapper {
|
.layer-ui__wrapper {
|
||||||
z-index: var(--zIndex-layerUI);
|
z-index: var(--zIndex-layerUI);
|
||||||
|
|
||||||
@@ -37,10 +73,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
|
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||||
transform: translate(-76px, 0);
|
transform: translate(-92px, 0);
|
||||||
}
|
}
|
||||||
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
|
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||||
transform: translate(76px, 0);
|
transform: translate(92px, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.layer-ui__wrapper__footer-left--transition-bottom {
|
&.layer-ui__wrapper__footer-left--transition-bottom {
|
||||||
@@ -80,19 +116,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.layer-ui__wrapper__footer-left,
|
.layer-ui__wrapper__footer-left,
|
||||||
.layer-ui__wrapper__footer-right,
|
|
||||||
.disable-zen-mode--visible {
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__wrapper__footer-left {
|
|
||||||
margin-bottom: 0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__wrapper__footer-right {
|
.layer-ui__wrapper__footer-right {
|
||||||
margin-top: auto;
|
pointer-events: all;
|
||||||
margin-bottom: auto;
|
|
||||||
margin-inline-end: 1em;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,15 +1,28 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React, { useCallback } from "react";
|
import React, {
|
||||||
|
RefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { CLASSES } from "../constants";
|
import { CLASSES } from "../constants";
|
||||||
import { exportCanvas } from "../data";
|
import { exportCanvas } from "../data";
|
||||||
|
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { Language, t } from "../i18n";
|
import { Language, t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
import {
|
||||||
|
AppProps,
|
||||||
|
AppState,
|
||||||
|
ExcalidrawProps,
|
||||||
|
LibraryItem,
|
||||||
|
LibraryItems,
|
||||||
|
} from "../types";
|
||||||
import { muteFSAbortError } from "../utils";
|
import { muteFSAbortError } from "../utils";
|
||||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
@@ -18,7 +31,10 @@ import { ErrorDialog } from "./ErrorDialog";
|
|||||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
import { HintViewer } from "./HintViewer";
|
import { HintViewer } from "./HintViewer";
|
||||||
|
import { exportFile, load, trash } from "./icons";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
|
import "./LayerUI.scss";
|
||||||
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { LockButton } from "./LockButton";
|
import { LockButton } from "./LockButton";
|
||||||
import { MobileMenu } from "./MobileMenu";
|
import { MobileMenu } from "./MobileMenu";
|
||||||
@@ -26,28 +42,21 @@ import { PasteChartDialog } from "./PasteChartDialog";
|
|||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import { HelpDialog } from "./HelpDialog";
|
import { HelpDialog } from "./HelpDialog";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import Library from "../data/library";
|
import Library from "../data/library";
|
||||||
import { JSONExportDialog } from "./JSONExportDialog";
|
import { JSONExportDialog } from "./JSONExportDialog";
|
||||||
import { LibraryButton } from "./LibraryButton";
|
import { LibraryButton } from "./LibraryButton";
|
||||||
import { isImageFileHandle } from "../data/blob";
|
|
||||||
import { LibraryMenu } from "./LibraryMenu";
|
|
||||||
|
|
||||||
import "./LayerUI.scss";
|
|
||||||
import "./Toolbar.scss";
|
|
||||||
import { PenModeButton } from "./PenModeButton";
|
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
files: BinaryFiles;
|
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
onCollabButtonClick?: () => void;
|
onCollabButtonClick?: () => void;
|
||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onPenModeToggle: () => void;
|
|
||||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||||
zenModeEnabled: boolean;
|
zenModeEnabled: boolean;
|
||||||
showExitZenModeBtn: boolean;
|
showExitZenModeBtn: boolean;
|
||||||
@@ -55,10 +64,7 @@ interface LayerUIProps {
|
|||||||
toggleZenMode: () => void;
|
toggleZenMode: () => void;
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
renderTopRightUI?: (
|
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||||
isMobile: boolean,
|
|
||||||
appState: AppState,
|
|
||||||
) => JSX.Element | null;
|
|
||||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||||
viewModeEnabled: boolean;
|
viewModeEnabled: boolean;
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
@@ -66,19 +72,300 @@ interface LayerUIProps {
|
|||||||
focusContainer: () => void;
|
focusContainer: () => void;
|
||||||
library: Library;
|
library: Library;
|
||||||
id: string;
|
id: string;
|
||||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useOnClickOutside = (
|
||||||
|
ref: RefObject<HTMLElement>,
|
||||||
|
cb: (event: MouseEvent) => void,
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (event: MouseEvent) => {
|
||||||
|
if (!ref.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.target instanceof Element &&
|
||||||
|
(ref.current.contains(event.target) ||
|
||||||
|
!document.body.contains(event.target))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(event);
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", listener, false);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", listener);
|
||||||
|
};
|
||||||
|
}, [ref, cb]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LibraryMenuItems = ({
|
||||||
|
libraryItems,
|
||||||
|
onRemoveFromLibrary,
|
||||||
|
onAddToLibrary,
|
||||||
|
onInsertShape,
|
||||||
|
pendingElements,
|
||||||
|
theme,
|
||||||
|
setAppState,
|
||||||
|
setLibraryItems,
|
||||||
|
libraryReturnUrl,
|
||||||
|
focusContainer,
|
||||||
|
library,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
libraryItems: LibraryItems;
|
||||||
|
pendingElements: LibraryItem;
|
||||||
|
onRemoveFromLibrary: (index: number) => void;
|
||||||
|
onInsertShape: (elements: LibraryItem) => void;
|
||||||
|
onAddToLibrary: (elements: LibraryItem) => void;
|
||||||
|
theme: AppState["theme"];
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
setLibraryItems: (library: LibraryItems) => void;
|
||||||
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
|
focusContainer: () => void;
|
||||||
|
library: Library;
|
||||||
|
id: string;
|
||||||
|
}) => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
|
||||||
|
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||||
|
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
|
||||||
|
const rows = [];
|
||||||
|
let addedPendingElements = false;
|
||||||
|
|
||||||
|
const referrer =
|
||||||
|
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||||
|
|
||||||
|
rows.push(
|
||||||
|
<div className="layer-ui__library-header" key="library-header">
|
||||||
|
<ToolButton
|
||||||
|
key="import"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.load")}
|
||||||
|
aria-label={t("buttons.load")}
|
||||||
|
icon={load}
|
||||||
|
onClick={() => {
|
||||||
|
importLibraryFromJSON(library)
|
||||||
|
.then(() => {
|
||||||
|
// Close and then open to get the libraries updated
|
||||||
|
setAppState({ isLibraryOpen: false });
|
||||||
|
setAppState({ isLibraryOpen: true });
|
||||||
|
})
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!!libraryItems.length && (
|
||||||
|
<>
|
||||||
|
<ToolButton
|
||||||
|
key="export"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.export")}
|
||||||
|
aria-label={t("buttons.export")}
|
||||||
|
icon={exportFile}
|
||||||
|
onClick={() => {
|
||||||
|
saveLibraryAsJSON(library)
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
key="reset"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.resetLibrary")}
|
||||||
|
aria-label={t("buttons.resetLibrary")}
|
||||||
|
icon={trash}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(t("alerts.resetLibrary"))) {
|
||||||
|
library.resetLibrary();
|
||||||
|
setLibraryItems([]);
|
||||||
|
focusContainer();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={`https://libraries.excalidraw.com?target=${
|
||||||
|
window.name || "_blank"
|
||||||
|
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
|
||||||
|
target="_excalidraw_libraries"
|
||||||
|
>
|
||||||
|
{t("labels.libraries")}
|
||||||
|
</a>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let row = 0; row < numRows; row++) {
|
||||||
|
const y = CELLS_PER_ROW * row;
|
||||||
|
const children = [];
|
||||||
|
for (let x = 0; x < CELLS_PER_ROW; x++) {
|
||||||
|
const shouldAddPendingElements: boolean =
|
||||||
|
pendingElements.length > 0 &&
|
||||||
|
!addedPendingElements &&
|
||||||
|
y + x >= libraryItems.length;
|
||||||
|
addedPendingElements = addedPendingElements || shouldAddPendingElements;
|
||||||
|
|
||||||
|
children.push(
|
||||||
|
<Stack.Col key={x}>
|
||||||
|
<LibraryUnit
|
||||||
|
elements={libraryItems[y + x]}
|
||||||
|
pendingElements={
|
||||||
|
shouldAddPendingElements ? pendingElements : undefined
|
||||||
|
}
|
||||||
|
onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
|
||||||
|
onClick={
|
||||||
|
shouldAddPendingElements
|
||||||
|
? onAddToLibrary.bind(null, pendingElements)
|
||||||
|
: onInsertShape.bind(null, libraryItems[y + x])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack.Col>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rows.push(
|
||||||
|
<Stack.Row align="center" gap={1} key={row}>
|
||||||
|
{children}
|
||||||
|
</Stack.Row>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack.Col align="start" gap={1} className="layer-ui__library-items">
|
||||||
|
{rows}
|
||||||
|
</Stack.Col>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LibraryMenu = ({
|
||||||
|
onClickOutside,
|
||||||
|
onInsertShape,
|
||||||
|
pendingElements,
|
||||||
|
onAddToLibrary,
|
||||||
|
theme,
|
||||||
|
setAppState,
|
||||||
|
libraryReturnUrl,
|
||||||
|
focusContainer,
|
||||||
|
library,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
pendingElements: LibraryItem;
|
||||||
|
onClickOutside: (event: MouseEvent) => void;
|
||||||
|
onInsertShape: (elements: LibraryItem) => void;
|
||||||
|
onAddToLibrary: () => void;
|
||||||
|
theme: AppState["theme"];
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
|
focusContainer: () => void;
|
||||||
|
library: Library;
|
||||||
|
id: string;
|
||||||
|
}) => {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useOnClickOutside(ref, (event) => {
|
||||||
|
// If click on the library icon, do nothing.
|
||||||
|
if ((event.target as Element).closest(".ToolIcon_type_button__library")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClickOutside(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
||||||
|
|
||||||
|
const [loadingState, setIsLoading] = useState<
|
||||||
|
"preloading" | "loading" | "ready"
|
||||||
|
>("preloading");
|
||||||
|
|
||||||
|
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.race([
|
||||||
|
new Promise((resolve) => {
|
||||||
|
loadingTimerRef.current = setTimeout(() => {
|
||||||
|
resolve("loading");
|
||||||
|
}, 100);
|
||||||
|
}),
|
||||||
|
library.loadLibrary().then((items) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
setIsLoading("ready");
|
||||||
|
}),
|
||||||
|
]).then((data) => {
|
||||||
|
if (data === "loading") {
|
||||||
|
setIsLoading("loading");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
clearTimeout(loadingTimerRef.current!);
|
||||||
|
};
|
||||||
|
}, [library]);
|
||||||
|
|
||||||
|
const removeFromLibrary = useCallback(
|
||||||
|
async (indexToRemove) => {
|
||||||
|
const items = await library.loadLibrary();
|
||||||
|
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
||||||
|
library.saveLibrary(nextItems).catch((error) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||||
|
});
|
||||||
|
setLibraryItems(nextItems);
|
||||||
|
},
|
||||||
|
[library, setAppState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addToLibrary = useCallback(
|
||||||
|
async (elements: LibraryItem) => {
|
||||||
|
const items = await library.loadLibrary();
|
||||||
|
const nextItems = [...items, elements];
|
||||||
|
onAddToLibrary();
|
||||||
|
library.saveLibrary(nextItems).catch((error) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||||
|
});
|
||||||
|
setLibraryItems(nextItems);
|
||||||
|
},
|
||||||
|
[onAddToLibrary, library, setAppState],
|
||||||
|
);
|
||||||
|
|
||||||
|
return loadingState === "preloading" ? null : (
|
||||||
|
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||||
|
{loadingState === "loading" ? (
|
||||||
|
<div className="layer-ui__library-message">
|
||||||
|
{t("labels.libraryLoadingMessage")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<LibraryMenuItems
|
||||||
|
libraryItems={libraryItems}
|
||||||
|
onRemoveFromLibrary={removeFromLibrary}
|
||||||
|
onAddToLibrary={addToLibrary}
|
||||||
|
onInsertShape={onInsertShape}
|
||||||
|
pendingElements={pendingElements}
|
||||||
|
setAppState={setAppState}
|
||||||
|
setLibraryItems={setLibraryItems}
|
||||||
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
|
focusContainer={focusContainer}
|
||||||
|
library={library}
|
||||||
|
theme={theme}
|
||||||
|
id={id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Island>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const LayerUI = ({
|
const LayerUI = ({
|
||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
files,
|
|
||||||
setAppState,
|
setAppState,
|
||||||
canvas,
|
canvas,
|
||||||
elements,
|
elements,
|
||||||
onCollabButtonClick,
|
onCollabButtonClick,
|
||||||
onLockToggle,
|
onLockToggle,
|
||||||
onPenModeToggle,
|
|
||||||
onInsertElements,
|
onInsertElements,
|
||||||
zenModeEnabled,
|
zenModeEnabled,
|
||||||
showExitZenModeBtn,
|
showExitZenModeBtn,
|
||||||
@@ -93,7 +380,6 @@ const LayerUI = ({
|
|||||||
focusContainer,
|
focusContainer,
|
||||||
library,
|
library,
|
||||||
id,
|
id,
|
||||||
onImageAction,
|
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
@@ -106,7 +392,6 @@ const LayerUI = ({
|
|||||||
<JSONExportDialog
|
<JSONExportDialog
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
files={files}
|
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
exportOpts={UIOptions.canvasActions.export}
|
exportOpts={UIOptions.canvasActions.export}
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
@@ -119,40 +404,25 @@ const LayerUI = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createExporter =
|
const createExporter = (type: ExportType): ExportCB => async (
|
||||||
(type: ExportType): ExportCB =>
|
|
||||||
async (exportedElements) => {
|
|
||||||
const fileHandle = await exportCanvas(
|
|
||||||
type,
|
|
||||||
exportedElements,
|
exportedElements,
|
||||||
appState,
|
) => {
|
||||||
files,
|
await exportCanvas(type, exportedElements, appState, {
|
||||||
{
|
|
||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
name: appState.name,
|
name: appState.name,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
},
|
})
|
||||||
)
|
|
||||||
.catch(muteFSAbortError)
|
.catch(muteFSAbortError)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setAppState({ errorMessage: error.message });
|
setAppState({ errorMessage: error.message });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
|
||||||
appState.exportEmbedScene &&
|
|
||||||
fileHandle &&
|
|
||||||
isImageFileHandle(fileHandle)
|
|
||||||
) {
|
|
||||||
setAppState({ fileHandle });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImageExportDialog
|
<ImageExportDialog
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
files={files}
|
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
onExportToPng={createExporter("png")}
|
onExportToPng={createExporter("png")}
|
||||||
onExportToSvg={createExporter("svg")}
|
onExportToSvg={createExporter("svg")}
|
||||||
@@ -186,7 +456,6 @@ const LayerUI = ({
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCanvasActions = () => (
|
const renderCanvasActions = () => (
|
||||||
<Section
|
<Section
|
||||||
heading="canvasActions"
|
heading="canvasActions"
|
||||||
@@ -254,15 +523,12 @@ const LayerUI = ({
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeLibrary = useCallback(() => {
|
const closeLibrary = useCallback(
|
||||||
const isDialogOpen = !!document.querySelector(".Dialog");
|
(event) => {
|
||||||
|
|
||||||
// Prevent closing if any dialog is open
|
|
||||||
if (isDialogOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAppState({ isLibraryOpen: false });
|
setAppState({ isLibraryOpen: false });
|
||||||
}, [setAppState]);
|
},
|
||||||
|
[setAppState],
|
||||||
|
);
|
||||||
|
|
||||||
const deselectItems = useCallback(() => {
|
const deselectItems = useCallback(() => {
|
||||||
setAppState({
|
setAppState({
|
||||||
@@ -273,8 +539,8 @@ const LayerUI = ({
|
|||||||
|
|
||||||
const libraryMenu = appState.isLibraryOpen ? (
|
const libraryMenu = appState.isLibraryOpen ? (
|
||||||
<LibraryMenu
|
<LibraryMenu
|
||||||
pendingElements={getSelectedElements(elements, appState, true)}
|
pendingElements={getSelectedElements(elements, appState)}
|
||||||
onClose={closeLibrary}
|
onClickOutside={closeLibrary}
|
||||||
onInsertShape={onInsertElements}
|
onInsertShape={onInsertElements}
|
||||||
onAddToLibrary={deselectItems}
|
onAddToLibrary={deselectItems}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
@@ -282,9 +548,7 @@ const LayerUI = ({
|
|||||||
focusContainer={focusContainer}
|
focusContainer={focusContainer}
|
||||||
library={library}
|
library={library}
|
||||||
theme={appState.theme}
|
theme={appState.theme}
|
||||||
files={files}
|
|
||||||
id={id}
|
id={id}
|
||||||
appState={appState}
|
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
@@ -310,19 +574,7 @@ const LayerUI = ({
|
|||||||
<Section heading="shapes">
|
<Section heading="shapes">
|
||||||
{(heading) => (
|
{(heading) => (
|
||||||
<Stack.Col gap={4} align="start">
|
<Stack.Col gap={4} align="start">
|
||||||
<Stack.Row
|
<Stack.Row gap={1}>
|
||||||
gap={1}
|
|
||||||
className={clsx("App-toolbar-container", {
|
|
||||||
"zen-mode": zenModeEnabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<PenModeButton
|
|
||||||
zenModeEnabled={zenModeEnabled}
|
|
||||||
checked={appState.penMode}
|
|
||||||
onChange={onPenModeToggle}
|
|
||||||
title={t("toolBar.penMode")}
|
|
||||||
penDetected={appState.penDetected}
|
|
||||||
/>
|
|
||||||
<LockButton
|
<LockButton
|
||||||
zenModeEnabled={zenModeEnabled}
|
zenModeEnabled={zenModeEnabled}
|
||||||
checked={appState.elementLocked}
|
checked={appState.elementLocked}
|
||||||
@@ -331,26 +583,15 @@ const LayerUI = ({
|
|||||||
/>
|
/>
|
||||||
<Island
|
<Island
|
||||||
padding={1}
|
padding={1}
|
||||||
className={clsx("App-toolbar", {
|
className={clsx({ "zen-mode": zenModeEnabled })}
|
||||||
"zen-mode": zenModeEnabled,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<HintViewer
|
<HintViewer appState={appState} elements={elements} />
|
||||||
appState={appState}
|
|
||||||
elements={elements}
|
|
||||||
isMobile={isMobile}
|
|
||||||
/>
|
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<ShapesSwitcher
|
<ShapesSwitcher
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
elementType={appState.elementType}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
onImageAction={({ pointerType }) => {
|
|
||||||
onImageAction({
|
|
||||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
@@ -382,9 +623,7 @@ const LayerUI = ({
|
|||||||
label={client.username || "Unknown user"}
|
label={client.username || "Unknown user"}
|
||||||
key={clientId}
|
key={clientId}
|
||||||
>
|
>
|
||||||
{actionManager.renderAction("goToCollaborator", {
|
{actionManager.renderAction("goToCollaborator", clientId)}
|
||||||
id: clientId,
|
|
||||||
})}
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</UserList>
|
</UserList>
|
||||||
@@ -417,17 +656,6 @@ const LayerUI = ({
|
|||||||
zoom={appState.zoom}
|
zoom={appState.zoom}
|
||||||
/>
|
/>
|
||||||
</Island>
|
</Island>
|
||||||
{!viewModeEnabled && (
|
|
||||||
<div
|
|
||||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
|
||||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
|
||||||
zenModeEnabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{actionManager.renderAction("undo", { size: "small" })}
|
|
||||||
{actionManager.renderAction("redo", { size: "small" })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
</Section>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</div>
|
</div>
|
||||||
@@ -435,8 +663,7 @@ const LayerUI = ({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"layer-ui__wrapper__footer-center zen-mode-transition",
|
"layer-ui__wrapper__footer-center zen-mode-transition",
|
||||||
{
|
{
|
||||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
|
||||||
zenModeEnabled,
|
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -508,14 +735,11 @@ const LayerUI = ({
|
|||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
onCollabButtonClick={onCollabButtonClick}
|
onCollabButtonClick={onCollabButtonClick}
|
||||||
onLockToggle={onLockToggle}
|
onLockToggle={onLockToggle}
|
||||||
onPenModeToggle={onPenModeToggle}
|
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
renderCustomFooter={renderCustomFooter}
|
renderCustomFooter={renderCustomFooter}
|
||||||
viewModeEnabled={viewModeEnabled}
|
viewModeEnabled={viewModeEnabled}
|
||||||
showThemeBtn={showThemeBtn}
|
showThemeBtn={showThemeBtn}
|
||||||
onImageAction={onImageAction}
|
|
||||||
renderTopRightUI={renderTopRightUI}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -563,7 +787,6 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
|||||||
prev.renderCustomFooter === next.renderCustomFooter &&
|
prev.renderCustomFooter === next.renderCustomFooter &&
|
||||||
prev.langCode === next.langCode &&
|
prev.langCode === next.langCode &&
|
||||||
prev.elements === next.elements &&
|
prev.elements === next.elements &&
|
||||||
prev.files === next.files &&
|
|
||||||
keys.every((key) => prevAppState[key] === nextAppState[key])
|
keys.every((key) => prevAppState[key] === nextAppState[key])
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
@@ -16,18 +15,18 @@ const LIBRARY_ICON = (
|
|||||||
export const LibraryButton: React.FC<{
|
export const LibraryButton: React.FC<{
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
isMobile?: boolean;
|
}> = ({ appState, setAppState }) => {
|
||||||
}> = ({ appState, setAppState, isMobile }) => {
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"ToolIcon ToolIcon_type_floating ToolIcon__library",
|
"ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
|
||||||
`ToolIcon_size_medium`,
|
`ToolIcon_size_m`,
|
||||||
{
|
{
|
||||||
"is-mobile": isMobile,
|
"zen-mode-visibility--hidden": appState.zenModeEnabled,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
title={`${capitalizeString(t("toolBar.library"))} — 0`}
|
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
||||||
|
style={{ marginInlineStart: "var(--space-factor)" }}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
className="ToolIcon_type_checkbox"
|
className="ToolIcon_type_checkbox"
|
||||||
@@ -38,7 +37,7 @@ export const LibraryButton: React.FC<{
|
|||||||
}}
|
}}
|
||||||
checked={appState.isLibraryOpen}
|
checked={appState.isLibraryOpen}
|
||||||
aria-label={capitalizeString(t("toolBar.library"))}
|
aria-label={capitalizeString(t("toolBar.library"))}
|
||||||
aria-keyshortcuts="0"
|
aria-keyshortcuts="9"
|
||||||
/>
|
/>
|
||||||
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
|
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
|
||||||
</label>
|
</label>
|
||||||
|
@@ -1,55 +0,0 @@
|
|||||||
@import "open-color/open-color";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.layer-ui__library {
|
|
||||||
margin: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
.layer-ui__library-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
margin: 2px 0;
|
|
||||||
|
|
||||||
button {
|
|
||||||
// 2px from the left to account for focus border of left-most button
|
|
||||||
margin: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
margin-inline-start: auto;
|
|
||||||
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
|
||||||
padding-inline-end: 18px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__library-message {
|
|
||||||
padding: 10px 20px;
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.publish-library-success {
|
|
||||||
.Dialog__content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-close.ToolIcon_type_button {
|
|
||||||
background-color: $oc-blue-6;
|
|
||||||
align-self: flex-end;
|
|
||||||
&:hover {
|
|
||||||
background-color: $oc-blue-8;
|
|
||||||
}
|
|
||||||
.ToolIcon__icon {
|
|
||||||
width: auto;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: $oc-white;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,326 +0,0 @@
|
|||||||
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
|
|
||||||
import Library from "../data/library";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { randomId } from "../random";
|
|
||||||
import {
|
|
||||||
LibraryItems,
|
|
||||||
LibraryItem,
|
|
||||||
AppState,
|
|
||||||
BinaryFiles,
|
|
||||||
ExcalidrawProps,
|
|
||||||
} from "../types";
|
|
||||||
import { Dialog } from "./Dialog";
|
|
||||||
import { Island } from "./Island";
|
|
||||||
import PublishLibrary from "./PublishLibrary";
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
|
|
||||||
import "./LibraryMenu.scss";
|
|
||||||
import LibraryMenuItems from "./LibraryMenuItems";
|
|
||||||
import { EVENT } from "../constants";
|
|
||||||
import { KEYS } from "../keys";
|
|
||||||
import { arrayToMap } from "../utils";
|
|
||||||
|
|
||||||
const useOnClickOutside = (
|
|
||||||
ref: RefObject<HTMLElement>,
|
|
||||||
cb: (event: MouseEvent) => void,
|
|
||||||
) => {
|
|
||||||
useEffect(() => {
|
|
||||||
const listener = (event: MouseEvent) => {
|
|
||||||
if (!ref.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.target instanceof Element &&
|
|
||||||
(ref.current.contains(event.target) ||
|
|
||||||
!document.body.contains(event.target))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cb(event);
|
|
||||||
};
|
|
||||||
document.addEventListener("pointerdown", listener, false);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("pointerdown", listener);
|
|
||||||
};
|
|
||||||
}, [ref, cb]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSelectedItems = (
|
|
||||||
libraryItems: LibraryItems,
|
|
||||||
selectedItems: LibraryItem["id"][],
|
|
||||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
|
||||||
|
|
||||||
export const LibraryMenu = ({
|
|
||||||
onClose,
|
|
||||||
onInsertShape,
|
|
||||||
pendingElements,
|
|
||||||
onAddToLibrary,
|
|
||||||
theme,
|
|
||||||
setAppState,
|
|
||||||
files,
|
|
||||||
libraryReturnUrl,
|
|
||||||
focusContainer,
|
|
||||||
library,
|
|
||||||
id,
|
|
||||||
appState,
|
|
||||||
}: {
|
|
||||||
pendingElements: LibraryItem["elements"];
|
|
||||||
onClose: () => void;
|
|
||||||
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
|
||||||
onAddToLibrary: () => void;
|
|
||||||
theme: AppState["theme"];
|
|
||||||
files: BinaryFiles;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
|
||||||
focusContainer: () => void;
|
|
||||||
library: Library;
|
|
||||||
id: string;
|
|
||||||
appState: AppState;
|
|
||||||
}) => {
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useOnClickOutside(ref, (event) => {
|
|
||||||
// If click on the library icon, do nothing.
|
|
||||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === KEYS.ESCAPE) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
|
||||||
|
|
||||||
const [loadingState, setIsLoading] = useState<
|
|
||||||
"preloading" | "loading" | "ready"
|
|
||||||
>("preloading");
|
|
||||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
|
||||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
|
||||||
useState(false);
|
|
||||||
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
|
|
||||||
url: string;
|
|
||||||
authorName: string;
|
|
||||||
}>(null);
|
|
||||||
const loadingTimerRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Promise.race([
|
|
||||||
new Promise((resolve) => {
|
|
||||||
loadingTimerRef.current = window.setTimeout(() => {
|
|
||||||
resolve("loading");
|
|
||||||
}, 100);
|
|
||||||
}),
|
|
||||||
library.loadLibrary().then((items) => {
|
|
||||||
setLibraryItems(items);
|
|
||||||
setIsLoading("ready");
|
|
||||||
}),
|
|
||||||
]).then((data) => {
|
|
||||||
if (data === "loading") {
|
|
||||||
setIsLoading("loading");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
clearTimeout(loadingTimerRef.current!);
|
|
||||||
};
|
|
||||||
}, [library]);
|
|
||||||
|
|
||||||
const removeFromLibrary = useCallback(async () => {
|
|
||||||
const items = await library.loadLibrary();
|
|
||||||
|
|
||||||
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
|
|
||||||
library.saveLibrary(nextItems).catch((error) => {
|
|
||||||
setLibraryItems(items);
|
|
||||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
|
||||||
});
|
|
||||||
setSelectedItems([]);
|
|
||||||
setLibraryItems(nextItems);
|
|
||||||
}, [library, setAppState, selectedItems, setSelectedItems]);
|
|
||||||
|
|
||||||
const resetLibrary = useCallback(() => {
|
|
||||||
library.resetLibrary();
|
|
||||||
setLibraryItems([]);
|
|
||||||
focusContainer();
|
|
||||||
}, [library, focusContainer]);
|
|
||||||
|
|
||||||
const addToLibrary = useCallback(
|
|
||||||
async (elements: LibraryItem["elements"]) => {
|
|
||||||
if (elements.some((element) => element.type === "image")) {
|
|
||||||
return setAppState({
|
|
||||||
errorMessage: "Support for adding images to the library coming soon!",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const items = await library.loadLibrary();
|
|
||||||
const nextItems: LibraryItems = [
|
|
||||||
{
|
|
||||||
status: "unpublished",
|
|
||||||
elements,
|
|
||||||
id: randomId(),
|
|
||||||
created: Date.now(),
|
|
||||||
},
|
|
||||||
...items,
|
|
||||||
];
|
|
||||||
onAddToLibrary();
|
|
||||||
library.saveLibrary(nextItems).catch((error) => {
|
|
||||||
setLibraryItems(items);
|
|
||||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
|
||||||
});
|
|
||||||
setLibraryItems(nextItems);
|
|
||||||
},
|
|
||||||
[onAddToLibrary, library, setAppState],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderPublishSuccess = useCallback(() => {
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
onCloseRequest={() => setPublishLibSuccess(null)}
|
|
||||||
title={t("publishSuccessDialog.title")}
|
|
||||||
className="publish-library-success"
|
|
||||||
small={true}
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
{t("publishSuccessDialog.content", {
|
|
||||||
authorName: publishLibSuccess!.authorName,
|
|
||||||
})}{" "}
|
|
||||||
<a
|
|
||||||
href={publishLibSuccess?.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{t("publishSuccessDialog.link")}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.close")}
|
|
||||||
aria-label={t("buttons.close")}
|
|
||||||
label={t("buttons.close")}
|
|
||||||
onClick={() => setPublishLibSuccess(null)}
|
|
||||||
data-testid="publish-library-success-close"
|
|
||||||
className="publish-library-success-close"
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
|
||||||
|
|
||||||
const onPublishLibSuccess = useCallback(
|
|
||||||
(data) => {
|
|
||||||
setShowPublishLibraryDialog(false);
|
|
||||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
|
||||||
const nextLibItems = libraryItems.slice();
|
|
||||||
nextLibItems.forEach((libItem) => {
|
|
||||||
if (selectedItems.includes(libItem.id)) {
|
|
||||||
libItem.status = "published";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
library.saveLibrary(nextLibItems);
|
|
||||||
setLibraryItems(nextLibItems);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
setShowPublishLibraryDialog,
|
|
||||||
setPublishLibSuccess,
|
|
||||||
libraryItems,
|
|
||||||
selectedItems,
|
|
||||||
library,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
|
||||||
LibraryItem["id"] | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
return loadingState === "preloading" ? null : (
|
|
||||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
|
||||||
{showPublishLibraryDialog && (
|
|
||||||
<PublishLibrary
|
|
||||||
onClose={() => setShowPublishLibraryDialog(false)}
|
|
||||||
libraryItems={getSelectedItems(libraryItems, selectedItems)}
|
|
||||||
appState={appState}
|
|
||||||
onSuccess={onPublishLibSuccess}
|
|
||||||
onError={(error) => window.alert(error)}
|
|
||||||
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
|
|
||||||
onRemove={(id: string) =>
|
|
||||||
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{publishLibSuccess && renderPublishSuccess()}
|
|
||||||
|
|
||||||
{loadingState === "loading" ? (
|
|
||||||
<div className="layer-ui__library-message">
|
|
||||||
{t("labels.libraryLoadingMessage")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<LibraryMenuItems
|
|
||||||
libraryItems={libraryItems}
|
|
||||||
onRemoveFromLibrary={removeFromLibrary}
|
|
||||||
onAddToLibrary={addToLibrary}
|
|
||||||
onInsertShape={onInsertShape}
|
|
||||||
pendingElements={pendingElements}
|
|
||||||
setAppState={setAppState}
|
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
|
||||||
library={library}
|
|
||||||
theme={theme}
|
|
||||||
files={files}
|
|
||||||
id={id}
|
|
||||||
selectedItems={selectedItems}
|
|
||||||
onToggle={(id, event) => {
|
|
||||||
const shouldSelect = !selectedItems.includes(id);
|
|
||||||
|
|
||||||
if (shouldSelect) {
|
|
||||||
if (event.shiftKey && lastSelectedItem) {
|
|
||||||
const rangeStart = libraryItems.findIndex(
|
|
||||||
(item) => item.id === lastSelectedItem,
|
|
||||||
);
|
|
||||||
const rangeEnd = libraryItems.findIndex(
|
|
||||||
(item) => item.id === id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rangeStart === -1 || rangeEnd === -1) {
|
|
||||||
setSelectedItems([...selectedItems, id]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedItemsMap = arrayToMap(selectedItems);
|
|
||||||
const nextSelectedIds = libraryItems.reduce(
|
|
||||||
(acc: LibraryItem["id"][], item, idx) => {
|
|
||||||
if (
|
|
||||||
(idx >= rangeStart && idx <= rangeEnd) ||
|
|
||||||
selectedItemsMap.has(item.id)
|
|
||||||
) {
|
|
||||||
acc.push(item.id);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
setSelectedItems(nextSelectedIds);
|
|
||||||
} else {
|
|
||||||
setSelectedItems([...selectedItems, id]);
|
|
||||||
}
|
|
||||||
setLastSelectedItem(id);
|
|
||||||
} else {
|
|
||||||
setLastSelectedItem(null);
|
|
||||||
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
|
||||||
resetLibrary={resetLibrary}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Island>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -1,102 +0,0 @@
|
|||||||
@import "open-color/open-color";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.library-menu-items-container {
|
|
||||||
.library-actions {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
button .library-actions-counter {
|
|
||||||
position: absolute;
|
|
||||||
right: 2px;
|
|
||||||
bottom: 2px;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
padding: 1px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--remove {
|
|
||||||
background-color: $oc-red-7;
|
|
||||||
&:hover {
|
|
||||||
background-color: $oc-red-8;
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: $oc-red-9;
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
color: $oc-white;
|
|
||||||
}
|
|
||||||
.library-actions-counter {
|
|
||||||
color: $oc-red-7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--export {
|
|
||||||
background-color: $oc-lime-5;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $oc-lime-7;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background-color: $oc-lime-8;
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
color: $oc-white;
|
|
||||||
}
|
|
||||||
.library-actions-counter {
|
|
||||||
color: $oc-lime-5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--publish {
|
|
||||||
background-color: $oc-cyan-6;
|
|
||||||
&:hover {
|
|
||||||
background-color: $oc-cyan-7;
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: $oc-cyan-9;
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
color: $oc-white;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
margin-left: -0.2em;
|
|
||||||
margin-right: 1.1em;
|
|
||||||
color: $oc-white;
|
|
||||||
font-size: 0.86em;
|
|
||||||
}
|
|
||||||
.library-actions-counter {
|
|
||||||
color: $oc-cyan-6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--load {
|
|
||||||
background-color: $oc-blue-6;
|
|
||||||
&:hover {
|
|
||||||
background-color: $oc-blue-7;
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: $oc-blue-9;
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
color: $oc-white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&__items {
|
|
||||||
max-height: 50vh;
|
|
||||||
overflow: auto;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0.6em 0.2em;
|
|
||||||
color: var(--text-primary-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,323 +0,0 @@
|
|||||||
import { chunk } from "lodash";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
|
||||||
import Library from "../data/library";
|
|
||||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import {
|
|
||||||
AppState,
|
|
||||||
BinaryFiles,
|
|
||||||
ExcalidrawProps,
|
|
||||||
LibraryItem,
|
|
||||||
LibraryItems,
|
|
||||||
} from "../types";
|
|
||||||
import { muteFSAbortError } from "../utils";
|
|
||||||
import { useIsMobile } from "./App";
|
|
||||||
import ConfirmDialog from "./ConfirmDialog";
|
|
||||||
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
|
||||||
import { LibraryUnit } from "./LibraryUnit";
|
|
||||||
import Stack from "./Stack";
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
import { Tooltip } from "./Tooltip";
|
|
||||||
|
|
||||||
import "./LibraryMenuItems.scss";
|
|
||||||
import { VERSIONS } from "../constants";
|
|
||||||
|
|
||||||
const LibraryMenuItems = ({
|
|
||||||
libraryItems,
|
|
||||||
onRemoveFromLibrary,
|
|
||||||
onAddToLibrary,
|
|
||||||
onInsertShape,
|
|
||||||
pendingElements,
|
|
||||||
theme,
|
|
||||||
setAppState,
|
|
||||||
libraryReturnUrl,
|
|
||||||
library,
|
|
||||||
files,
|
|
||||||
id,
|
|
||||||
selectedItems,
|
|
||||||
onToggle,
|
|
||||||
onPublish,
|
|
||||||
resetLibrary,
|
|
||||||
}: {
|
|
||||||
libraryItems: LibraryItems;
|
|
||||||
pendingElements: LibraryItem["elements"];
|
|
||||||
onRemoveFromLibrary: () => void;
|
|
||||||
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
|
||||||
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
|
||||||
theme: AppState["theme"];
|
|
||||||
files: BinaryFiles;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
|
||||||
library: Library;
|
|
||||||
id: string;
|
|
||||||
selectedItems: LibraryItem["id"][];
|
|
||||||
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
|
|
||||||
onPublish: () => void;
|
|
||||||
resetLibrary: () => void;
|
|
||||||
}) => {
|
|
||||||
const renderRemoveLibAlert = useCallback(() => {
|
|
||||||
const content = selectedItems.length
|
|
||||||
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
|
||||||
: t("alerts.resetLibrary");
|
|
||||||
const title = selectedItems.length
|
|
||||||
? t("confirmDialog.removeItemsFromLib")
|
|
||||||
: t("confirmDialog.resetLibrary");
|
|
||||||
return (
|
|
||||||
<ConfirmDialog
|
|
||||||
onConfirm={() => {
|
|
||||||
if (selectedItems.length) {
|
|
||||||
onRemoveFromLibrary();
|
|
||||||
} else {
|
|
||||||
resetLibrary();
|
|
||||||
}
|
|
||||||
setShowRemoveLibAlert(false);
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
setShowRemoveLibAlert(false);
|
|
||||||
}}
|
|
||||||
title={title}
|
|
||||||
>
|
|
||||||
<p>{content}</p>
|
|
||||||
</ConfirmDialog>
|
|
||||||
);
|
|
||||||
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
|
||||||
|
|
||||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const renderLibraryActions = () => {
|
|
||||||
const itemsSelected = !!selectedItems.length;
|
|
||||||
const items = itemsSelected
|
|
||||||
? libraryItems.filter((item) => selectedItems.includes(item.id))
|
|
||||||
: libraryItems;
|
|
||||||
const resetLabel = itemsSelected
|
|
||||||
? t("buttons.remove")
|
|
||||||
: t("buttons.resetLibrary");
|
|
||||||
return (
|
|
||||||
<div className="library-actions">
|
|
||||||
{(!itemsSelected || !isMobile) && (
|
|
||||||
<ToolButton
|
|
||||||
key="import"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.load")}
|
|
||||||
aria-label={t("buttons.load")}
|
|
||||||
icon={load}
|
|
||||||
onClick={() => {
|
|
||||||
importLibraryFromJSON(library)
|
|
||||||
.then(() => {
|
|
||||||
// Close and then open to get the libraries updated
|
|
||||||
setAppState({ isLibraryOpen: false });
|
|
||||||
setAppState({ isLibraryOpen: true });
|
|
||||||
})
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="library-actions--load"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!!items.length && (
|
|
||||||
<>
|
|
||||||
<ToolButton
|
|
||||||
key="export"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.export")}
|
|
||||||
aria-label={t("buttons.export")}
|
|
||||||
icon={exportToFileIcon}
|
|
||||||
onClick={async () => {
|
|
||||||
const libraryItems = itemsSelected
|
|
||||||
? items
|
|
||||||
: await library.loadLibrary();
|
|
||||||
saveLibraryAsJSON(libraryItems)
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="library-actions--export"
|
|
||||||
>
|
|
||||||
{selectedItems.length > 0 && (
|
|
||||||
<span className="library-actions-counter">
|
|
||||||
{selectedItems.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ToolButton>
|
|
||||||
<ToolButton
|
|
||||||
key="reset"
|
|
||||||
type="button"
|
|
||||||
title={resetLabel}
|
|
||||||
aria-label={resetLabel}
|
|
||||||
icon={trash}
|
|
||||||
onClick={() => setShowRemoveLibAlert(true)}
|
|
||||||
className="library-actions--remove"
|
|
||||||
>
|
|
||||||
{selectedItems.length > 0 && (
|
|
||||||
<span className="library-actions-counter">
|
|
||||||
{selectedItems.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ToolButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{itemsSelected && !isPublished && (
|
|
||||||
<Tooltip label={t("hints.publishLibrary")}>
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
aria-label={t("buttons.publishLibrary")}
|
|
||||||
label={t("buttons.publishLibrary")}
|
|
||||||
icon={publishIcon}
|
|
||||||
className="library-actions--publish"
|
|
||||||
onClick={onPublish}
|
|
||||||
>
|
|
||||||
{!isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
|
||||||
{selectedItems.length > 0 && (
|
|
||||||
<span className="library-actions-counter">
|
|
||||||
{selectedItems.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ToolButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
|
||||||
|
|
||||||
const referrer =
|
|
||||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
|
||||||
const isPublished = selectedItems.some(
|
|
||||||
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
|
|
||||||
);
|
|
||||||
|
|
||||||
const createLibraryItemCompo = (params: {
|
|
||||||
item:
|
|
||||||
| LibraryItem
|
|
||||||
| /* pending library item */ {
|
|
||||||
id: null;
|
|
||||||
elements: readonly NonDeleted<ExcalidrawElement>[];
|
|
||||||
}
|
|
||||||
| null;
|
|
||||||
onClick?: () => void;
|
|
||||||
key: string;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Stack.Col key={params.key}>
|
|
||||||
<LibraryUnit
|
|
||||||
elements={params.item?.elements}
|
|
||||||
files={files}
|
|
||||||
isPending={!params.item?.id && !!params.item?.elements}
|
|
||||||
onClick={params.onClick || (() => {})}
|
|
||||||
id={params.item?.id || null}
|
|
||||||
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
|
|
||||||
onToggle={(id, event) => {
|
|
||||||
onToggle(id, event);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack.Col>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLibrarySection = (
|
|
||||||
items: (
|
|
||||||
| LibraryItem
|
|
||||||
| /* pending library item */ {
|
|
||||||
id: null;
|
|
||||||
elements: readonly NonDeleted<ExcalidrawElement>[];
|
|
||||||
}
|
|
||||||
)[],
|
|
||||||
) => {
|
|
||||||
const _items = items.map((item) => {
|
|
||||||
if (item.id) {
|
|
||||||
return createLibraryItemCompo({
|
|
||||||
item,
|
|
||||||
onClick: () => onInsertShape(item.elements),
|
|
||||||
key: item.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return createLibraryItemCompo({
|
|
||||||
key: "__pending__item__",
|
|
||||||
item,
|
|
||||||
onClick: () => onAddToLibrary(pendingElements),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ensure we render all empty cells if no items are present
|
|
||||||
let rows = chunk(_items, CELLS_PER_ROW);
|
|
||||||
if (!rows.length) {
|
|
||||||
rows = [[]];
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows.map((rowItems, index, rows) => {
|
|
||||||
if (index === rows.length - 1) {
|
|
||||||
// pad row with empty cells
|
|
||||||
rowItems = rowItems.concat(
|
|
||||||
new Array(CELLS_PER_ROW - rowItems.length)
|
|
||||||
.fill(null)
|
|
||||||
.map((_, index) => {
|
|
||||||
return createLibraryItemCompo({
|
|
||||||
key: `empty_${index}`,
|
|
||||||
item: null,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Stack.Row align="center" gap={1} key={index}>
|
|
||||||
{rowItems}
|
|
||||||
</Stack.Row>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const publishedItems = libraryItems.filter(
|
|
||||||
(item) => item.status === "published",
|
|
||||||
);
|
|
||||||
const unpublishedItems = [
|
|
||||||
// append pending library item
|
|
||||||
...(pendingElements.length
|
|
||||||
? [{ id: null, elements: pendingElements }]
|
|
||||||
: []),
|
|
||||||
...libraryItems.filter((item) => item.status !== "published"),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="library-menu-items-container">
|
|
||||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
|
||||||
<div className="layer-ui__library-header" key="library-header">
|
|
||||||
{renderLibraryActions()}
|
|
||||||
<a
|
|
||||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
|
||||||
window.name || "_blank"
|
|
||||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
|
||||||
VERSIONS.excalidrawLibrary
|
|
||||||
}`}
|
|
||||||
target="_excalidraw_libraries"
|
|
||||||
>
|
|
||||||
{t("labels.libraries")}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<Stack.Col
|
|
||||||
className="library-menu-items-container__items"
|
|
||||||
align="start"
|
|
||||||
gap={1}
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
<div className="separator">{t("labels.personalLib")}</div>
|
|
||||||
{renderLibrarySection(unpublishedItems)}
|
|
||||||
</>
|
|
||||||
|
|
||||||
<>
|
|
||||||
<div className="separator">{t("labels.excalidrawLib")} </div>
|
|
||||||
|
|
||||||
{renderLibrarySection(publishedItems)}
|
|
||||||
</>
|
|
||||||
</Stack.Col>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LibraryMenuItems;
|
|
@@ -1,5 +1,3 @@
|
|||||||
@import "../css/variables.module";
|
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.library-unit {
|
.library-unit {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -9,26 +7,10 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 63px;
|
width: 63px;
|
||||||
height: 63px; // match width
|
height: 63px; // match width
|
||||||
|
|
||||||
&--hover {
|
|
||||||
box-shadow: inset 0px 0px 0px 2px $oc-blue-5;
|
|
||||||
border-color: $oc-blue-5;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--selected {
|
|
||||||
box-shadow: inset 0px 0px 0px 2px $oc-blue-8;
|
|
||||||
border-color: $oc-blue-8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.theme--dark .library-unit {
|
|
||||||
border-color: rgb(48, 48, 48);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-unit__dragger {
|
.library-unit__dragger {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -40,9 +22,9 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-unit__checkbox-container,
|
.library-unit__removeFromLibrary,
|
||||||
.library-unit__checkbox-container:hover,
|
.library-unit__removeFromLibrary:hover,
|
||||||
.library-unit__checkbox-container:active {
|
.library-unit__removeFromLibrary:active {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -50,35 +32,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.5rem;
|
padding: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 2rem;
|
right: 5px;
|
||||||
bottom: 2rem;
|
top: 5px;
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
input {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-unit__checkbox {
|
|
||||||
position: absolute;
|
|
||||||
left: 2.3rem;
|
|
||||||
bottom: 2.3rem;
|
|
||||||
|
|
||||||
.Checkbox-box {
|
|
||||||
width: 13px;
|
|
||||||
height: 13px;
|
|
||||||
border-radius: 2px;
|
|
||||||
margin: 0.5em 0.5em 0.2em 0.2em;
|
|
||||||
background-color: $oc-blue-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Checkbox:hover {
|
|
||||||
.Checkbox-box {
|
|
||||||
background-color: $oc-blue-2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-unit__removeFromLibrary > svg {
|
.library-unit__removeFromLibrary > svg {
|
||||||
@@ -86,37 +43,29 @@
|
|||||||
width: 16px;
|
width: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-unit__adder {
|
.library-unit__pulse {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
animation: library-unit__adder-animation 1s ease-in infinite;
|
animation: library-unit__pulse-animation 1s ease-in infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-unit__adder {
|
.library-unit__adder {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 40%;
|
left: 50%;
|
||||||
top: 40%;
|
top: 50%;
|
||||||
width: 2rem;
|
width: 20px;
|
||||||
height: 2rem;
|
height: 20px;
|
||||||
margin-left: -10px;
|
margin-left: -10px;
|
||||||
margin-top: -10px;
|
margin-top: -10px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.library-unit:hover .library-unit__adder {
|
|
||||||
fill: $oc-blue-7;
|
|
||||||
}
|
|
||||||
.library-unit:active .library-unit__adder {
|
|
||||||
animation: none;
|
|
||||||
transform: scale(0.8);
|
|
||||||
fill: $oc-black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-unit__active {
|
.library-unit__active {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes library-unit__adder-animation {
|
@keyframes library-unit__pulse-animation {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(0.85);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
@@ -124,7 +73,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: scale(0.85);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,103 +1,81 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { close } from "../components/icons";
|
||||||
import { MIME_TYPES } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import { exportToSvg } from "../scene/export";
|
import { exportToSvg } from "../scene/export";
|
||||||
import { BinaryFiles, LibraryItem } from "../types";
|
import { LibraryItem } from "../types";
|
||||||
import "./LibraryUnit.scss";
|
import "./LibraryUnit.scss";
|
||||||
import { CheckboxItem } from "./CheckboxItem";
|
|
||||||
|
|
||||||
|
// fa-plus
|
||||||
const PLUS_ICON = (
|
const PLUS_ICON = (
|
||||||
<svg viewBox="0 0 1792 1792">
|
<svg viewBox="0 0 1792 1792">
|
||||||
<path
|
<path
|
||||||
d="M1600 736v192c0 26.667-9.33 49.333-28 68-18.67 18.67-41.33 28-68 28h-416v416c0 26.67-9.33 49.33-28 68s-41.33 28-68 28H800c-26.667 0-49.333-9.33-68-28s-28-41.33-28-68v-416H288c-26.667 0-49.333-9.33-68-28-18.667-18.667-28-41.333-28-68V736c0-26.667 9.333-49.333 28-68s41.333-28 68-28h416V224c0-26.667 9.333-49.333 28-68s41.333-28 68-28h192c26.67 0 49.33 9.333 68 28s28 41.333 28 68v416h416c26.67 0 49.33 9.333 68 28s28 41.333 28 68Z"
|
fill="currentColor"
|
||||||
style={{
|
d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"
|
||||||
stroke: "#fff",
|
|
||||||
strokeWidth: 140,
|
|
||||||
}}
|
|
||||||
transform="translate(0 64)"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const LibraryUnit = ({
|
export const LibraryUnit = ({
|
||||||
id,
|
|
||||||
elements,
|
elements,
|
||||||
files,
|
pendingElements,
|
||||||
isPending,
|
onRemoveFromLibrary,
|
||||||
onClick,
|
onClick,
|
||||||
selected,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
}: {
|
||||||
id: LibraryItem["id"] | /** for pending item */ null;
|
elements?: LibraryItem;
|
||||||
elements?: LibraryItem["elements"];
|
pendingElements?: LibraryItem;
|
||||||
files: BinaryFiles;
|
onRemoveFromLibrary: () => void;
|
||||||
isPending?: boolean;
|
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
selected: boolean;
|
|
||||||
onToggle: (id: string, event: React.MouseEvent) => void;
|
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = ref.current;
|
const elementsToRender = elements || pendingElements;
|
||||||
if (!node) {
|
if (!elementsToRender) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const svg = exportToSvg(elementsToRender, {
|
||||||
(async () => {
|
|
||||||
if (!elements) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const svg = await exportToSvg(
|
|
||||||
elements,
|
|
||||||
{
|
|
||||||
exportBackground: false,
|
exportBackground: false,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
},
|
});
|
||||||
files,
|
for (const child of ref.current!.children) {
|
||||||
);
|
if (child.tagName !== "svg") {
|
||||||
node.innerHTML = svg.outerHTML;
|
continue;
|
||||||
})();
|
}
|
||||||
|
ref.current!.removeChild(child);
|
||||||
|
}
|
||||||
|
ref.current!.appendChild(svg);
|
||||||
|
|
||||||
|
const current = ref.current!;
|
||||||
return () => {
|
return () => {
|
||||||
node.innerHTML = "";
|
current.removeChild(svg);
|
||||||
};
|
};
|
||||||
}, [elements, files]);
|
}, [elements, pendingElements]);
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const adder = isPending && (
|
|
||||||
|
const adder = (isHovered || isMobile) && pendingElements && (
|
||||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("library-unit", {
|
className={clsx("library-unit", {
|
||||||
"library-unit__active": elements,
|
"library-unit__active": elements || pendingElements,
|
||||||
"library-unit--hover": elements && isHovered,
|
|
||||||
"library-unit--selected": selected,
|
|
||||||
})}
|
})}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx("library-unit__dragger", {
|
className={clsx("library-unit__dragger", {
|
||||||
"library-unit__pulse": !!isPending,
|
"library-unit__pulse": !!pendingElements,
|
||||||
})}
|
})}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
draggable={!!elements}
|
draggable={!!elements}
|
||||||
onClick={
|
onClick={!!elements || !!pendingElements ? onClick : undefined}
|
||||||
!!elements || !!isPending
|
|
||||||
? (event) => {
|
|
||||||
if (id && event.shiftKey) {
|
|
||||||
onToggle(id, event);
|
|
||||||
} else {
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
setIsHovered(false);
|
setIsHovered(false);
|
||||||
event.dataTransfer.setData(
|
event.dataTransfer.setData(
|
||||||
@@ -107,12 +85,14 @@ export const LibraryUnit = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{adder}
|
{adder}
|
||||||
{id && elements && (isHovered || isMobile || selected) && (
|
{elements && (isHovered || isMobile) && (
|
||||||
<CheckboxItem
|
<button
|
||||||
checked={selected}
|
className="library-unit__removeFromLibrary"
|
||||||
onChange={(checked, event) => onToggle(id, event)}
|
aria-label={t("labels.removeFromLibrary")}
|
||||||
className="library-unit__checkbox"
|
onClick={onRemoveFromLibrary}
|
||||||
/>
|
>
|
||||||
|
{close}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
export const LoadingMessage = () => {
|
export const LoadingMessage = () => {
|
||||||
|
@@ -2,7 +2,8 @@ import "./ToolIcon.scss";
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ToolButtonSize } from "./ToolButton";
|
|
||||||
|
type LockIconSize = "s" | "m";
|
||||||
|
|
||||||
type LockIconProps = {
|
type LockIconProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -10,10 +11,9 @@ type LockIconProps = {
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange?(): void;
|
onChange?(): void;
|
||||||
zenModeEnabled?: boolean;
|
zenModeEnabled?: boolean;
|
||||||
isMobile?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
const DEFAULT_SIZE: LockIconSize = "m";
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
CHECKED: (
|
CHECKED: (
|
||||||
@@ -43,10 +43,10 @@ export const LockButton = (props: LockIconProps) => {
|
|||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
|
||||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||||
{
|
{
|
||||||
"is-mobile": props.isMobile,
|
"zen-mode-visibility--hidden": props.zenModeEnabled,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
title={`${props.title} — Q`}
|
title={`${props.title} — Q`}
|
||||||
|
@@ -17,7 +17,6 @@ import { LockButton } from "./LockButton";
|
|||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
import { LibraryButton } from "./LibraryButton";
|
import { LibraryButton } from "./LibraryButton";
|
||||||
import { PenModeButton } from "./PenModeButton";
|
|
||||||
|
|
||||||
type MobileMenuProps = {
|
type MobileMenuProps = {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
@@ -29,17 +28,11 @@ type MobileMenuProps = {
|
|||||||
libraryMenu: JSX.Element | null;
|
libraryMenu: JSX.Element | null;
|
||||||
onCollabButtonClick?: () => void;
|
onCollabButtonClick?: () => void;
|
||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onPenModeToggle: () => void;
|
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||||
viewModeEnabled: boolean;
|
viewModeEnabled: boolean;
|
||||||
showThemeBtn: boolean;
|
showThemeBtn: boolean;
|
||||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
|
||||||
renderTopRightUI?: (
|
|
||||||
isMobile: boolean,
|
|
||||||
appState: AppState,
|
|
||||||
) => JSX.Element | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MobileMenu = ({
|
export const MobileMenu = ({
|
||||||
@@ -52,14 +45,11 @@ export const MobileMenu = ({
|
|||||||
setAppState,
|
setAppState,
|
||||||
onCollabButtonClick,
|
onCollabButtonClick,
|
||||||
onLockToggle,
|
onLockToggle,
|
||||||
onPenModeToggle,
|
|
||||||
canvas,
|
canvas,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
viewModeEnabled,
|
viewModeEnabled,
|
||||||
showThemeBtn,
|
showThemeBtn,
|
||||||
onImageAction,
|
|
||||||
renderTopRightUI,
|
|
||||||
}: MobileMenuProps) => {
|
}: MobileMenuProps) => {
|
||||||
const renderToolbar = () => {
|
const renderToolbar = () => {
|
||||||
return (
|
return (
|
||||||
@@ -67,47 +57,29 @@ export const MobileMenu = ({
|
|||||||
<Section heading="shapes">
|
<Section heading="shapes">
|
||||||
{(heading) => (
|
{(heading) => (
|
||||||
<Stack.Col gap={4} align="center">
|
<Stack.Col gap={4} align="center">
|
||||||
<Stack.Row gap={1} className="App-toolbar-container">
|
<Stack.Row gap={1}>
|
||||||
<Island padding={1} className="App-toolbar">
|
<Island padding={1}>
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<ShapesSwitcher
|
<ShapesSwitcher
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
elementType={appState.elementType}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
onImageAction={({ pointerType }) => {
|
|
||||||
onImageAction({
|
|
||||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
|
||||||
<LockButton
|
<LockButton
|
||||||
checked={appState.elementLocked}
|
checked={appState.elementLocked}
|
||||||
onChange={onLockToggle}
|
onChange={onLockToggle}
|
||||||
title={t("toolBar.lock")}
|
title={t("toolBar.lock")}
|
||||||
isMobile
|
|
||||||
/>
|
|
||||||
<LibraryButton
|
|
||||||
appState={appState}
|
|
||||||
setAppState={setAppState}
|
|
||||||
isMobile
|
|
||||||
/>
|
|
||||||
<PenModeButton
|
|
||||||
checked={appState.penMode}
|
|
||||||
onChange={onPenModeToggle}
|
|
||||||
title={t("toolBar.penMode")}
|
|
||||||
isMobile
|
|
||||||
penDetected={appState.penDetected}
|
|
||||||
/>
|
/>
|
||||||
|
<LibraryButton appState={appState} setAppState={setAppState} />
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{libraryMenu}
|
{libraryMenu}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
<HintViewer appState={appState} elements={elements} isMobile={true} />
|
<HintViewer appState={appState} elements={elements} />
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -196,9 +168,10 @@ export const MobileMenu = ({
|
|||||||
)
|
)
|
||||||
.map(([clientId, client]) => (
|
.map(([clientId, client]) => (
|
||||||
<React.Fragment key={clientId}>
|
<React.Fragment key={clientId}>
|
||||||
{actionManager.renderAction("goToCollaborator", {
|
{actionManager.renderAction(
|
||||||
id: clientId,
|
"goToCollaborator",
|
||||||
})}
|
clientId,
|
||||||
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</UserList>
|
</UserList>
|
||||||
|
@@ -6,7 +6,6 @@ import clsx from "clsx";
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { useExcalidrawContainer, useIsMobile } from "./App";
|
import { useExcalidrawContainer, useIsMobile } from "./App";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { THEME } from "../constants";
|
|
||||||
|
|
||||||
export const Modal = (props: {
|
export const Modal = (props: {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -15,9 +14,8 @@ export const Modal = (props: {
|
|||||||
onCloseRequest(): void;
|
onCloseRequest(): void;
|
||||||
labelledBy: string;
|
labelledBy: string;
|
||||||
theme?: AppState["theme"];
|
theme?: AppState["theme"];
|
||||||
closeOnClickOutside?: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
|
const { theme = "light" } = props;
|
||||||
const modalRoot = useBodyRoot(theme);
|
const modalRoot = useBodyRoot(theme);
|
||||||
|
|
||||||
if (!modalRoot) {
|
if (!modalRoot) {
|
||||||
@@ -40,10 +38,7 @@ export const Modal = (props: {
|
|||||||
onKeyDown={handleKeydown}
|
onKeyDown={handleKeydown}
|
||||||
aria-labelledby={props.labelledBy}
|
aria-labelledby={props.labelledBy}
|
||||||
>
|
>
|
||||||
<div
|
<div className="Modal__background" onClick={props.onCloseRequest}></div>
|
||||||
className="Modal__background"
|
|
||||||
onClick={closeOnClickOutside ? props.onCloseRequest : undefined}
|
|
||||||
></div>
|
|
||||||
<div
|
<div
|
||||||
className="Modal__content"
|
className="Modal__content"
|
||||||
style={{ "--max-width": `${props.maxWidth}px` }}
|
style={{ "--max-width": `${props.maxWidth}px` }}
|
||||||
|
@@ -34,25 +34,19 @@ const ChartPreviewBtn = (props: {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
setChartElements(elements);
|
setChartElements(elements);
|
||||||
let svg: SVGSVGElement;
|
|
||||||
const previewNode = previewRef.current!;
|
|
||||||
|
|
||||||
(async () => {
|
const svg = exportToSvg(elements, {
|
||||||
svg = await exportToSvg(
|
|
||||||
elements,
|
|
||||||
{
|
|
||||||
exportBackground: false,
|
exportBackground: false,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
},
|
});
|
||||||
null, // files
|
|
||||||
);
|
const previewNode = previewRef.current!;
|
||||||
|
|
||||||
previewNode.appendChild(svg);
|
previewNode.appendChild(svg);
|
||||||
|
|
||||||
if (props.selected) {
|
if (props.selected) {
|
||||||
(previewNode.parentNode as HTMLDivElement).focus();
|
(previewNode.parentNode as HTMLDivElement).focus();
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
previewNode.removeChild(svg);
|
previewNode.removeChild(svg);
|
||||||
@@ -82,7 +76,7 @@ export const PasteChartDialog = ({
|
|||||||
appState: AppState;
|
appState: AppState;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
onInsertChart: (elements: LibraryItem["elements"]) => void;
|
onInsertChart: (elements: LibraryItem) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const handleClose = React.useCallback(() => {
|
const handleClose = React.useCallback(() => {
|
||||||
if (onClose) {
|
if (onClose) {
|
||||||
|
@@ -1,91 +0,0 @@
|
|||||||
import "./ToolIcon.scss";
|
|
||||||
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { ToolButtonSize } from "./ToolButton";
|
|
||||||
|
|
||||||
type PenModeIconProps = {
|
|
||||||
title?: string;
|
|
||||||
name?: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange?(): void;
|
|
||||||
zenModeEnabled?: boolean;
|
|
||||||
isMobile?: boolean;
|
|
||||||
penDetected: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
|
||||||
|
|
||||||
const ICONS = {
|
|
||||||
CHECKED: (
|
|
||||||
<svg
|
|
||||||
width="205"
|
|
||||||
height="205"
|
|
||||||
viewBox="0 0 205 205"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path d="m35 195-25-29.17V50h50v115l-25 30" />
|
|
||||||
<path d="M10 40V10h50v30H10" />
|
|
||||||
<path d="M125 145h70v50h-70" />
|
|
||||||
<path d="M190 145v-30l-10-20h-40l-10 20v30h15v-30l5-5h20l5 5v30h15" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
UNCHECKED: (
|
|
||||||
<svg
|
|
||||||
width="205"
|
|
||||||
height="205"
|
|
||||||
viewBox="0 0 205 205"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="unlocked-icon rtl-mirror"
|
|
||||||
>
|
|
||||||
<path d="m35 195-25-29.17V50h50v115l-25 30" />
|
|
||||||
<path d="M10 40V10h50v30H10" />
|
|
||||||
<path d="M125 145h70v50h-70" />
|
|
||||||
<path d="M145 145v-30l-10-20H95l-10 20v30h15v-30l5-5h20l5 5v30h15" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PenModeButton = (props: PenModeIconProps) => {
|
|
||||||
if (!props.penDetected) {
|
|
||||||
if (props.isMobile) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
className={clsx(
|
|
||||||
"ToolIcon ToolIcon__penMode ToolIcon_type_floating",
|
|
||||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
|
||||||
{
|
|
||||||
"is-mobile": props.isMobile,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="ToolIcon__icon ToolIcon__hidden" />
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
className={clsx(
|
|
||||||
"ToolIcon ToolIcon__penMode ToolIcon_type_floating",
|
|
||||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
|
||||||
{
|
|
||||||
"is-mobile": props.isMobile,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
title={`${props.title}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="ToolIcon_type_checkbox"
|
|
||||||
type="checkbox"
|
|
||||||
name={props.name}
|
|
||||||
onChange={props.onChange}
|
|
||||||
checked={props.checked}
|
|
||||||
aria-label={props.title}
|
|
||||||
/>
|
|
||||||
<div className="ToolIcon__icon">
|
|
||||||
{props.checked ? ICONS.CHECKED : ICONS.UNCHECKED}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -8,10 +8,6 @@ type Props = {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onCloseRequest?(event: PointerEvent): void;
|
onCloseRequest?(event: PointerEvent): void;
|
||||||
fitInViewport?: boolean;
|
fitInViewport?: boolean;
|
||||||
offsetLeft?: number;
|
|
||||||
offsetTop?: number;
|
|
||||||
viewportWidth?: number;
|
|
||||||
viewportHeight?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Popover = ({
|
export const Popover = ({
|
||||||
@@ -20,10 +16,6 @@ export const Popover = ({
|
|||||||
top,
|
top,
|
||||||
onCloseRequest,
|
onCloseRequest,
|
||||||
fitInViewport = false,
|
fitInViewport = false,
|
||||||
offsetLeft = 0,
|
|
||||||
offsetTop = 0,
|
|
||||||
viewportWidth = window.innerWidth,
|
|
||||||
viewportHeight = window.innerHeight,
|
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -32,14 +24,17 @@ export const Popover = ({
|
|||||||
if (fitInViewport && popoverRef.current) {
|
if (fitInViewport && popoverRef.current) {
|
||||||
const element = popoverRef.current;
|
const element = popoverRef.current;
|
||||||
const { x, y, width, height } = element.getBoundingClientRect();
|
const { x, y, width, height } = element.getBoundingClientRect();
|
||||||
if (x + width - offsetLeft > viewportWidth) {
|
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
if (x + width > viewportWidth) {
|
||||||
element.style.left = `${viewportWidth - width}px`;
|
element.style.left = `${viewportWidth - width}px`;
|
||||||
}
|
}
|
||||||
if (y + height - offsetTop > viewportHeight) {
|
const viewportHeight = window.innerHeight;
|
||||||
|
if (y + height > viewportHeight) {
|
||||||
element.style.top = `${viewportHeight - height}px`;
|
element.style.top = `${viewportHeight - height}px`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]);
|
}, [fitInViewport]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onCloseRequest) {
|
if (onCloseRequest) {
|
||||||
|
@@ -42,7 +42,6 @@ export const ProjectName = (props: Props) => {
|
|||||||
</label>
|
</label>
|
||||||
{props.isNameEditable ? (
|
{props.isNameEditable ? (
|
||||||
<input
|
<input
|
||||||
type="text"
|
|
||||||
className="TextInput"
|
className="TextInput"
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
@@ -1,92 +0,0 @@
|
|||||||
@import "../css/variables.module";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.publish-library {
|
|
||||||
&__fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
label {
|
|
||||||
padding: 1em;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
span {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: $oc-gray-6;
|
|
||||||
}
|
|
||||||
input,
|
|
||||||
textarea {
|
|
||||||
width: 70%;
|
|
||||||
padding: 0.6em;
|
|
||||||
font-family: var(--ui-font);
|
|
||||||
}
|
|
||||||
|
|
||||||
.required {
|
|
||||||
color: $oc-red-8;
|
|
||||||
margin: 0.2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__buttons {
|
|
||||||
display: flex;
|
|
||||||
padding: 0.2rem 0;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
.ToolIcon__icon {
|
|
||||||
min-width: 2.5rem;
|
|
||||||
width: auto;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon_type_button {
|
|
||||||
margin-left: 1rem;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--confirm.ToolIcon_type_button {
|
|
||||||
background-color: $oc-blue-6;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $oc-blue-8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--cancel.ToolIcon_type_button {
|
|
||||||
background-color: $oc-gray-5;
|
|
||||||
&:hover {
|
|
||||||
background-color: $oc-gray-6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__icon {
|
|
||||||
color: $oc-white;
|
|
||||||
.Spinner {
|
|
||||||
--spinner-color: #fff;
|
|
||||||
svg {
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-library-items {
|
|
||||||
display: flex;
|
|
||||||
padding: 0 0.8rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.single-library-item-wrapper {
|
|
||||||
width: 9rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-note {
|
|
||||||
padding: 1em;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 14px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,455 +0,0 @@
|
|||||||
import { ReactNode, useCallback, useEffect, useState } from "react";
|
|
||||||
import OpenColor from "open-color";
|
|
||||||
|
|
||||||
import { Dialog } from "./Dialog";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
|
|
||||||
import { AppState, LibraryItems, LibraryItem } from "../types";
|
|
||||||
import { exportToCanvas } from "../packages/utils";
|
|
||||||
import {
|
|
||||||
EXPORT_DATA_TYPES,
|
|
||||||
EXPORT_SOURCE,
|
|
||||||
MIME_TYPES,
|
|
||||||
VERSIONS,
|
|
||||||
} from "../constants";
|
|
||||||
import { ExportedLibraryData } from "../data/types";
|
|
||||||
|
|
||||||
import "./PublishLibrary.scss";
|
|
||||||
import SingleLibraryItem from "./SingleLibraryItem";
|
|
||||||
import { canvasToBlob, resizeImageFile } from "../data/blob";
|
|
||||||
import { chunk } from "../utils";
|
|
||||||
|
|
||||||
interface PublishLibraryDataParams {
|
|
||||||
authorName: string;
|
|
||||||
githubHandle: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
twitterHandle: string;
|
|
||||||
website: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
|
|
||||||
|
|
||||||
const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
|
|
||||||
JSON.stringify(data),
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
// Unable to access window.localStorage
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const importPublishLibDataFromStorage = () => {
|
|
||||||
try {
|
|
||||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
|
|
||||||
if (data) {
|
|
||||||
return JSON.parse(data);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
// Unable to access localStorage
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generatePreviewImage = async (libraryItems: LibraryItems) => {
|
|
||||||
const MAX_ITEMS_PER_ROW = 6;
|
|
||||||
const BOX_SIZE = 128;
|
|
||||||
const BOX_PADDING = Math.round(BOX_SIZE / 16);
|
|
||||||
const BORDER_WIDTH = Math.max(Math.round(BOX_SIZE / 64), 2);
|
|
||||||
|
|
||||||
const rows = chunk(libraryItems, MAX_ITEMS_PER_ROW);
|
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
|
|
||||||
canvas.width =
|
|
||||||
rows[0].length * BOX_SIZE +
|
|
||||||
(rows[0].length + 1) * (BOX_PADDING * 2) -
|
|
||||||
BOX_PADDING * 2;
|
|
||||||
canvas.height =
|
|
||||||
rows.length * BOX_SIZE +
|
|
||||||
(rows.length + 1) * (BOX_PADDING * 2) -
|
|
||||||
BOX_PADDING * 2;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d")!;
|
|
||||||
|
|
||||||
ctx.fillStyle = OpenColor.white;
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
// draw items
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
for (const [index, item] of libraryItems.entries()) {
|
|
||||||
const itemCanvas = await exportToCanvas({
|
|
||||||
elements: item.elements,
|
|
||||||
files: null,
|
|
||||||
maxWidthOrHeight: BOX_SIZE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { width, height } = itemCanvas;
|
|
||||||
|
|
||||||
// draw item
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
const rowOffset =
|
|
||||||
Math.floor(index / MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
|
|
||||||
const colOffset =
|
|
||||||
(index % MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
|
|
||||||
|
|
||||||
ctx.drawImage(
|
|
||||||
itemCanvas,
|
|
||||||
colOffset + (BOX_SIZE - width) / 2 + BOX_PADDING,
|
|
||||||
rowOffset + (BOX_SIZE - height) / 2 + BOX_PADDING,
|
|
||||||
);
|
|
||||||
|
|
||||||
// draw item border
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
ctx.lineWidth = BORDER_WIDTH;
|
|
||||||
ctx.strokeStyle = OpenColor.gray[4];
|
|
||||||
ctx.strokeRect(
|
|
||||||
colOffset + BOX_PADDING / 2,
|
|
||||||
rowOffset + BOX_PADDING / 2,
|
|
||||||
BOX_SIZE + BOX_PADDING,
|
|
||||||
BOX_SIZE + BOX_PADDING,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await resizeImageFile(
|
|
||||||
new File([await canvasToBlob(canvas)], "preview", { type: MIME_TYPES.png }),
|
|
||||||
{
|
|
||||||
outputType: MIME_TYPES.jpg,
|
|
||||||
maxWidthOrHeight: 5000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PublishLibrary = ({
|
|
||||||
onClose,
|
|
||||||
libraryItems,
|
|
||||||
appState,
|
|
||||||
onSuccess,
|
|
||||||
onError,
|
|
||||||
updateItemsInStorage,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
onClose: () => void;
|
|
||||||
libraryItems: LibraryItems;
|
|
||||||
appState: AppState;
|
|
||||||
onSuccess: (data: {
|
|
||||||
url: string;
|
|
||||||
authorName: string;
|
|
||||||
items: LibraryItems;
|
|
||||||
}) => void;
|
|
||||||
|
|
||||||
onError: (error: Error) => void;
|
|
||||||
updateItemsInStorage: (items: LibraryItems) => void;
|
|
||||||
onRemove: (id: string) => void;
|
|
||||||
}) => {
|
|
||||||
const [libraryData, setLibraryData] = useState<PublishLibraryDataParams>({
|
|
||||||
authorName: "",
|
|
||||||
githubHandle: "",
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
twitterHandle: "",
|
|
||||||
website: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const data = importPublishLibDataFromStorage();
|
|
||||||
if (data) {
|
|
||||||
setLibraryData(data);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [clonedLibItems, setClonedLibItems] = useState<LibraryItems>(
|
|
||||||
libraryItems.slice(),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setClonedLibItems(libraryItems.slice());
|
|
||||||
}, [libraryItems]);
|
|
||||||
|
|
||||||
const onInputChange = (event: any) => {
|
|
||||||
setLibraryData({
|
|
||||||
...libraryData,
|
|
||||||
[event.target.name]: event.target.value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setIsSubmitting(true);
|
|
||||||
const erroredLibItems: LibraryItem[] = [];
|
|
||||||
let isError = false;
|
|
||||||
clonedLibItems.forEach((libItem) => {
|
|
||||||
let error = "";
|
|
||||||
if (!libItem.name) {
|
|
||||||
error = t("publishDialog.errors.required");
|
|
||||||
isError = true;
|
|
||||||
}
|
|
||||||
erroredLibItems.push({ ...libItem, error });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
setClonedLibItems(erroredLibItems);
|
|
||||||
setIsSubmitting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewImage = await generatePreviewImage(clonedLibItems);
|
|
||||||
|
|
||||||
const libContent: ExportedLibraryData = {
|
|
||||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
|
||||||
version: VERSIONS.excalidrawLibrary,
|
|
||||||
source: EXPORT_SOURCE,
|
|
||||||
libraryItems: clonedLibItems,
|
|
||||||
};
|
|
||||||
const content = JSON.stringify(libContent, null, 2);
|
|
||||||
const lib = new Blob([content], { type: "application/json" });
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("excalidrawLib", lib);
|
|
||||||
formData.append("previewImage", previewImage);
|
|
||||||
formData.append("previewImageType", previewImage.type);
|
|
||||||
formData.append("title", libraryData.name);
|
|
||||||
formData.append("authorName", libraryData.authorName);
|
|
||||||
formData.append("githubHandle", libraryData.githubHandle);
|
|
||||||
formData.append("name", libraryData.name);
|
|
||||||
formData.append("description", libraryData.description);
|
|
||||||
formData.append("twitterHandle", libraryData.twitterHandle);
|
|
||||||
formData.append("website", libraryData.website);
|
|
||||||
|
|
||||||
fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
|
|
||||||
method: "post",
|
|
||||||
body: formData,
|
|
||||||
})
|
|
||||||
.then(
|
|
||||||
(response) => {
|
|
||||||
if (response.ok) {
|
|
||||||
return response.json().then(({ url }) => {
|
|
||||||
// flush data from local storage
|
|
||||||
localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
|
|
||||||
onSuccess({
|
|
||||||
url,
|
|
||||||
authorName: libraryData.authorName,
|
|
||||||
items: clonedLibItems,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
.json()
|
|
||||||
.catch(() => {
|
|
||||||
throw new Error(response.statusText || "something went wrong");
|
|
||||||
})
|
|
||||||
.then((error) => {
|
|
||||||
throw new Error(
|
|
||||||
error.message || response.statusText || "something went wrong",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
console.error(err);
|
|
||||||
onError(err);
|
|
||||||
setIsSubmitting(false);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
onError(err);
|
|
||||||
setIsSubmitting(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLibraryItems = () => {
|
|
||||||
const items: ReactNode[] = [];
|
|
||||||
clonedLibItems.forEach((libItem, index) => {
|
|
||||||
items.push(
|
|
||||||
<div className="single-library-item-wrapper" key={index}>
|
|
||||||
<SingleLibraryItem
|
|
||||||
libItem={libItem}
|
|
||||||
appState={appState}
|
|
||||||
index={index}
|
|
||||||
onChange={(val, index) => {
|
|
||||||
const items = clonedLibItems.slice();
|
|
||||||
items[index].name = val;
|
|
||||||
setClonedLibItems(items);
|
|
||||||
}}
|
|
||||||
onRemove={onRemove}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return <div className="selected-library-items">{items}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDialogClose = useCallback(() => {
|
|
||||||
updateItemsInStorage(clonedLibItems);
|
|
||||||
savePublishLibDataToStorage(libraryData);
|
|
||||||
onClose();
|
|
||||||
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
|
|
||||||
|
|
||||||
const shouldRenderForm = !!libraryItems.length;
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
onCloseRequest={onDialogClose}
|
|
||||||
title={t("publishDialog.title")}
|
|
||||||
className="publish-library"
|
|
||||||
>
|
|
||||||
{shouldRenderForm ? (
|
|
||||||
<form onSubmit={onSubmit}>
|
|
||||||
<div className="publish-library-note">
|
|
||||||
{t("publishDialog.noteDescription.pre")}
|
|
||||||
<a
|
|
||||||
href="https://libraries.excalidraw.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{t("publishDialog.noteDescription.link")}
|
|
||||||
</a>{" "}
|
|
||||||
{t("publishDialog.noteDescription.post")}
|
|
||||||
</div>
|
|
||||||
<span className="publish-library-note">
|
|
||||||
{t("publishDialog.noteGuidelines.pre")}
|
|
||||||
<a
|
|
||||||
href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{t("publishDialog.noteGuidelines.link")}
|
|
||||||
</a>
|
|
||||||
{t("publishDialog.noteGuidelines.post")}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="publish-library-note">
|
|
||||||
{t("publishDialog.noteItems")}
|
|
||||||
</div>
|
|
||||||
{renderLibraryItems()}
|
|
||||||
<div className="publish-library__fields">
|
|
||||||
<label>
|
|
||||||
<div>
|
|
||||||
<span>{t("publishDialog.libraryName")}</span>
|
|
||||||
<span aria-hidden="true" className="required">
|
|
||||||
*
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
value={libraryData.name}
|
|
||||||
onChange={onInputChange}
|
|
||||||
placeholder={t("publishDialog.placeholder.libraryName")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label style={{ alignItems: "flex-start" }}>
|
|
||||||
<div>
|
|
||||||
<span>{t("publishDialog.libraryDesc")}</span>
|
|
||||||
<span aria-hidden="true" className="required">
|
|
||||||
*
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
name="description"
|
|
||||||
rows={4}
|
|
||||||
required
|
|
||||||
value={libraryData.description}
|
|
||||||
onChange={onInputChange}
|
|
||||||
placeholder={t("publishDialog.placeholder.libraryDesc")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<div>
|
|
||||||
<span>{t("publishDialog.authorName")}</span>
|
|
||||||
<span aria-hidden="true" className="required">
|
|
||||||
*
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="authorName"
|
|
||||||
required
|
|
||||||
value={libraryData.authorName}
|
|
||||||
onChange={onInputChange}
|
|
||||||
placeholder={t("publishDialog.placeholder.authorName")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>{t("publishDialog.githubUsername")}</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="githubHandle"
|
|
||||||
value={libraryData.githubHandle}
|
|
||||||
onChange={onInputChange}
|
|
||||||
placeholder={t("publishDialog.placeholder.githubHandle")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>{t("publishDialog.twitterUsername")}</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="twitterHandle"
|
|
||||||
value={libraryData.twitterHandle}
|
|
||||||
onChange={onInputChange}
|
|
||||||
placeholder={t("publishDialog.placeholder.twitterHandle")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>{t("publishDialog.website")}</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="website"
|
|
||||||
pattern="https?://.+"
|
|
||||||
title={t("publishDialog.errors.website")}
|
|
||||||
value={libraryData.website}
|
|
||||||
onChange={onInputChange}
|
|
||||||
placeholder={t("publishDialog.placeholder.website")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<span className="publish-library-note">
|
|
||||||
{t("publishDialog.noteLicense.pre")}
|
|
||||||
<a
|
|
||||||
href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{t("publishDialog.noteLicense.link")}
|
|
||||||
</a>
|
|
||||||
{t("publishDialog.noteLicense.post")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="publish-library__buttons">
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.cancel")}
|
|
||||||
aria-label={t("buttons.cancel")}
|
|
||||||
label={t("buttons.cancel")}
|
|
||||||
onClick={onDialogClose}
|
|
||||||
data-testid="cancel-clear-canvas-button"
|
|
||||||
className="publish-library__buttons--cancel"
|
|
||||||
/>
|
|
||||||
<ToolButton
|
|
||||||
type="submit"
|
|
||||||
title={t("buttons.submit")}
|
|
||||||
aria-label={t("buttons.submit")}
|
|
||||||
label={t("buttons.submit")}
|
|
||||||
className="publish-library__buttons--confirm"
|
|
||||||
isLoading={isSubmitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<p style={{ padding: "1em", textAlign: "center", fontWeight: 500 }}>
|
|
||||||
{t("publishDialog.atleastOneLibItem")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PublishLibrary;
|
|
@@ -1,66 +0,0 @@
|
|||||||
@import "../css/variables.module";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.single-library-item {
|
|
||||||
position: relative;
|
|
||||||
&__svg {
|
|
||||||
width: 7.5rem;
|
|
||||||
height: 7.5rem;
|
|
||||||
border: 1px solid var(--button-gray-2);
|
|
||||||
margin: 0.3rem;
|
|
||||||
svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__icon {
|
|
||||||
background-color: $oc-white;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
margin: 0 0.5rem;
|
|
||||||
}
|
|
||||||
.ToolIcon,
|
|
||||||
.ToolIcon_type_button:hover {
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
.required,
|
|
||||||
.error {
|
|
||||||
color: $oc-red-8;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1rem;
|
|
||||||
margin: 0.2rem;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.3em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--remove {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.2rem;
|
|
||||||
right: 1.3rem;
|
|
||||||
|
|
||||||
.ToolIcon__icon {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.ToolIcon__icon {
|
|
||||||
background-color: $oc-red-6;
|
|
||||||
&:hover {
|
|
||||||
background-color: $oc-red-7;
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: $oc-red-8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
color: $oc-white;
|
|
||||||
padding: 0.26rem;
|
|
||||||
border-radius: 0.3em;
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,99 +0,0 @@
|
|||||||
import oc from "open-color";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { exportToSvg } from "../packages/utils";
|
|
||||||
import { AppState, LibraryItem } from "../types";
|
|
||||||
import { close } from "./icons";
|
|
||||||
|
|
||||||
import "./SingleLibraryItem.scss";
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
|
|
||||||
const SingleLibraryItem = ({
|
|
||||||
libItem,
|
|
||||||
appState,
|
|
||||||
index,
|
|
||||||
onChange,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
libItem: LibraryItem;
|
|
||||||
appState: AppState;
|
|
||||||
index: number;
|
|
||||||
onChange: (val: string, index: number) => void;
|
|
||||||
onRemove: (id: string) => void;
|
|
||||||
}) => {
|
|
||||||
const svgRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const node = svgRef.current;
|
|
||||||
if (!node) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
(async () => {
|
|
||||||
const svg = await exportToSvg({
|
|
||||||
elements: libItem.elements,
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
viewBackgroundColor: oc.white,
|
|
||||||
exportBackground: true,
|
|
||||||
},
|
|
||||||
files: null,
|
|
||||||
});
|
|
||||||
node.innerHTML = svg.outerHTML;
|
|
||||||
})();
|
|
||||||
}, [libItem.elements, appState]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="single-library-item">
|
|
||||||
<div ref={svgRef} className="single-library-item__svg" />
|
|
||||||
<ToolButton
|
|
||||||
aria-label={t("buttons.remove")}
|
|
||||||
type="button"
|
|
||||||
icon={close}
|
|
||||||
className="single-library-item--remove"
|
|
||||||
onClick={onRemove.bind(null, libItem.id)}
|
|
||||||
title={t("buttons.remove")}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
margin: "0.8rem 0.3rem",
|
|
||||||
width: "100%",
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: 500,
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ padding: "0.5em 0" }}>
|
|
||||||
<span style={{ fontWeight: 500, color: oc.gray[6] }}>
|
|
||||||
{t("publishDialog.itemName")}
|
|
||||||
</span>
|
|
||||||
<span aria-hidden="true" className="required">
|
|
||||||
*
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
ref={inputRef}
|
|
||||||
style={{ width: "80%", padding: "0.2rem" }}
|
|
||||||
defaultValue={libItem.name}
|
|
||||||
placeholder="Item name"
|
|
||||||
onChange={(event) => {
|
|
||||||
onChange(event.target.value, index);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<span className="error">{libItem.error}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SingleLibraryItem;
|
|
@@ -1,48 +0,0 @@
|
|||||||
@import "open-color/open-color.scss";
|
|
||||||
|
|
||||||
$duration: 1.6s;
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.Spinner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
|
|
||||||
--spinner-color: var(--icon-fill-color);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
animation: rotate $duration linear infinite;
|
|
||||||
transform-origin: center center;
|
|
||||||
}
|
|
||||||
|
|
||||||
circle {
|
|
||||||
stroke: var(--spinner-color);
|
|
||||||
animation: dash $duration linear 0s infinite;
|
|
||||||
stroke-linecap: round;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rotate {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dash {
|
|
||||||
0% {
|
|
||||||
stroke-dasharray: 1, 300;
|
|
||||||
stroke-dashoffset: 0;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
stroke-dasharray: 150, 300;
|
|
||||||
stroke-dashoffset: -200;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
stroke-dasharray: 1, 300;
|
|
||||||
stroke-dashoffset: -280;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,28 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
import "./Spinner.scss";
|
|
||||||
|
|
||||||
const Spinner = ({
|
|
||||||
size = "1em",
|
|
||||||
circleWidth = 8,
|
|
||||||
}: {
|
|
||||||
size?: string | number;
|
|
||||||
circleWidth?: number;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="Spinner">
|
|
||||||
<svg viewBox="0 0 100 100" style={{ width: size, height: size }}>
|
|
||||||
<circle
|
|
||||||
cx="50"
|
|
||||||
cy="50"
|
|
||||||
r={50 - circleWidth / 2}
|
|
||||||
strokeWidth={circleWidth}
|
|
||||||
fill="none"
|
|
||||||
strokeMiterlimit="10"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Spinner;
|
|
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.TextInput {
|
.TextInput {
|
||||||
|
color: var(--text-primary-color);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
border: 1.5px solid var(--button-gray-1);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: var(--space-factor);
|
||||||
|
background-color: var(--input-bg-color);
|
||||||
|
|
||||||
|
&:not(:focus) {
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--input-hover-bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import React, { useCallback, useEffect, useRef } from "react";
|
||||||
import { TOAST_TIMEOUT } from "../constants";
|
import { TOAST_TIMEOUT } from "../constants";
|
||||||
import "./Toast.scss";
|
import "./Toast.scss";
|
||||||
|
|
||||||
|
@@ -1,13 +1,10 @@
|
|||||||
import "./ToolIcon.scss";
|
import "./ToolIcon.scss";
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useExcalidrawContainer } from "./App";
|
import { useExcalidrawContainer } from "./App";
|
||||||
import { AbortError } from "../errors";
|
|
||||||
import Spinner from "./Spinner";
|
|
||||||
import { PointerType } from "../element/types";
|
|
||||||
|
|
||||||
export type ToolButtonSize = "small" | "medium";
|
type ToolIconSize = "s" | "m";
|
||||||
|
|
||||||
type ToolButtonBaseProps = {
|
type ToolButtonBaseProps = {
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
@@ -18,26 +15,20 @@ type ToolButtonBaseProps = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
size?: ToolButtonSize;
|
size?: ToolIconSize;
|
||||||
keyBindingLabel?: string;
|
keyBindingLabel?: string;
|
||||||
showAriaLabel?: boolean;
|
showAriaLabel?: boolean;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
isLoading?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolButtonProps =
|
type ToolButtonProps =
|
||||||
| (ToolButtonBaseProps & {
|
| (ToolButtonBaseProps & {
|
||||||
type: "button";
|
type: "button";
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onClick?(event: React.MouseEvent): void;
|
onClick?(): void;
|
||||||
})
|
|
||||||
| (ToolButtonBaseProps & {
|
|
||||||
type: "submit";
|
|
||||||
children?: React.ReactNode;
|
|
||||||
onClick?(event: React.MouseEvent): void;
|
|
||||||
})
|
})
|
||||||
| (ToolButtonBaseProps & {
|
| (ToolButtonBaseProps & {
|
||||||
type: "icon";
|
type: "icon";
|
||||||
@@ -47,57 +38,18 @@ type ToolButtonProps =
|
|||||||
| (ToolButtonBaseProps & {
|
| (ToolButtonBaseProps & {
|
||||||
type: "radio";
|
type: "radio";
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange?(data: { pointerType: PointerType | null }): void;
|
onChange?(): void;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DEFAULT_SIZE: ToolIconSize = "m";
|
||||||
|
|
||||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||||
const { id: excalId } = useExcalidrawContainer();
|
const { id: excalId } = useExcalidrawContainer();
|
||||||
const innerRef = React.useRef(null);
|
const innerRef = React.useRef(null);
|
||||||
React.useImperativeHandle(ref, () => innerRef.current);
|
React.useImperativeHandle(ref, () => innerRef.current);
|
||||||
const sizeCn = `ToolIcon_size_${props.size}`;
|
const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
if (props.type === "button" || props.type === "icon") {
|
||||||
|
|
||||||
const isMountedRef = useRef(true);
|
|
||||||
|
|
||||||
const onClick = async (event: React.MouseEvent) => {
|
|
||||||
const ret = "onClick" in props && props.onClick?.(event);
|
|
||||||
|
|
||||||
if (ret && "then" in ret) {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
await ret;
|
|
||||||
} catch (error: any) {
|
|
||||||
if (!(error instanceof AbortError)) {
|
|
||||||
throw error;
|
|
||||||
} else {
|
|
||||||
console.warn(error);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const lastPointerTypeRef = useRef<PointerType | null>(null);
|
|
||||||
|
|
||||||
if (
|
|
||||||
props.type === "button" ||
|
|
||||||
props.type === "icon" ||
|
|
||||||
props.type === "submit"
|
|
||||||
) {
|
|
||||||
const type = (props.type === "icon" ? "button" : props.type) as
|
|
||||||
| "button"
|
|
||||||
| "submit";
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -117,10 +69,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
hidden={props.hidden}
|
hidden={props.hidden}
|
||||||
title={props.title}
|
title={props.title}
|
||||||
aria-label={props["aria-label"]}
|
aria-label={props["aria-label"]}
|
||||||
type={type}
|
type="button"
|
||||||
onClick={onClick}
|
onClick={props.onClick}
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
disabled={isLoading || props.isLoading}
|
|
||||||
>
|
>
|
||||||
{(props.icon || props.label) && (
|
{(props.icon || props.label) && (
|
||||||
<div className="ToolIcon__icon" aria-hidden="true">
|
<div className="ToolIcon__icon" aria-hidden="true">
|
||||||
@@ -130,13 +81,10 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
{props.keyBindingLabel}
|
{props.keyBindingLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{props.isLoading && <Spinner />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{props.showAriaLabel && (
|
{props.showAriaLabel && (
|
||||||
<div className="ToolIcon__label">
|
<div className="ToolIcon__label">{props["aria-label"]}</div>
|
||||||
{props["aria-label"]} {isLoading && <Spinner />}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{props.children}
|
{props.children}
|
||||||
</button>
|
</button>
|
||||||
@@ -144,18 +92,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label className={clsx("ToolIcon", props.className)} title={props.title}>
|
||||||
className={clsx("ToolIcon", props.className)}
|
|
||||||
title={props.title}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
lastPointerTypeRef.current = event.pointerType || null;
|
|
||||||
}}
|
|
||||||
onPointerUp={() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
lastPointerTypeRef.current = null;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
className={`ToolIcon_type_radio ${sizeCn}`}
|
className={`ToolIcon_type_radio ${sizeCn}`}
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -164,9 +101,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
||||||
data-testid={props["data-testid"]}
|
data-testid={props["data-testid"]}
|
||||||
id={`${excalId}-${props.id}`}
|
id={`${excalId}-${props.id}`}
|
||||||
onChange={() => {
|
onChange={props.onChange}
|
||||||
props.onChange?.({ pointerType: lastPointerTypeRef.current });
|
|
||||||
}}
|
|
||||||
checked={props.checked}
|
checked={props.checked}
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
/>
|
/>
|
||||||
@@ -183,5 +118,4 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
ToolButton.defaultProps = {
|
ToolButton.defaultProps = {
|
||||||
visible: true,
|
visible: true,
|
||||||
className: "",
|
className: "",
|
||||||
size: "medium",
|
|
||||||
};
|
};
|
||||||
|
@@ -6,22 +6,12 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
font-family: Cascadia;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
border-radius: var(--space-factor);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon--plain {
|
|
||||||
background-color: transparent;
|
|
||||||
.ToolIcon__icon {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon_type_radio,
|
|
||||||
.ToolIcon_type_checkbox {
|
|
||||||
& + .ToolIcon__icon {
|
|
||||||
background-color: var(--button-gray-1);
|
background-color: var(--button-gray-1);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -31,6 +21,13 @@
|
|||||||
background-color: var(--button-gray-3);
|
background-color: var(--button-gray-3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ToolIcon--plain {
|
||||||
|
background-color: transparent;
|
||||||
|
.ToolIcon__icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
@@ -42,11 +39,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--space-factor);
|
||||||
|
|
||||||
& + .ToolIcon__label {
|
|
||||||
margin-inline-start: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -54,24 +47,22 @@
|
|||||||
fill: var(--icon-fill-color);
|
fill: var(--icon-fill-color);
|
||||||
color: var(--icon-fill-color);
|
color: var(--icon-fill-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& + .ToolIcon__label {
|
||||||
|
margin-inline-start: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon__label {
|
.ToolIcon__label {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--icon-fill-color);
|
color: var(--icon-fill-color);
|
||||||
font-family: var(--ui-font);
|
font-family: var(--ui-font);
|
||||||
margin: 0 0.8em;
|
margin: 0 0.8em;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
.Spinner {
|
|
||||||
margin-left: 0.6em;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon_size_small .ToolIcon__icon {
|
.ToolIcon_size_s .ToolIcon__icon {
|
||||||
width: 2rem;
|
width: 1.4rem;
|
||||||
height: 2rem;
|
height: 1.4rem;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +74,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus {
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +116,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible + .ToolIcon__icon {
|
&:focus + .ToolIcon__icon {
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +136,10 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
background-color: var(--button-gray-1);
|
background-color: var(--button-gray-1);
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -159,6 +154,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ToolIcon.ToolIcon__lock {
|
||||||
|
margin-inline-end: var(--space-factor);
|
||||||
|
&.ToolIcon_type_floating {
|
||||||
|
margin-left: 0.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ToolIcon__keybinding {
|
.ToolIcon__keybinding {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 2px;
|
bottom: 2px;
|
||||||
@@ -219,10 +221,6 @@
|
|||||||
margin-inline-end: 0;
|
margin-inline-end: 0;
|
||||||
top: 60px;
|
top: 60px;
|
||||||
}
|
}
|
||||||
.ToolIcon.ToolIcon__penMode {
|
|
||||||
margin-inline-end: 0;
|
|
||||||
top: 140px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.unlocked-icon {
|
.unlocked-icon {
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user