Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cb67a0bc9 |
@@ -2,9 +2,6 @@
|
|||||||
!public/
|
!public/
|
||||||
!src/
|
!src/
|
||||||
!.npmrc
|
!.npmrc
|
||||||
!.eslintrc.json
|
|
||||||
!.prettierrc
|
|
||||||
!package-lock.json
|
!package-lock.json
|
||||||
!package.json
|
!package.json
|
||||||
!tsconfig.json
|
!tsconfig.json
|
||||||
!.env
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
|
|
||||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
|
||||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
|
||||||
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
|
|
||||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
REACT_APP_INCLUDE_GTAG=true
|
|
||||||
@@ -3,4 +3,3 @@ build/
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
.vscode/
|
.vscode/
|
||||||
firebase/
|
firebase/
|
||||||
dist/
|
|
||||||
|
|||||||
+8
-18
@@ -3,8 +3,6 @@
|
|||||||
"plugins": ["prettier"],
|
"plugins": ["prettier"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"curly": "warn",
|
"curly": "warn",
|
||||||
"dot-notation": "warn",
|
|
||||||
"import/no-anonymous-default-export": "off",
|
|
||||||
"no-console": [
|
"no-console": [
|
||||||
"warn",
|
"warn",
|
||||||
{
|
{
|
||||||
@@ -12,22 +10,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"no-else-return": "warn",
|
"no-else-return": "warn",
|
||||||
"no-lonely-if": "warn",
|
|
||||||
"no-restricted-syntax": [
|
|
||||||
"warn",
|
|
||||||
{
|
|
||||||
"message": "Use 't(...)' instead of literal text in JSX",
|
|
||||||
"selector": "JSXText[value=/\\w/]"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"no-unneeded-ternary": "warn",
|
|
||||||
"no-unused-expressions": "warn",
|
|
||||||
"no-unused-vars": "warn",
|
|
||||||
"no-useless-return": "warn",
|
"no-useless-return": "warn",
|
||||||
"no-var": "warn",
|
|
||||||
"object-shorthand": "warn",
|
|
||||||
"one-var": ["warn", "never"],
|
|
||||||
"prefer-arrow-callback": "warn",
|
|
||||||
"prefer-const": [
|
"prefer-const": [
|
||||||
"warn",
|
"warn",
|
||||||
{
|
{
|
||||||
@@ -35,6 +18,13 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"prefer-template": "warn",
|
"prefer-template": "warn",
|
||||||
"prettier/prettier": "warn"
|
"prettier/prettier": "warn",
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"selector": "JSXText[value=/\\w/]",
|
||||||
|
"message": "Use 't(...)' instead of literal text in JSX"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
open-pull-requests-limit: 99
|
|
||||||
reviewers:
|
|
||||||
- lipis
|
|
||||||
assignees:
|
|
||||||
- lipis
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: "/src/packages/excalidraw/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
open-pull-requests-limit: 99
|
|
||||||
reviewers:
|
|
||||||
- ad1992
|
|
||||||
assignees:
|
|
||||||
- ad1992
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: "/src/packages/utils/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
open-pull-requests-limit: 99
|
|
||||||
reviewers:
|
|
||||||
- ad1992
|
|
||||||
assignees:
|
|
||||||
- ad1992
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
name: Build Docker image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v1
|
|
||||||
- run: docker build -t excalidraw .
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
name: Build packages
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v1
|
|
||||||
|
|
||||||
- name: Setup Node.js 12.x
|
|
||||||
uses: actions/setup-node@v1
|
|
||||||
with:
|
|
||||||
node-version: 12.x
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
npm ci --prefix src/packages/excalidraw
|
|
||||||
npm ci --prefix src/packages/utils
|
|
||||||
|
|
||||||
- name: Build @excalidraw/excalidraw
|
|
||||||
run: |
|
|
||||||
npm run pack --prefix src/packages/excalidraw
|
|
||||||
|
|
||||||
- name: Build @excalidraw/utils
|
|
||||||
run: |
|
|
||||||
npm run pack --prefix src/packages/utils
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
name: Build locales coverage
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- "l10n_master"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
locales:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
|
||||||
|
|
||||||
- name: Setup Node.js 12.x
|
|
||||||
uses: actions/setup-node@v1
|
|
||||||
with:
|
|
||||||
node-version: 12.x
|
|
||||||
|
|
||||||
- name: Create report file
|
|
||||||
run: |
|
|
||||||
npm run locales-coverage
|
|
||||||
FILE_CHANGED=$(git diff src/locales/percentages.json)
|
|
||||||
if [ ! -z "${FILE_CHANGED}" ]; then
|
|
||||||
git config --global user.name 'Kostas Bariotis'
|
|
||||||
git config --global user.email 'konmpar@gmail.com'
|
|
||||||
git add src/locales/percentages.json
|
|
||||||
git commit -am "Auto commit: Calculate translation coverage"
|
|
||||||
git push
|
|
||||||
fi
|
|
||||||
- name: Construct comment body
|
|
||||||
id: getCommentBody
|
|
||||||
run: |
|
|
||||||
body=$(npm run locales-coverage:description | grep '^[^>]')
|
|
||||||
body="${body//'%'/'%25'}"
|
|
||||||
body="${body//$'\n'/'%0A'}"
|
|
||||||
body="${body//$'\r'/'%0D'}"
|
|
||||||
echo ::set-output name=body::$body
|
|
||||||
|
|
||||||
- name: Update description with coverage
|
|
||||||
uses: kt3k/update-pr-description@v1.0.1
|
|
||||||
with:
|
|
||||||
pr_body: ${{ steps.getCommentBody.outputs.body }}
|
|
||||||
pr_title: "chore: New Crowdin updates"
|
|
||||||
github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
name: Publish Docker
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish-docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- uses: docker/build-push-action@v1
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
repository: excalidraw/excalidraw
|
|
||||||
tag_with_ref: true
|
|
||||||
tag_with_sha: true
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
name: "Semantic PR title"
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- edited
|
|
||||||
- synchronize
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
main:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: amannn/action-semantic-pull-request@v2.1.0
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
+6
-13
@@ -1,18 +1,10 @@
|
|||||||
.DS_Store
|
|
||||||
.env.development.local
|
|
||||||
.env.local
|
|
||||||
.env.production.local
|
|
||||||
.env.test.local
|
|
||||||
.envrc
|
|
||||||
.eslintcache
|
|
||||||
.idea
|
|
||||||
.vercel
|
|
||||||
.vscode
|
|
||||||
*.log
|
*.log
|
||||||
*.tgz
|
.DS_Store
|
||||||
|
.envrc
|
||||||
|
.now
|
||||||
|
.vscode
|
||||||
build
|
build
|
||||||
dist
|
firebase/
|
||||||
firebase
|
|
||||||
logs
|
logs
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -20,3 +12,4 @@ static
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
.idea
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
const { CLIEngine } = require("eslint");
|
const { CLIEngine } = require("eslint");
|
||||||
|
|
||||||
// see https://github.com/okonet/lint-staged#how-can-i-ignore-files-from-eslintignore-
|
// see https://github.com/okonet/lint-staged#how-can-i-ignore-files-from-eslintignore-
|
||||||
// for explanation
|
// for explanation
|
||||||
const cli = new CLIEngine({});
|
const cli = new CLIEngine({});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
## 2020-10-13
|
|
||||||
|
|
||||||
- Added ability to embed scene source into exported PNG/SVG files so you can import the scene from them (open via `Load` button or drag & drop). #2219
|
|
||||||
+5
-7
@@ -1,18 +1,16 @@
|
|||||||
FROM node:14-alpine AS build
|
FROM node:14-alpine AS build
|
||||||
|
|
||||||
WORKDIR /opt/node_app
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm i --no-optional
|
RUN npm install
|
||||||
|
|
||||||
ARG REACT_APP_INCLUDE_GTAG=false
|
|
||||||
ARG NODE_ENV=production
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build:app:docker
|
RUN npm run build:app
|
||||||
|
|
||||||
FROM nginx:1.17-alpine
|
FROM nginx:1.17-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
COPY --from=build /usr/src/app/build /usr/share/nginx/html
|
||||||
|
|
||||||
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1
|
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1
|
||||||
|
|||||||
@@ -2,30 +2,23 @@
|
|||||||
<a href="https://excalidraw.com">
|
<a href="https://excalidraw.com">
|
||||||
<img src="./public/og-image.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
<img src="./public/og-image.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
||||||
</a>
|
</a>
|
||||||
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.</h3>
|
<h3>Excalidraw is a whiteboard tool that lets you easily sketch diagrams with a hand-drawn feel.</h3>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://twitter.com/Excalidraw">
|
<a href="https://twitter.com/Excalidraw">
|
||||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
|
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
|
||||||
</a>
|
</a>
|
||||||
<a target="_blank" href="https://crowdin.com/project/excalidraw">
|
<a title="Crowdin" target="_blank" href="https://crowdin.com/project/excalidraw">
|
||||||
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
||||||
</a>
|
</a>
|
||||||
<a target="_blank" href="https://hub.docker.com/r/excalidraw/excalidraw">
|
|
||||||
<img src="https://img.shields.io/docker/pulls/excalidraw/excalidraw">
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Try it now
|
## Try it now
|
||||||
|
|
||||||
Go to [excalidraw.com](https://excalidraw.com) to start sketching.
|
Go to https://excalidraw.com to start sketching.
|
||||||
|
|
||||||
Read our [blog](https://blog.excalidraw.com) and follow the [guides](https://howto.excalidraw.com) to learn more about Excalidraw and how to use it effectively.
|
Read our [blog](https://blog.excalidraw.com) and follow the [guides](https://howto.excalidraw.com) to learn more about Excalidraw and how to use it effectively.
|
||||||
|
|
||||||
## Shape libraries
|
|
||||||
|
|
||||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
|
||||||
|
|
||||||
## Run the code
|
## Run the code
|
||||||
|
|
||||||
### Code Sandbox
|
### Code Sandbox
|
||||||
@@ -55,28 +48,24 @@ git clone https://github.com/excalidraw/excalidraw.git
|
|||||||
| `npm run test:update` | Update test snapshots |
|
| `npm run test:update` | Update test snapshots |
|
||||||
| `npm run test:code` | Test for formatting with Prettier |
|
| `npm run test:code` | Test for formatting with Prettier |
|
||||||
|
|
||||||
#### Docker Compose
|
### Docker Installation
|
||||||
|
|
||||||
You can use docker-compose to work on excalidraw locally if you don't want to setup a Node.js env.
|
A production-ready version for deploying to e.g. Kubernetes or OpenShift can be built using Docker.
|
||||||
|
|
||||||
|
#### Docker Compose
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker-compose up --build -d
|
docker-compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## Self hosting
|
#### Native Docker
|
||||||
|
|
||||||
We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self host your own client under your own domain, on Kubernetes, AWS ECS, etc.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t excalidraw/excalidraw .
|
docker build -t excalidraw/excalidraw .
|
||||||
docker run --rm -dit --name excalidraw -p 5000:80 excalidraw/excalidraw:latest
|
docker run --rm -dit --name excalidraw -p 5000:80 excalidraw/excalidraw:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
The Docker image is free of analytics and other tracking libraries.
|
After building the image and running the container, open <http://localhost:5000> to see the application.
|
||||||
|
|
||||||
**At the moment, self-hosting your own instance doesn't support sharing or collaboration features.**
|
|
||||||
|
|
||||||
We are working towards providing a full-fledged solution for self hosting your own Excalidraw.
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -86,13 +75,11 @@ Pull requests are welcome. For major changes, please [open an issue](https://git
|
|||||||
|
|
||||||
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
|
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
|
||||||
|
|
||||||
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
|
|
||||||
|
|
||||||
## Excalidraw is built using these awesome tools
|
## Excalidraw is built using these awesome tools
|
||||||
|
|
||||||
- [React](https://reactjs.org)
|
- [React](https://reactjs.org)
|
||||||
- [Rough.js](https://roughjs.com)
|
- [Rough.js](https://roughjs.com)
|
||||||
- [TypeScript](https://www.typescriptlang.org)
|
- [TypeScript](https://typescriptlang.org)
|
||||||
- [Vercel](https://vercel.com)
|
- [Vercel](https://vercel.com)
|
||||||
|
|
||||||
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
|
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
| Excalidraw | Category | Name | Label | Value |
|
|
||||||
| ----------------------- | -------- | ---------------------------------- | ------------------------------- | --------- |
|
|
||||||
| Shape / Selection | shape | selection, rectangle, diamond, etc | `toolbar` or `shortcut` |
|
|
||||||
| Text on double click | shape | text | `double-click` |
|
|
||||||
| Lock selection | shape | lock | `on` or `off` |
|
|
||||||
| Clear canvas | action | clear canvas |
|
|
||||||
| Zoom in | action | zoom | in | `zoom` |
|
|
||||||
| Zoom out | action | zoom | out | `zoom` |
|
|
||||||
| Zoom fit | action | zoom | fit | `zoom` |
|
|
||||||
| Zoom reset | action | zoom | reset | `zoom` |
|
|
||||||
| Scroll back to content | action | scroll to content |
|
|
||||||
| Load file | io | load | `MIME type` |
|
|
||||||
| Import from URL | io | import |
|
|
||||||
| Save | io | save |
|
|
||||||
| Save as | io | save as |
|
|
||||||
| Export to backend | io | export | backend |
|
|
||||||
| Export as SVG | io | export | `svg` or `clipboard-svg` |
|
|
||||||
| Export to PNG | io | export | `png` or `clipboard-png` |
|
|
||||||
| Canvas color | change | canvas color | `color` |
|
|
||||||
| Background color | change | background color | `color` |
|
|
||||||
| Stroke color | change | stroke color | `color` |
|
|
||||||
| Stroke width | change | stroke | width | `width` |
|
|
||||||
| Stroke style | change | style | `solid` or `dashed` or `dotted` |
|
|
||||||
| Stroke sloppiness | change | stroke | sloppiness | `value` |
|
|
||||||
| Fill | change | fill | `value` |
|
|
||||||
| Edge | change | edge | `value` |
|
|
||||||
| Opacity | change | opacity | value | `opacity` |
|
|
||||||
| Project name | change | title |
|
|
||||||
| Theme | change | theme | `light` or `dark` |
|
|
||||||
| Change language | change | language | `language` |
|
|
||||||
| Send to back | layer | move | `back` |
|
|
||||||
| Send backward | layer | move | `down` |
|
|
||||||
| Bring to front | layer | move | `front` |
|
|
||||||
| Bring forward | layer | move | `up` |
|
|
||||||
| Align left | align | align | `left` |
|
|
||||||
| Align right | align | align | `right` |
|
|
||||||
| Align top | align | align | `top` |
|
|
||||||
| Align bottom | align | align | `bottom` |
|
|
||||||
| Center horizontally | align | horizontally | `center` |
|
|
||||||
| Center vertically | align | vertically | `center` |
|
|
||||||
| Distribute horizontally | align | distribute | `horizontally` |
|
|
||||||
| Distribute vertically | align | distribute | `vertically` |
|
|
||||||
| Start session | share | session start |
|
|
||||||
| Join session | share | session join |
|
|
||||||
| Start end | share | session end |
|
|
||||||
| Copy room link | share | copy link |
|
|
||||||
| Go to collaborator | share | go to collaborator |
|
|
||||||
| Change name | share | name |
|
|
||||||
| Add to library | library | add |
|
|
||||||
| Remove from library | library | remove |
|
|
||||||
| Load library | library | load |
|
|
||||||
| Save library | library | save |
|
|
||||||
| Import library | library | import |
|
|
||||||
| Shortcuts dialog | dialog | shortcuts |
|
|
||||||
| Collaboration dialog | dialog | collaboration |
|
|
||||||
| Export dialog | dialog | export |
|
|
||||||
| Library dialog | dialog | library |
|
|
||||||
| E2EE shield | exit | e2ee shield |
|
|
||||||
| GitHub corner | exit | github |
|
|
||||||
| Excalidraw blog | exit | blog |
|
|
||||||
| Excalidraw guides | exit | guides |
|
|
||||||
| File issues | exit | issues |
|
|
||||||
| First load | load | first load |
|
|
||||||
| Load from stroage | load | storage | size | `bytes` |
|
|
||||||
+3
-19
@@ -1,25 +1,9 @@
|
|||||||
version: "3.8"
|
version: "3"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
excalidraw:
|
excalidraw:
|
||||||
build:
|
build: .
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
- NODE_ENV=development
|
|
||||||
container_name: excalidraw
|
container_name: excalidraw
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "5000:80"
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
stdin_open: true
|
|
||||||
healthcheck:
|
|
||||||
disable: true
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=development
|
|
||||||
volumes:
|
|
||||||
- ./:/opt/node_app/app:delegated
|
|
||||||
- ./package.json:/opt/node_app/package.json
|
|
||||||
- ./package-lock.json:/opt/node_app/package-lock.json
|
|
||||||
- notused:/opt/node_app/app/node_modules
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
notused:
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"projects": {
|
|
||||||
"default": "excalidraw-room-persistence"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
firebase-debug.log*
|
|
||||||
firebase-debug.*.log*
|
|
||||||
|
|
||||||
# Firebase cache
|
|
||||||
.firebase/
|
|
||||||
|
|
||||||
# Firebase config
|
|
||||||
|
|
||||||
# Uncomment this if you'd like others to create their own Firebase project.
|
|
||||||
# For a team working on the same Firebase project(s), it is recommended to leave
|
|
||||||
# it commented so all members can deploy to the same project(s) in .firebaserc.
|
|
||||||
# .firebaserc
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variables file
|
|
||||||
.env
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"firestore": {
|
|
||||||
"rules": "firestore.rules",
|
|
||||||
"indexes": "firestore.indexes.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"indexes": [],
|
|
||||||
"fieldOverrides": []
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
rules_version = '2';
|
|
||||||
service cloud.firestore {
|
|
||||||
match /databases/{database}/documents {
|
|
||||||
match /{document=**} {
|
|
||||||
allow get, write: if true;
|
|
||||||
// never set this to true, otherwise anyone can delete anyone else's drawing.
|
|
||||||
allow list: if false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+5955
-12048
File diff suppressed because it is too large
Load Diff
+34
-45
@@ -19,47 +19,41 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/browser": "5.29.2",
|
"@sentry/browser": "5.15.5",
|
||||||
"@sentry/integrations": "5.29.2",
|
"@sentry/integrations": "5.15.5",
|
||||||
"@testing-library/jest-dom": "5.11.8",
|
"@testing-library/jest-dom": "5.7.0",
|
||||||
"@testing-library/react": "11.2.2",
|
"@testing-library/react": "10.0.4",
|
||||||
"@types/jest": "26.0.19",
|
"@types/jest": "25.2.1",
|
||||||
"@types/nanoid": "2.1.0",
|
"@types/nanoid": "2.1.0",
|
||||||
"@types/react": "17.0.0",
|
"@types/react": "16.9.35",
|
||||||
"@types/react-dom": "17.0.0",
|
"@types/react-dom": "16.9.8",
|
||||||
"@types/socket.io-client": "1.4.34",
|
"@types/socket.io-client": "1.4.32",
|
||||||
"browser-nativefs": "0.12.0",
|
"browser-nativefs": "0.7.3",
|
||||||
"clsx": "1.1.1",
|
"i18next-browser-languagedetector": "4.1.1",
|
||||||
"firebase": "8.2.1",
|
|
||||||
"i18next-browser-languagedetector": "6.0.1",
|
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "2.1.11",
|
"nanoid": "2.1.11",
|
||||||
"node-sass": "4.14.1",
|
"node-sass": "4.14.1",
|
||||||
"open-color": "1.7.0",
|
"open-color": "1.7.0",
|
||||||
"pako": "1.0.11",
|
|
||||||
"png-chunk-text": "1.0.0",
|
|
||||||
"png-chunks-encode": "1.0.0",
|
|
||||||
"png-chunks-extract": "1.0.0",
|
|
||||||
"points-on-curve": "0.2.0",
|
"points-on-curve": "0.2.0",
|
||||||
"pwacompat": "2.0.17",
|
"pwacompat": "2.0.12",
|
||||||
"react": "17.0.1",
|
"react": "16.13.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "16.13.1",
|
||||||
"react-scripts": "4.0.1",
|
"react-scripts": "3.4.1",
|
||||||
"roughjs": "4.3.1",
|
"roughjs": "4.3.1",
|
||||||
"socket.io-client": "2.3.1",
|
"socket.io-client": "2.3.0",
|
||||||
"typescript": "4.1.3"
|
"typescript": "3.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.6",
|
||||||
"@types/pako": "1.0.1",
|
"asar": "3.0.3",
|
||||||
"eslint-config-prettier": "7.1.0",
|
"eslint": "6.8.0",
|
||||||
"eslint-plugin-prettier": "3.3.0",
|
"eslint-config-prettier": "6.11.0",
|
||||||
"firebase-tools": "9.1.0",
|
"eslint-plugin-prettier": "3.1.3",
|
||||||
"husky": "4.3.6",
|
"husky": "4.2.5",
|
||||||
"jest-canvas-mock": "2.3.0",
|
"jest-canvas-mock": "2.2.0",
|
||||||
"lint-staged": "10.5.3",
|
"lint-staged": "10.2.2",
|
||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.2",
|
||||||
"prettier": "2.2.1",
|
"prettier": "2.0.5",
|
||||||
"rewire": "5.0.0"
|
"rewire": "5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -74,32 +68,27 @@
|
|||||||
"jest": {
|
"jest": {
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
|
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
|
||||||
],
|
]
|
||||||
"resetMocks": false
|
|
||||||
},
|
},
|
||||||
"name": "excalidraw",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "npm run build:app && npm run build:zip",
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "REACT_APP_INCLUDE_GTAG=false REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
"build:app": "REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
|
||||||
"build:app": "REACT_APP_INCLUDE_GTAG=true REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
|
"build:zip": "node ./scripts/build-version.js",
|
||||||
"build:version": "node ./scripts/build-version.js",
|
|
||||||
"build": "npm run build:app && npm run build:version",
|
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
|
"fix": "npm run fix:other && npm run fix:code",
|
||||||
"fix:code": "npm run test:code -- --fix",
|
"fix:code": "npm run test:code -- --fix",
|
||||||
"fix:other": "npm run prettier -- --write",
|
"fix:other": "npm run prettier -- --write",
|
||||||
"fix": "npm run fix:other && npm run fix:code",
|
|
||||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
|
||||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
|
||||||
"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": "npm run test:app",
|
||||||
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
|
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
|
||||||
"test:app": "react-scripts test --passWithNoTests",
|
"test:update": "npm run test:app -- --updateSnapshot --watchAll=false",
|
||||||
|
"test:app": "react-scripts test --env=jsdom --passWithNoTests",
|
||||||
"test:code": "eslint --max-warnings=0 --ignore-path .gitignore --ext .js,.ts,.tsx .",
|
"test:code": "eslint --max-warnings=0 --ignore-path .gitignore --ext .js,.ts,.tsx .",
|
||||||
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
|
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
|
||||||
"test:other": "npm run prettier -- --list-different",
|
"test:other": "npm run prettier -- --list-different",
|
||||||
"test:typecheck": "tsc",
|
"test:typecheck": "tsc"
|
||||||
"test:update": "npm run test:app -- --updateSnapshot --watchAll=false",
|
|
||||||
"test": "npm run test:app"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-55
@@ -13,6 +13,18 @@
|
|||||||
|
|
||||||
<meta name="theme-color" content="#000" />
|
<meta name="theme-color" content="#000" />
|
||||||
|
|
||||||
|
<!-- Origin Trial token for the Native File System API v1 https://developers.chrome.com/origintrials/#/view_trial/3868592079911256065 (Chrome 78–81) -->
|
||||||
|
<meta
|
||||||
|
http-equiv="origin-trial"
|
||||||
|
content="AoGjY+6r8OQZ5c0AXpK+bbca0pJdCTSHWFqSFNulxiW4OwFBB63kHdDHNo433GeuEOir8IvSovR0LOZLfPnEDAUAAABceyJvcmlnaW4iOiJodHRwczovL3d3dy5leGNhbGlkcmF3LmNvbTo0NDMiLCJmZWF0dXJlIjoiTmF0aXZlRmlsZVN5c3RlbSIsImV4cGlyeSI6MTU4OTMyNzk5OX0="
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Origin Trial token for the Native File System API v2 https://developers.chrome.com/origintrials/#/view_trial/4019462667428167681 (Chrome 83–85) -->
|
||||||
|
<meta
|
||||||
|
http-equiv="origin-trial"
|
||||||
|
content="AgMee3sqSZkE0QaZP8f/F9OJj5iSLdnNMRGttIDlOQy552MI4GoL41jyCAHOYsQ8UWM1kPdrb6PVmbSllX/JqwEAAABZeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJOYXRpdmVGaWxlU3lzdGVtMiIsImV4cGlyeSI6MTU5MDU3MzM5MX0="
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- General tags -->
|
<!-- General tags -->
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
@@ -54,9 +66,7 @@
|
|||||||
<!-- OG tags require absolute url for images -->
|
<!-- OG tags require absolute url for images -->
|
||||||
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
|
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
|
||||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||||
|
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||||
<!-- Excalidraw version -->
|
|
||||||
<meta name="version" content="{version}" />
|
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
href="FG_Virgil.woff2"
|
href="FG_Virgil.woff2"
|
||||||
@@ -73,7 +83,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<link
|
<link
|
||||||
href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
|
href="https://excalidraw-socket.herokuapp.com/socket.io"
|
||||||
rel="preconnect"
|
rel="preconnect"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
/>
|
/>
|
||||||
@@ -81,12 +91,38 @@
|
|||||||
<link
|
<link
|
||||||
rel="manifest"
|
rel="manifest"
|
||||||
href="manifest.json"
|
href="manifest.json"
|
||||||
style="--pwacompat-splash-font: 24px Virgil"
|
style="--pwacompat-splash-font: 24px Virgil;"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
<style>
|
||||||
|
.LoadingMessage {
|
||||||
<% if (process.env.REACT_APP_INCLUDE_GTAG === 'true') { %>
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.LoadingMessage span {
|
||||||
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.8em 1.2em;
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute !important;
|
||||||
|
height: 1px;
|
||||||
|
width: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
||||||
|
clip: rect(1px, 1px, 1px, 1px);
|
||||||
|
white-space: nowrap; /* added line */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<script
|
<script
|
||||||
async
|
async
|
||||||
src="https://www.googletagmanager.com/gtag/js?id=UA-387204-13"
|
src="https://www.googletagmanager.com/gtag/js?id=UA-387204-13"
|
||||||
@@ -99,56 +135,12 @@
|
|||||||
gtag("js", new Date());
|
gtag("js", new Date());
|
||||||
gtag("config", "UA-387204-13");
|
gtag("config", "UA-387204-13");
|
||||||
</script>
|
</script>
|
||||||
<% } %>
|
|
||||||
|
|
||||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
|
||||||
Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
font-family: var(--ui-font);
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visually-hidden {
|
|
||||||
position: absolute !important;
|
|
||||||
height: 1px;
|
|
||||||
width: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(1px, 1px, 1px, 1px);
|
|
||||||
white-space: nowrap; /* added line */
|
|
||||||
}
|
|
||||||
|
|
||||||
.LoadingMessage {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 999;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.LoadingMessage span {
|
|
||||||
background-color: var(--button-gray-1);
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 0.8em 1.2em;
|
|
||||||
color: var(--popup-text-color);
|
|
||||||
font-size: 1.3em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
<noscript>
|
||||||
|
You need to enable JavaScript to run this app.
|
||||||
|
</noscript>
|
||||||
<header>
|
<header>
|
||||||
<h1 class="visually-hidden">Excalidraw</h1>
|
<h1 class="visually-hidden">Excalidraw</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+2
-11
@@ -14,17 +14,8 @@
|
|||||||
"sizes": "256x256"
|
"sizes": "256x256"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": "/",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"theme_color": "#000000",
|
"theme_color": "#000000",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff"
|
||||||
"file_handlers": [
|
|
||||||
{
|
|
||||||
"action": "/",
|
|
||||||
"accept": {
|
|
||||||
"application/vnd.excalidraw+json": [".excalidraw"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"capture_links": "new_client"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
user-agent: *
|
|
||||||
Allow: /$
|
|
||||||
Disallow: /
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
const { readdirSync, writeFileSync } = require("fs");
|
|
||||||
const files = readdirSync(`${__dirname}/../src/locales`);
|
|
||||||
|
|
||||||
const flatten = (object) =>
|
|
||||||
Object.keys(object).reduce(
|
|
||||||
(initial, current) => ({ ...initial, ...object[current] }),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
const locales = files.filter(
|
|
||||||
(file) => file !== "README.md" && file !== "percentages.json",
|
|
||||||
);
|
|
||||||
|
|
||||||
const percentages = {};
|
|
||||||
|
|
||||||
for (let index = 0; index < locales.length; index++) {
|
|
||||||
const currentLocale = locales[index];
|
|
||||||
const data = flatten(require(`${__dirname}/../src/locales/${currentLocale}`));
|
|
||||||
|
|
||||||
const allKeys = Object.keys(data);
|
|
||||||
const translatedKeys = allKeys.filter((item) => data[item] !== "");
|
|
||||||
|
|
||||||
const percentage = (100 * translatedKeys.length) / allKeys.length;
|
|
||||||
|
|
||||||
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(
|
|
||||||
`${__dirname}/../src/locales/percentages.json`,
|
|
||||||
`${JSON.stringify(percentages, null, 2)}\n`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
// node build/static/js/build-node.js
|
// node build/static/js/build-node.js
|
||||||
// open test.png
|
// open test.png
|
||||||
|
|
||||||
const rewire = require("rewire");
|
var rewire = require("rewire");
|
||||||
const defaults = rewire("react-scripts/scripts/build.js");
|
var defaults = rewire("react-scripts/scripts/build.js");
|
||||||
const config = defaults.__get__("config");
|
var config = defaults.__get__("config");
|
||||||
|
|
||||||
// Disable multiple chunks
|
// Disable multiple chunks
|
||||||
config.optimization.runtimeChunk = false;
|
config.optimization.runtimeChunk = false;
|
||||||
@@ -29,7 +29,7 @@ config.entry = "./src/index-node";
|
|||||||
// By default, webpack is going to replace the require of the canvas.node file
|
// By default, webpack is going to replace the require of the canvas.node file
|
||||||
// to just a string with the path of the canvas.node file. We need to tell
|
// to just a string with the path of the canvas.node file. We need to tell
|
||||||
// webpack to avoid rewriting that dependency.
|
// webpack to avoid rewriting that dependency.
|
||||||
config.externals = (context, request, callback) => {
|
config.externals = function (context, request, callback) {
|
||||||
if (/\.node$/.test(request)) {
|
if (/\.node$/.test(request)) {
|
||||||
return callback(
|
return callback(
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const versionFile = path.join("build", "version.json");
|
const asar = require("asar");
|
||||||
const indexFile = path.join("build", "index.html");
|
|
||||||
|
|
||||||
const zero = (digit) => `0${digit}`.slice(-2);
|
const zero = (digit) => `0${digit}`.slice(-2);
|
||||||
|
|
||||||
@@ -21,24 +20,18 @@ const now = new Date();
|
|||||||
|
|
||||||
const data = JSON.stringify(
|
const data = JSON.stringify(
|
||||||
{
|
{
|
||||||
|
asar: `excalidraw.asar`,
|
||||||
version: versionDate(now),
|
version: versionDate(now),
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
|
|
||||||
fs.writeFileSync(versionFile, data);
|
fs.writeFileSync(path.join("build", "version.json"), data);
|
||||||
|
|
||||||
// https://stackoverflow.com/a/14181136/8418
|
(async () => {
|
||||||
fs.readFile(indexFile, "utf8", (error, data) => {
|
const src = "build/";
|
||||||
if (error) {
|
const dest = path.join("build", `excalidraw.asar`);
|
||||||
return console.error(error);
|
|
||||||
}
|
|
||||||
const result = data.replace(/{version}/g, versionDate(now));
|
|
||||||
|
|
||||||
fs.writeFile(indexFile, result, "utf8", (error) => {
|
await asar.createPackage(src, dest);
|
||||||
if (error) {
|
})();
|
||||||
return console.error(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
const THRESSHOLD = 85;
|
|
||||||
|
|
||||||
const crowdinMap = {
|
|
||||||
"ar-SA": "en-ar",
|
|
||||||
"el-GR": "en-el",
|
|
||||||
"fi-FI": "en-fi",
|
|
||||||
"ja-JP": "en-ja",
|
|
||||||
"bg-BG": "en-bg",
|
|
||||||
"ca-ES": "en-ca",
|
|
||||||
"de-DE": "en-de",
|
|
||||||
"es-ES": "en-es",
|
|
||||||
"fa-IR": "en-fa",
|
|
||||||
"fr-FR": "en-fr",
|
|
||||||
"he-IL": "en-he",
|
|
||||||
"hi-IN": "en-hi",
|
|
||||||
"hu-HU": "en-hu",
|
|
||||||
"id-ID": "en-id",
|
|
||||||
"it-IT": "en-it",
|
|
||||||
"ko-KR": "en-ko",
|
|
||||||
"my-MM": "en-my",
|
|
||||||
"nb-NO": "en-nb",
|
|
||||||
"nl-NL": "en-nl",
|
|
||||||
"nn-NO": "en-nnno",
|
|
||||||
"pl-PL": "en-pl",
|
|
||||||
"pt-BR": "en-ptbr",
|
|
||||||
"pt-PT": "en-pt",
|
|
||||||
"ro-RO": "en-ro",
|
|
||||||
"ru-RU": "en-ru",
|
|
||||||
"sk-SK": "en-sk",
|
|
||||||
"sv-SE": "en-sv",
|
|
||||||
"tr-TR": "en-tr",
|
|
||||||
"uk-UA": "en-uk",
|
|
||||||
"zh-CN": "en-zhcn",
|
|
||||||
"zh-TW": "en-zhtw",
|
|
||||||
};
|
|
||||||
|
|
||||||
const flags = {
|
|
||||||
"ar-SA": "🇸🇦",
|
|
||||||
"bg-BG": "🇧🇬",
|
|
||||||
"ca-ES": "🇪🇸",
|
|
||||||
"de-DE": "🇩🇪",
|
|
||||||
"el-GR": "🇬🇷",
|
|
||||||
"es-ES": "🇪🇸",
|
|
||||||
"fa-IR": "🇮🇷",
|
|
||||||
"fi-FI": "🇫🇮",
|
|
||||||
"fr-FR": "🇫🇷",
|
|
||||||
"he-IL": "🇮🇱",
|
|
||||||
"hi-IN": "🇮🇳",
|
|
||||||
"hu-HU": "🇭🇺",
|
|
||||||
"id-ID": "🇮🇩",
|
|
||||||
"it-IT": "🇮🇹",
|
|
||||||
"ja-JP": "🇯🇵",
|
|
||||||
"ko-KR": "🇰🇷",
|
|
||||||
"my-MM": "🇲🇲",
|
|
||||||
"nb-NO": "🇳🇴",
|
|
||||||
"nl-NL": "🇳🇱",
|
|
||||||
"nn-NO": "🇳🇴",
|
|
||||||
"pl-PL": "🇵🇱",
|
|
||||||
"pt-BR": "🇧🇷",
|
|
||||||
"pt-PT": "🇵🇹",
|
|
||||||
"ro-RO": "🇷🇴",
|
|
||||||
"ru-RU": "🇷🇺",
|
|
||||||
"sk-SK": "🇸🇰",
|
|
||||||
"sv-SE": "🇸🇪",
|
|
||||||
"tr-TR": "🇹🇷",
|
|
||||||
"uk-UA": "🇺🇦",
|
|
||||||
"zh-CN": "🇨🇳",
|
|
||||||
"zh-TW": "🇹🇼",
|
|
||||||
};
|
|
||||||
|
|
||||||
const languages = {
|
|
||||||
"ar-SA": "العربية",
|
|
||||||
"bg-BG": "Български",
|
|
||||||
"ca-ES": "Catalan",
|
|
||||||
"de-DE": "Deutsch",
|
|
||||||
"el-GR": "Ελληνικά",
|
|
||||||
"es-ES": "Español",
|
|
||||||
"fa-IR": "فارسی",
|
|
||||||
"fi-FI": "Suomi",
|
|
||||||
"fr-FR": "Français",
|
|
||||||
"he-IL": "עברית",
|
|
||||||
"hi-IN": "हिन्दी",
|
|
||||||
"hu-HU": "Magyar",
|
|
||||||
"id-ID": "Bahasa Indonesia",
|
|
||||||
"it-IT": "Italiano",
|
|
||||||
"ja-JP": "日本語",
|
|
||||||
"ko-KR": "한국어",
|
|
||||||
"my-MM": "Burmese",
|
|
||||||
"nb-NO": "Norsk bokmål",
|
|
||||||
"nl-NL": "Nederlands",
|
|
||||||
"nn-NO": "Norsk nynorsk",
|
|
||||||
"pl-PL": "Polski",
|
|
||||||
"pt-BR": "Português Brasileiro",
|
|
||||||
"pt-PT": "Português",
|
|
||||||
"ro-RO": "Română",
|
|
||||||
"ru-RU": "Русский",
|
|
||||||
"sk-SK": "Slovenčina",
|
|
||||||
"sv-SE": "Svenska",
|
|
||||||
"tr-TR": "Türkçe",
|
|
||||||
"uk-UA": "Українська",
|
|
||||||
"zh-CN": "简体中文",
|
|
||||||
"zh-TW": "繁體中文",
|
|
||||||
};
|
|
||||||
|
|
||||||
const percentages = fs.readFileSync(
|
|
||||||
`${__dirname}/../src/locales/percentages.json`,
|
|
||||||
);
|
|
||||||
const rowData = JSON.parse(percentages);
|
|
||||||
|
|
||||||
const coverages = Object.entries(rowData)
|
|
||||||
.sort(([, a], [, b]) => b - a)
|
|
||||||
.reduce((r, [k, v]) => ({ ...r, [k]: v }), {});
|
|
||||||
|
|
||||||
const boldIf = (text, condition) => (condition ? `**${text}**` : text);
|
|
||||||
|
|
||||||
const printHeader = () => {
|
|
||||||
let result = "| | Flag | Locale | % |\n";
|
|
||||||
result += "| :--: | :--: | -- | :--: |";
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const printRow = (id, locale, coverage) => {
|
|
||||||
const isOver = coverage >= THRESSHOLD;
|
|
||||||
let result = `| ${isOver ? id : "..."} | `;
|
|
||||||
result += `${locale in flags ? flags[locale] : ""} | `;
|
|
||||||
const language = locale in languages ? languages[locale] : locale;
|
|
||||||
if (locale in crowdinMap && crowdinMap[locale]) {
|
|
||||||
result += `[${boldIf(
|
|
||||||
language,
|
|
||||||
isOver,
|
|
||||||
)}](https://crowdin.com/translate/excalidraw/10/${crowdinMap[locale]}) | `;
|
|
||||||
} else {
|
|
||||||
result += `${boldIf(language, isOver)} | `;
|
|
||||||
}
|
|
||||||
result += `${coverage === 100 ? "✅" : boldIf(coverage, isOver)} |`;
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
console.info(
|
|
||||||
`Each language must be at least **${THRESSHOLD}%** translated in order to appear on Excalidraw. Join us on [Crowdin](https://crowdin.com/project/excalidraw) and help us translate your own language. **Can't find yours yet?** Open an [issue](https://github.com/excalidraw/excalidraw/issues/new) and we'll add it to the list.`,
|
|
||||||
);
|
|
||||||
console.info("\n\r");
|
|
||||||
console.info(printHeader());
|
|
||||||
let index = 1;
|
|
||||||
for (const coverage in coverages) {
|
|
||||||
if (coverage === "en") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
console.info(printRow(index, coverage, coverages[coverage]));
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
console.info("\n\r");
|
|
||||||
console.info("\\* Languages in **bold** are going to appear on production.");
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { register } from "./register";
|
|
||||||
import { getSelectedElements } from "../scene";
|
|
||||||
import { getNonDeletedElements } from "../element";
|
|
||||||
import { deepCopyElement } from "../element/newElement";
|
|
||||||
import { Library } from "../data/library";
|
|
||||||
import { EVENT_LIBRARY, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
export const actionAddToLibrary = register({
|
|
||||||
name: "addToLibrary",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
|
|
||||||
Library.loadLibrary().then((items) => {
|
|
||||||
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
|
||||||
});
|
|
||||||
trackEvent(EVENT_LIBRARY, "add");
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
contextMenuOrder: 6,
|
|
||||||
contextItemLabel: "labels.addToLibrary",
|
|
||||||
});
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { KEYS } from "../keys";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { register } from "./register";
|
|
||||||
import {
|
|
||||||
AlignBottomIcon,
|
|
||||||
AlignLeftIcon,
|
|
||||||
AlignRightIcon,
|
|
||||||
AlignTopIcon,
|
|
||||||
CenterHorizontallyIcon,
|
|
||||||
CenterVerticallyIcon,
|
|
||||||
} from "../components/icons";
|
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { alignElements, Alignment } from "../align";
|
|
||||||
import { getShortcutKey } from "../utils";
|
|
||||||
import { trackEvent, EVENT_ALIGN } from "../analytics";
|
|
||||||
|
|
||||||
const enableActionGroup = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
|
|
||||||
|
|
||||||
const alignSelectedElements = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: Readonly<AppState>,
|
|
||||||
alignment: Alignment,
|
|
||||||
) => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedElements = alignElements(selectedElements, alignment);
|
|
||||||
|
|
||||||
const updatedElementsMap = getElementMap(updatedElements);
|
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actionAlignTop = register({
|
|
||||||
name: "alignTop",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
trackEvent(EVENT_ALIGN, "align", "top");
|
|
||||||
return {
|
|
||||||
appState,
|
|
||||||
elements: alignSelectedElements(elements, appState, {
|
|
||||||
position: "start",
|
|
||||||
axis: "y",
|
|
||||||
}),
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
keyTest: (event) =>
|
|
||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
|
||||||
type="button"
|
|
||||||
icon={<AlignTopIcon appearance={appState.appearance} />}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
title={`${t("labels.alignTop")} — ${getShortcutKey(
|
|
||||||
"CtrlOrCmd+Shift+Up",
|
|
||||||
)}`}
|
|
||||||
aria-label={t("labels.alignTop")}
|
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionAlignBottom = register({
|
|
||||||
name: "alignBottom",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
trackEvent(EVENT_ALIGN, "align", "bottom");
|
|
||||||
return {
|
|
||||||
appState,
|
|
||||||
elements: alignSelectedElements(elements, appState, {
|
|
||||||
position: "end",
|
|
||||||
axis: "y",
|
|
||||||
}),
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
keyTest: (event) =>
|
|
||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
|
||||||
type="button"
|
|
||||||
icon={<AlignBottomIcon appearance={appState.appearance} />}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
title={`${t("labels.alignBottom")} — ${getShortcutKey(
|
|
||||||
"CtrlOrCmd+Shift+Down",
|
|
||||||
)}`}
|
|
||||||
aria-label={t("labels.alignBottom")}
|
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionAlignLeft = register({
|
|
||||||
name: "alignLeft",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
trackEvent(EVENT_ALIGN, "align", "left");
|
|
||||||
return {
|
|
||||||
appState,
|
|
||||||
elements: alignSelectedElements(elements, appState, {
|
|
||||||
position: "start",
|
|
||||||
axis: "x",
|
|
||||||
}),
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
keyTest: (event) =>
|
|
||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
|
||||||
type="button"
|
|
||||||
icon={<AlignLeftIcon appearance={appState.appearance} />}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
title={`${t("labels.alignLeft")} — ${getShortcutKey(
|
|
||||||
"CtrlOrCmd+Shift+Left",
|
|
||||||
)}`}
|
|
||||||
aria-label={t("labels.alignLeft")}
|
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionAlignRight = register({
|
|
||||||
name: "alignRight",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
trackEvent(EVENT_ALIGN, "align", "right");
|
|
||||||
return {
|
|
||||||
appState,
|
|
||||||
elements: alignSelectedElements(elements, appState, {
|
|
||||||
position: "end",
|
|
||||||
axis: "x",
|
|
||||||
}),
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
keyTest: (event) =>
|
|
||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
|
||||||
type="button"
|
|
||||||
icon={<AlignRightIcon appearance={appState.appearance} />}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
title={`${t("labels.alignRight")} — ${getShortcutKey(
|
|
||||||
"CtrlOrCmd+Shift+Right",
|
|
||||||
)}`}
|
|
||||||
aria-label={t("labels.alignRight")}
|
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionAlignVerticallyCentered = register({
|
|
||||||
name: "alignVerticallyCentered",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
trackEvent(EVENT_ALIGN, "vertically", "center");
|
|
||||||
return {
|
|
||||||
appState,
|
|
||||||
elements: alignSelectedElements(elements, appState, {
|
|
||||||
position: "center",
|
|
||||||
axis: "y",
|
|
||||||
}),
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
|
||||||
type="button"
|
|
||||||
icon={<CenterVerticallyIcon appearance={appState.appearance} />}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
title={t("labels.centerVertically")}
|
|
||||||
aria-label={t("labels.centerVertically")}
|
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionAlignHorizontallyCentered = register({
|
|
||||||
name: "alignHorizontallyCentered",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
trackEvent(EVENT_ALIGN, "horizontally", "center");
|
|
||||||
return {
|
|
||||||
appState,
|
|
||||||
elements: alignSelectedElements(elements, appState, {
|
|
||||||
position: "center",
|
|
||||||
axis: "x",
|
|
||||||
}),
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
|
||||||
type="button"
|
|
||||||
icon={<CenterHorizontallyIcon appearance={appState.appearance} />}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
title={t("labels.centerHorizontally")}
|
|
||||||
aria-label={t("labels.centerHorizontally")}
|
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
+80
-135
@@ -1,35 +1,21 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics";
|
|
||||||
import { getDefaultAppState } from "../appState";
|
|
||||||
import colors from "../colors";
|
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
import { getDefaultAppState } from "../appState";
|
||||||
|
import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { getNormalizedZoom, calculateScrollCenter } from "../scene";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
import { getShortcutKey } from "../utils";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { CODES, KEYS } from "../keys";
|
|
||||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
|
||||||
import { getNewZoom } from "../scene/zoom";
|
|
||||||
import { AppState, NormalizedZoomValue } from "../types";
|
|
||||||
import { getNewSceneName, getShortcutKey } from "../utils";
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
import { AppState, FlooredNumber } from "../types";
|
||||||
|
import { getCommonBounds } from "../element";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
if (value !== appState.viewBackgroundColor) {
|
|
||||||
trackEvent(
|
|
||||||
EVENT_CHANGE,
|
|
||||||
"canvas color",
|
|
||||||
colors.canvasBackground.includes(value)
|
|
||||||
? `${value} (picker ${colors.canvasBackground.indexOf(value)})`
|
|
||||||
: value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, viewBackgroundColor: value },
|
appState: { ...appState, viewBackgroundColor: value },
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
@@ -52,22 +38,13 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
export const actionClearCanvas = register({
|
export const actionClearCanvas = register({
|
||||||
name: "clearCanvas",
|
name: "clearCanvas",
|
||||||
perform: (elements, appState: AppState) => {
|
perform: (elements, appState: AppState) => {
|
||||||
trackEvent(EVENT_ACTION, "clear canvas");
|
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) =>
|
elements: elements.map((element) =>
|
||||||
newElementWith(element, { isDeleted: true }),
|
newElementWith(element, { isDeleted: true }),
|
||||||
),
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...getDefaultAppState(),
|
...getDefaultAppState(),
|
||||||
name: getNewSceneName(),
|
username: appState.username,
|
||||||
appearance: appState.appearance,
|
|
||||||
elementLocked: appState.elementLocked,
|
|
||||||
exportBackground: appState.exportBackground,
|
|
||||||
exportEmbedScene: appState.exportEmbedScene,
|
|
||||||
gridSize: appState.gridSize,
|
|
||||||
shouldAddWatermark: appState.shouldAddWatermark,
|
|
||||||
showStats: appState.showStats,
|
|
||||||
pasteDialog: appState.pasteDialog,
|
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -81,6 +58,10 @@ export const actionClearCanvas = register({
|
|||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(t("alerts.clearReset"))) {
|
if (window.confirm(t("alerts.clearReset"))) {
|
||||||
|
// TODO: Defined globally, since file handles aren't yet serializable.
|
||||||
|
// Once `FileSystemFileHandle` can be serialized, make this
|
||||||
|
// part of `AppState`.
|
||||||
|
(window as any).handle = null;
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -90,20 +71,23 @@ export const actionClearCanvas = register({
|
|||||||
|
|
||||||
const ZOOM_STEP = 0.1;
|
const ZOOM_STEP = 0.1;
|
||||||
|
|
||||||
|
const KEY_CODES = {
|
||||||
|
MINUS: "Minus",
|
||||||
|
EQUAL: "Equal",
|
||||||
|
ONE: "Digit1",
|
||||||
|
ZERO: "Digit0",
|
||||||
|
NUM_SUBTRACT: "NumpadSubtract",
|
||||||
|
NUM_ADD: "NumpadAdd",
|
||||||
|
NUM_ZERO: "Numpad0",
|
||||||
|
};
|
||||||
|
|
||||||
export const actionZoomIn = register({
|
export const actionZoomIn = register({
|
||||||
name: "zoomIn",
|
name: "zoomIn",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
const zoom = getNewZoom(
|
|
||||||
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
|
||||||
appState.zoom,
|
|
||||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
|
||||||
{ x: appState.width / 2, y: appState.height / 2 },
|
|
||||||
);
|
|
||||||
trackEvent(EVENT_ACTION, "zoom", "in", zoom.value * 100);
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom,
|
zoom: getNormalizedZoom(appState.zoom + ZOOM_STEP),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -120,25 +104,17 @@ export const actionZoomIn = register({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === CODES.EQUAL || event.code === CODES.NUM_ADD) &&
|
(event.code === KEY_CODES.EQUAL || event.code === KEY_CODES.NUM_ADD) &&
|
||||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionZoomOut = register({
|
export const actionZoomOut = register({
|
||||||
name: "zoomOut",
|
name: "zoomOut",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
const zoom = getNewZoom(
|
|
||||||
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
|
||||||
appState.zoom,
|
|
||||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
|
||||||
{ x: appState.width / 2, y: appState.height / 2 },
|
|
||||||
);
|
|
||||||
|
|
||||||
trackEvent(EVENT_ACTION, "zoom", "out", zoom.value * 100);
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom,
|
zoom: getNormalizedZoom(appState.zoom - ZOOM_STEP),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -155,26 +131,17 @@ export const actionZoomOut = register({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === CODES.MINUS || event.code === CODES.NUM_SUBTRACT) &&
|
(event.code === KEY_CODES.MINUS || event.code === KEY_CODES.NUM_SUBTRACT) &&
|
||||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionResetZoom = register({
|
export const actionResetZoom = register({
|
||||||
name: "resetZoom",
|
name: "resetZoom",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
trackEvent(EVENT_ACTION, "zoom", "reset", 100);
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom: getNewZoom(
|
zoom: 1,
|
||||||
1 as NormalizedZoomValue,
|
|
||||||
appState.zoom,
|
|
||||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
|
||||||
{
|
|
||||||
x: appState.width / 2,
|
|
||||||
y: appState.height / 2,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -191,88 +158,66 @@ export const actionResetZoom = register({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
(event.code === KEY_CODES.ZERO || event.code === KEY_CODES.NUM_ZERO) &&
|
||||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||||
});
|
});
|
||||||
|
|
||||||
const zoomValueToFitBoundsOnViewport = (
|
const calculateZoom = (
|
||||||
bounds: [number, number, number, number],
|
commonBounds: number[],
|
||||||
viewportDimensions: { width: number; height: number },
|
currentZoom: number,
|
||||||
) => {
|
{
|
||||||
const [x1, y1, x2, y2] = bounds;
|
scrollX,
|
||||||
const commonBoundsWidth = x2 - x1;
|
scrollY,
|
||||||
const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth;
|
}: {
|
||||||
const commonBoundsHeight = y2 - y1;
|
scrollX: FlooredNumber;
|
||||||
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
|
scrollY: FlooredNumber;
|
||||||
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
|
},
|
||||||
const zoomAdjustedToSteps =
|
): number => {
|
||||||
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
const { innerWidth, innerHeight } = window;
|
||||||
const clampedZoomValueToFitElements = Math.min(
|
const [x, y] = commonBounds;
|
||||||
Math.max(zoomAdjustedToSteps, ZOOM_STEP),
|
const zoomX = -innerWidth / (2 * scrollX + 2 * x - innerWidth);
|
||||||
1,
|
const zoomY = -innerHeight / (2 * scrollY + 2 * y - innerHeight);
|
||||||
);
|
const margin = 0.01;
|
||||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
let newZoom;
|
||||||
|
|
||||||
|
if (zoomX < zoomY) {
|
||||||
|
newZoom = zoomX - margin;
|
||||||
|
} else if (zoomY <= zoomX) {
|
||||||
|
newZoom = zoomY - margin;
|
||||||
|
} else {
|
||||||
|
newZoom = currentZoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newZoom <= 0.1) {
|
||||||
|
return 0.1;
|
||||||
|
}
|
||||||
|
if (newZoom >= 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newZoom;
|
||||||
};
|
};
|
||||||
|
|
||||||
const zoomToFitElements = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: Readonly<AppState>,
|
|
||||||
zoomToSelection: boolean,
|
|
||||||
) => {
|
|
||||||
const nonDeletedElements = getNonDeletedElements(elements);
|
|
||||||
const selectedElements = getSelectedElements(nonDeletedElements, appState);
|
|
||||||
|
|
||||||
const commonBounds =
|
|
||||||
zoomToSelection && selectedElements.length > 0
|
|
||||||
? getCommonBounds(selectedElements)
|
|
||||||
: getCommonBounds(nonDeletedElements);
|
|
||||||
|
|
||||||
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
|
||||||
width: appState.width,
|
|
||||||
height: appState.height,
|
|
||||||
});
|
|
||||||
const newZoom = getNewZoom(zoomValue, appState.zoom, {
|
|
||||||
left: appState.offsetLeft,
|
|
||||||
top: appState.offsetTop,
|
|
||||||
});
|
|
||||||
const action = zoomToSelection ? "selection" : "fit";
|
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = commonBounds;
|
|
||||||
const centerX = (x1 + x2) / 2;
|
|
||||||
const centerY = (y1 + y2) / 2;
|
|
||||||
trackEvent(EVENT_ACTION, "zoom", action, newZoom.value * 100);
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
...centerScrollOn({
|
|
||||||
scenePoint: { x: centerX, y: centerY },
|
|
||||||
viewportDimensions: {
|
|
||||||
width: appState.width,
|
|
||||||
height: appState.height,
|
|
||||||
},
|
|
||||||
zoom: newZoom,
|
|
||||||
}),
|
|
||||||
zoom: newZoom,
|
|
||||||
},
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actionZoomToSelected = register({
|
|
||||||
name: "zoomToSelection",
|
|
||||||
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
|
||||||
keyTest: (event) =>
|
|
||||||
event.code === CODES.TWO &&
|
|
||||||
event.shiftKey &&
|
|
||||||
!event.altKey &&
|
|
||||||
!event[KEYS.CTRL_OR_CMD],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionZoomToFit = register({
|
export const actionZoomToFit = register({
|
||||||
name: "zoomToFit",
|
name: "zoomToFit",
|
||||||
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
perform: (elements, appState) => {
|
||||||
|
const nonDeletedElements = elements.filter((element) => !element.isDeleted);
|
||||||
|
const scrollCenter = calculateScrollCenter(nonDeletedElements);
|
||||||
|
const commonBounds = getCommonBounds(nonDeletedElements);
|
||||||
|
const zoom = calculateZoom(commonBounds, appState.zoom, scrollCenter);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
scrollX: scrollCenter.scrollX,
|
||||||
|
scrollY: scrollCenter.scrollY,
|
||||||
|
zoom,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.code === CODES.ONE &&
|
event.code === KEY_CODES.ONE &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
!event[KEYS.CTRL_OR_CMD],
|
!event[KEYS.CTRL_OR_CMD],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isSomeElementSelected } from "../scene";
|
import { deleteSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@@ -6,122 +6,14 @@ import { trash } from "../components/icons";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { getElementsInGroup } from "../groups";
|
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
) => {
|
|
||||||
return {
|
|
||||||
elements: elements.map((el) => {
|
|
||||||
if (appState.selectedElementIds[el.id]) {
|
|
||||||
return newElementWith(el, { isDeleted: true });
|
|
||||||
}
|
|
||||||
return el;
|
|
||||||
}),
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
selectedElementIds: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGroupEditingState = (
|
|
||||||
appState: AppState,
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
): AppState => {
|
|
||||||
if (appState.editingGroupId) {
|
|
||||||
const siblingElements = getElementsInGroup(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState.editingGroupId!,
|
|
||||||
);
|
|
||||||
if (siblingElements.length) {
|
|
||||||
return {
|
|
||||||
...appState,
|
|
||||||
selectedElementIds: { [siblingElements[0].id]: true },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return appState;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actionDeleteSelected = register({
|
export const actionDeleteSelected = register({
|
||||||
name: "deleteSelectedElements",
|
name: "deleteSelectedElements",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
if (appState.editingLinearElement) {
|
const {
|
||||||
const {
|
|
||||||
elementId,
|
|
||||||
activePointIndex,
|
|
||||||
startBindingElement,
|
|
||||||
endBindingElement,
|
|
||||||
} = appState.editingLinearElement;
|
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
|
||||||
if (!element) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
// case: no point selected → delete whole element
|
|
||||||
activePointIndex == null ||
|
|
||||||
activePointIndex === -1 ||
|
|
||||||
// case: deleting last remaining point
|
|
||||||
element.points.length < 2
|
|
||||||
) {
|
|
||||||
const nextElements = elements.filter((el) => el.id !== element.id);
|
|
||||||
const nextAppState = handleGroupEditingState(appState, nextElements);
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements: nextElements,
|
|
||||||
appState: {
|
|
||||||
...nextAppState,
|
|
||||||
editingLinearElement: null,
|
|
||||||
},
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// We cannot do this inside `movePoint` because it is also called
|
|
||||||
// when deleting the uncommitted point (which hasn't caused any binding)
|
|
||||||
const binding = {
|
|
||||||
startBindingElement:
|
|
||||||
activePointIndex === 0 ? null : startBindingElement,
|
|
||||||
endBindingElement:
|
|
||||||
activePointIndex === element.points.length - 1
|
|
||||||
? null
|
|
||||||
: endBindingElement,
|
|
||||||
};
|
|
||||||
|
|
||||||
LinearElementEditor.movePoint(element, activePointIndex, "delete");
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements,
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
editingLinearElement: {
|
|
||||||
...appState.editingLinearElement,
|
|
||||||
...binding,
|
|
||||||
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: nextAppState,
|
appState: nextAppState,
|
||||||
} = deleteSelectedElements(elements, appState);
|
} = deleteSelectedElements(elements, appState);
|
||||||
fixBindingsAfterDeletion(
|
|
||||||
nextElements,
|
|
||||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
|
||||||
);
|
|
||||||
|
|
||||||
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
@@ -136,7 +28,7 @@ export const actionDeleteSelected = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.delete",
|
contextItemLabel: "labels.delete",
|
||||||
contextMenuOrder: 999999,
|
contextMenuOrder: 3,
|
||||||
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { CODES } from "../keys";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { register } from "./register";
|
|
||||||
import {
|
|
||||||
DistributeHorizontallyIcon,
|
|
||||||
DistributeVerticallyIcon,
|
|
||||||
} from "../components/icons";
|
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { distributeElements, Distribution } from "../disitrubte";
|
|
||||||
import { getShortcutKey } from "../utils";
|
|
||||||
import { EVENT_ALIGN, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
const enableActionGroup = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
|
|
||||||
|
|
||||||
const distributeSelectedElements = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: Readonly<AppState>,
|
|
||||||
distribution: Distribution,
|
|
||||||
) => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedElements = distributeElements(selectedElements, distribution);
|
|
||||||
|
|
||||||
const updatedElementsMap = getElementMap(updatedElements);
|
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const distributeHorizontally = register({
|
|
||||||
name: "distributeHorizontally",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
trackEvent(EVENT_ALIGN, "distribute", "horizontally");
|
|
||||||
return {
|
|
||||||
appState,
|
|
||||||
elements: distributeSelectedElements(elements, appState, {
|
|
||||||
space: "between",
|
|
||||||
axis: "x",
|
|
||||||
}),
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
keyTest: (event) => event.altKey && event.code === CODES.H,
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
|
||||||
type="button"
|
|
||||||
icon={<DistributeHorizontallyIcon appearance={appState.appearance} />}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
title={`${t("labels.distributeHorizontally")} — ${getShortcutKey(
|
|
||||||
"Alt+H",
|
|
||||||
)}`}
|
|
||||||
aria-label={t("labels.distributeHorizontally")}
|
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const distributeVertically = register({
|
|
||||||
name: "distributeVertically",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
trackEvent(EVENT_ALIGN, "distribute", "vertically");
|
|
||||||
return {
|
|
||||||
appState,
|
|
||||||
elements: distributeSelectedElements(elements, appState, {
|
|
||||||
space: "between",
|
|
||||||
axis: "y",
|
|
||||||
}),
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
keyTest: (event) => event.altKey && event.code === CODES.V,
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
|
||||||
type="button"
|
|
||||||
icon={<DistributeVerticallyIcon appearance={appState.appearance} />}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
title={`${t("labels.distributeVertically")} — ${getShortcutKey("Alt+V")}`}
|
|
||||||
aria-label={t("labels.distributeVertically")}
|
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
@@ -8,63 +8,32 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { clone } from "../components/icons";
|
import { clone } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
import {
|
|
||||||
selectGroupsForSelectedElements,
|
|
||||||
getSelectedGroupForElement,
|
|
||||||
getElementsInGroup,
|
|
||||||
} from "../groups";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
|
||||||
import { ActionResult } from "./types";
|
|
||||||
import { GRID_SIZE } from "../constants";
|
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
// duplicate point if selected while editing multi-point element
|
|
||||||
if (appState.editingLinearElement) {
|
|
||||||
const { activePointIndex, elementId } = appState.editingLinearElement;
|
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
|
||||||
if (!element || activePointIndex === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const { points } = element;
|
|
||||||
const selectedPoint = points[activePointIndex];
|
|
||||||
const nextPoint = points[activePointIndex + 1];
|
|
||||||
mutateElement(element, {
|
|
||||||
points: [
|
|
||||||
...points.slice(0, activePointIndex + 1),
|
|
||||||
nextPoint
|
|
||||||
? [
|
|
||||||
(selectedPoint[0] + nextPoint[0]) / 2,
|
|
||||||
(selectedPoint[1] + nextPoint[1]) / 2,
|
|
||||||
]
|
|
||||||
: [selectedPoint[0] + 30, selectedPoint[1] + 30],
|
|
||||||
...points.slice(activePointIndex + 1),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
editingLinearElement: {
|
|
||||||
...appState.editingLinearElement,
|
|
||||||
activePointIndex: activePointIndex + 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
elements,
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...duplicateElements(elements, appState),
|
appState,
|
||||||
|
elements: elements.reduce(
|
||||||
|
(acc: Array<ExcalidrawElement>, element: ExcalidrawElement) => {
|
||||||
|
if (appState.selectedElementIds[element.id]) {
|
||||||
|
const newElement = duplicateElement(element, {
|
||||||
|
x: element.x + 10,
|
||||||
|
y: element.y + 10,
|
||||||
|
});
|
||||||
|
appState.selectedElementIds[newElement.id] = true;
|
||||||
|
delete appState.selectedElementIds[element.id];
|
||||||
|
return acc.concat([element, newElement]);
|
||||||
|
}
|
||||||
|
return acc.concat(element);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.duplicateSelection",
|
contextItemLabel: "labels.duplicateSelection",
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === "d",
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -78,74 +47,3 @@ export const actionDuplicateSelection = register({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const duplicateElements = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
): Partial<ActionResult> => {
|
|
||||||
const groupIdMap = new Map();
|
|
||||||
const newElements: ExcalidrawElement[] = [];
|
|
||||||
const oldElements: ExcalidrawElement[] = [];
|
|
||||||
const oldIdToDuplicatedId = new Map();
|
|
||||||
|
|
||||||
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
|
||||||
const newElement = duplicateElement(
|
|
||||||
appState.editingGroupId,
|
|
||||||
groupIdMap,
|
|
||||||
element,
|
|
||||||
{
|
|
||||||
x: element.x + GRID_SIZE / 2,
|
|
||||||
y: element.y + GRID_SIZE / 2,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
|
||||||
oldElements.push(element);
|
|
||||||
newElements.push(newElement);
|
|
||||||
return newElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
const finalElements: ExcalidrawElement[] = [];
|
|
||||||
|
|
||||||
let index = 0;
|
|
||||||
while (index < elements.length) {
|
|
||||||
const element = elements[index];
|
|
||||||
if (appState.selectedElementIds[element.id]) {
|
|
||||||
if (element.groupIds.length) {
|
|
||||||
const groupId = getSelectedGroupForElement(appState, element);
|
|
||||||
// if group selected, duplicate it atomically
|
|
||||||
if (groupId) {
|
|
||||||
const groupElements = getElementsInGroup(elements, groupId);
|
|
||||||
finalElements.push(
|
|
||||||
...groupElements,
|
|
||||||
...groupElements.map((element) =>
|
|
||||||
duplicateAndOffsetElement(element),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
index = index + groupElements.length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finalElements.push(element, duplicateAndOffsetElement(element));
|
|
||||||
} else {
|
|
||||||
finalElements.push(element);
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements: finalElements,
|
|
||||||
appState: selectGroupsForSelectedElements(
|
|
||||||
{
|
|
||||||
...appState,
|
|
||||||
selectedGroupIds: {},
|
|
||||||
selectedElementIds: newElements.reduce((acc, element) => {
|
|
||||||
acc[element.id] = true;
|
|
||||||
return acc;
|
|
||||||
}, {} as any),
|
|
||||||
},
|
|
||||||
getNonDeletedElements(finalElements),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { EVENT_CHANGE, EVENT_IO, trackEvent } from "../analytics";
|
|
||||||
import { load, save, saveAs } from "../components/icons";
|
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
|
import { saveAsJSON, loadFromJSON } from "../data";
|
||||||
|
import { load, save } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
|
||||||
import { questionCircle } from "../components/icons";
|
|
||||||
import { loadFromJSON, saveAsJSON } from "../data";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { KEYS } from "../keys";
|
|
||||||
import { muteFSAbortError } from "../utils";
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import "../components/ToolIcon.scss";
|
import { KEYS } from "../keys";
|
||||||
import { SCENE_NAME_FALLBACK } from "../constants";
|
|
||||||
|
|
||||||
export const actionChangeProjectName = register({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "title");
|
|
||||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
<ProjectName
|
<ProjectName
|
||||||
label={t("labels.fileTitle")}
|
label={t("labels.fileTitle")}
|
||||||
value={appState.name || SCENE_NAME_FALLBACK}
|
value={appState.name || "Unnamed"}
|
||||||
onChange={(name: string) => updateData(name)}
|
onChange={(name: string) => updateData(name)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -49,33 +42,6 @@ export const actionChangeExportBackground = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeExportEmbedScene = register({
|
|
||||||
name: "changeExportEmbedScene",
|
|
||||||
perform: (_elements, appState, value) => {
|
|
||||||
return {
|
|
||||||
appState: { ...appState, exportEmbedScene: value },
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
|
||||||
<label style={{ display: "flex" }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={appState.exportEmbedScene}
|
|
||||||
onChange={(event) => updateData(event.target.checked)}
|
|
||||||
/>{" "}
|
|
||||||
{t("labels.exportEmbedScene")}
|
|
||||||
<Tooltip
|
|
||||||
label={t("labels.exportEmbedScene_details")}
|
|
||||||
position="above"
|
|
||||||
long={true}
|
|
||||||
>
|
|
||||||
<div className="TooltipIcon">{questionCircle}</div>
|
|
||||||
</Tooltip>
|
|
||||||
</label>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionChangeShouldAddWatermark = register({
|
export const actionChangeShouldAddWatermark = register({
|
||||||
name: "changeShouldAddWatermark",
|
name: "changeShouldAddWatermark",
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
@@ -98,20 +64,13 @@ export const actionChangeShouldAddWatermark = register({
|
|||||||
|
|
||||||
export const actionSaveScene = register({
|
export const actionSaveScene = register({
|
||||||
name: "saveScene",
|
name: "saveScene",
|
||||||
perform: async (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
try {
|
saveAsJSON(elements, appState).catch((error) => console.error(error));
|
||||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
return { commitToHistory: false };
|
||||||
trackEvent(EVENT_IO, "save");
|
},
|
||||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
keyTest: (event) => {
|
||||||
} catch (error) {
|
return event.key === "s" && event[KEYS.CTRL_OR_CMD];
|
||||||
if (error?.name !== "AbortError") {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
return { commitToHistory: false };
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
|
||||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -124,55 +83,23 @@ export const actionSaveScene = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionSaveAsScene = register({
|
|
||||||
name: "saveAsScene",
|
|
||||||
perform: async (elements, appState, value) => {
|
|
||||||
try {
|
|
||||||
const { fileHandle } = await saveAsJSON(elements, {
|
|
||||||
...appState,
|
|
||||||
fileHandle: null,
|
|
||||||
});
|
|
||||||
trackEvent(EVENT_IO, "save as");
|
|
||||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name !== "AbortError") {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
return { commitToHistory: false };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
keyTest: (event) =>
|
|
||||||
event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
|
|
||||||
PanelComponent: ({ updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
icon={saveAs}
|
|
||||||
title={t("buttons.saveAs")}
|
|
||||||
aria-label={t("buttons.saveAs")}
|
|
||||||
showAriaLabel={useIsMobile()}
|
|
||||||
hidden={
|
|
||||||
!("chooseFileSystemEntries" in window || "showOpenFilePicker" in window)
|
|
||||||
}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionLoadScene = register({
|
export const actionLoadScene = register({
|
||||||
name: "loadScene",
|
name: "loadScene",
|
||||||
perform: (
|
perform: (
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
{ elements: loadedElements, appState: loadedAppState, error },
|
{ elements: loadedElements, appState: loadedAppState, error },
|
||||||
) => ({
|
) => {
|
||||||
elements: loadedElements,
|
return {
|
||||||
appState: {
|
elements: loadedElements,
|
||||||
...loadedAppState,
|
appState: {
|
||||||
errorMessage: error,
|
...loadedAppState,
|
||||||
},
|
errorMessage: error,
|
||||||
commitToHistory: true,
|
},
|
||||||
}),
|
commitToHistory: false,
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={load}
|
icon={load}
|
||||||
@@ -180,12 +107,15 @@ export const actionLoadScene = register({
|
|||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
loadFromJSON(appState)
|
loadFromJSON()
|
||||||
.then(({ elements, appState }) => {
|
.then(({ elements, appState }) => {
|
||||||
updateData({ elements, appState });
|
updateData({ elements: elements, appState: appState });
|
||||||
})
|
})
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
// if user cancels, ignore the error
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateData({ error: error.message });
|
updateData({ error: error.message });
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -8,47 +8,10 @@ import { t } from "../i18n";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { isPathALoop } from "../math";
|
import { isPathALoop } from "../math";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
|
||||||
import Scene from "../scene/Scene";
|
|
||||||
import {
|
|
||||||
maybeBindLinearElement,
|
|
||||||
bindOrUnbindLinearElement,
|
|
||||||
} from "../element/binding";
|
|
||||||
import { isBindingElement } from "../element/typeChecks";
|
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
if (appState.editingLinearElement) {
|
|
||||||
const {
|
|
||||||
elementId,
|
|
||||||
startBindingElement,
|
|
||||||
endBindingElement,
|
|
||||||
} = appState.editingLinearElement;
|
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
if (isBindingElement(element)) {
|
|
||||||
bindOrUnbindLinearElement(
|
|
||||||
element,
|
|
||||||
startBindingElement,
|
|
||||||
endBindingElement,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
elements:
|
|
||||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
|
||||||
? elements.filter((el) => el.id !== element.id)
|
|
||||||
: undefined,
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
editingLinearElement: null,
|
|
||||||
},
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
window.document.activeElement.blur();
|
window.document.activeElement.blur();
|
||||||
@@ -83,17 +46,16 @@ export const actionFinalize = register({
|
|||||||
// If the multi point line closes the loop,
|
// If the multi point line closes the loop,
|
||||||
// set the last point to first point.
|
// set the last point to first point.
|
||||||
// This ensures that loop remains closed at different scales.
|
// This ensures that loop remains closed at different scales.
|
||||||
const isLoop = isPathALoop(multiPointElement.points);
|
|
||||||
if (
|
if (
|
||||||
multiPointElement.type === "line" ||
|
multiPointElement.type === "line" ||
|
||||||
multiPointElement.type === "draw"
|
multiPointElement.type === "draw"
|
||||||
) {
|
) {
|
||||||
if (isLoop) {
|
if (isPathALoop(multiPointElement.points)) {
|
||||||
const linePoints = multiPointElement.points;
|
const linePoints = multiPointElement.points;
|
||||||
const firstPoint = linePoints[0];
|
const firstPoint = linePoints[0];
|
||||||
mutateElement(multiPointElement, {
|
mutateElement(multiPointElement, {
|
||||||
points: linePoints.map((point, index) =>
|
points: linePoints.map((point, i) =>
|
||||||
index === linePoints.length - 1
|
i === linePoints.length - 1
|
||||||
? ([firstPoint[0], firstPoint[1]] as const)
|
? ([firstPoint[0], firstPoint[1]] as const)
|
||||||
: point,
|
: point,
|
||||||
),
|
),
|
||||||
@@ -101,23 +63,6 @@ export const actionFinalize = register({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
isBindingElement(multiPointElement) &&
|
|
||||||
!isLoop &&
|
|
||||||
multiPointElement.points.length > 1
|
|
||||||
) {
|
|
||||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
||||||
multiPointElement,
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
maybeBindLinearElement(
|
|
||||||
multiPointElement,
|
|
||||||
appState,
|
|
||||||
Scene.getScene(multiPointElement)!,
|
|
||||||
{ x, y },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!appState.elementLocked) {
|
if (!appState.elementLocked) {
|
||||||
appState.selectedElementIds[multiPointElement.id] = true;
|
appState.selectedElementIds[multiPointElement.id] = true;
|
||||||
}
|
}
|
||||||
@@ -136,8 +81,6 @@ export const actionFinalize = register({
|
|||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
editingElement: null,
|
editingElement: null,
|
||||||
startBoundElement: null,
|
|
||||||
suggestedBindings: [],
|
|
||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
multiPointElement && !appState.elementLocked
|
multiPointElement && !appState.elementLocked
|
||||||
? {
|
? {
|
||||||
@@ -146,13 +89,13 @@ export const actionFinalize = register({
|
|||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.elementType === "draw",
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
(event.key === KEYS.ESCAPE &&
|
(event.key === KEYS.ESCAPE &&
|
||||||
(appState.editingLinearElement !== null ||
|
!appState.draggingElement &&
|
||||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
appState.multiElement === null) ||
|
||||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||||
appState.multiElement !== null),
|
appState.multiElement !== null),
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { CODES, KEYS } from "../keys";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { getShortcutKey } from "../utils";
|
|
||||||
import { register } from "./register";
|
|
||||||
import { UngroupIcon, GroupIcon } from "../components/icons";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|
||||||
import {
|
|
||||||
getSelectedGroupIds,
|
|
||||||
selectGroup,
|
|
||||||
selectGroupsForSelectedElements,
|
|
||||||
getElementsInGroup,
|
|
||||||
addToGroup,
|
|
||||||
removeFromSelectedGroups,
|
|
||||||
isElementInGroup,
|
|
||||||
} from "../groups";
|
|
||||||
import { getNonDeletedElements } from "../element";
|
|
||||||
import { randomId } from "../random";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
|
|
||||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
|
||||||
if (elements.length >= 2) {
|
|
||||||
const groupIds = elements[0].groupIds;
|
|
||||||
for (const groupId of groupIds) {
|
|
||||||
if (
|
|
||||||
elements.reduce(
|
|
||||||
(acc, element) => acc && isElementInGroup(element, groupId),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const enableActionGroup = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
) => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actionGroup = register({
|
|
||||||
name: "group",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
if (selectedElements.length < 2) {
|
|
||||||
// nothing to group
|
|
||||||
return { appState, elements, commitToHistory: false };
|
|
||||||
}
|
|
||||||
// if everything is already grouped into 1 group, there is nothing to do
|
|
||||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
|
||||||
if (selectedGroupIds.length === 1) {
|
|
||||||
const selectedGroupId = selectedGroupIds[0];
|
|
||||||
const elementIdsInGroup = new Set(
|
|
||||||
getElementsInGroup(elements, selectedGroupId).map(
|
|
||||||
(element) => element.id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const selectedElementIds = new Set(
|
|
||||||
selectedElements.map((element) => element.id),
|
|
||||||
);
|
|
||||||
const combinedSet = new Set([
|
|
||||||
...Array.from(elementIdsInGroup),
|
|
||||||
...Array.from(selectedElementIds),
|
|
||||||
]);
|
|
||||||
if (combinedSet.size === elementIdsInGroup.size) {
|
|
||||||
// no incremental ids in the selected ids
|
|
||||||
return { appState, elements, commitToHistory: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newGroupId = randomId();
|
|
||||||
const updatedElements = elements.map((element) => {
|
|
||||||
if (!appState.selectedElementIds[element.id]) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
return newElementWith(element, {
|
|
||||||
groupIds: addToGroup(
|
|
||||||
element.groupIds,
|
|
||||||
newGroupId,
|
|
||||||
appState.editingGroupId,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// keep the z order within the group the same, but move them
|
|
||||||
// to the z order of the highest element in the layer stack
|
|
||||||
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
|
||||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
|
||||||
const lastGroupElementIndex = updatedElements.lastIndexOf(
|
|
||||||
lastElementInGroup,
|
|
||||||
);
|
|
||||||
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
|
||||||
const elementsBeforeGroup = updatedElements
|
|
||||||
.slice(0, lastGroupElementIndex)
|
|
||||||
.filter(
|
|
||||||
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
|
||||||
);
|
|
||||||
const updatedElementsInOrder = [
|
|
||||||
...elementsBeforeGroup,
|
|
||||||
...elementsInGroup,
|
|
||||||
...elementsAfterGroup,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
appState: selectGroup(
|
|
||||||
newGroupId,
|
|
||||||
{ ...appState, selectedGroupIds: {} },
|
|
||||||
getNonDeletedElements(updatedElementsInOrder),
|
|
||||||
),
|
|
||||||
elements: updatedElementsInOrder,
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
contextMenuOrder: 4,
|
|
||||||
contextItemLabel: "labels.group",
|
|
||||||
contextItemPredicate: (elements, appState) =>
|
|
||||||
enableActionGroup(elements, appState),
|
|
||||||
keyTest: (event) =>
|
|
||||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
|
||||||
type="button"
|
|
||||||
icon={<GroupIcon appearance={appState.appearance} />}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
title={`${t("labels.group")} — ${getShortcutKey("CtrlOrCmd+G")}`}
|
|
||||||
aria-label={t("labels.group")}
|
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
|
||||||
></ToolButton>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionUngroup = register({
|
|
||||||
name: "ungroup",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
const groupIds = getSelectedGroupIds(appState);
|
|
||||||
if (groupIds.length === 0) {
|
|
||||||
return { appState, elements, commitToHistory: false };
|
|
||||||
}
|
|
||||||
const nextElements = elements.map((element) => {
|
|
||||||
const nextGroupIds = removeFromSelectedGroups(
|
|
||||||
element.groupIds,
|
|
||||||
appState.selectedGroupIds,
|
|
||||||
);
|
|
||||||
if (nextGroupIds.length === element.groupIds.length) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
return newElementWith(element, {
|
|
||||||
groupIds: nextGroupIds,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
appState: selectGroupsForSelectedElements(
|
|
||||||
{ ...appState, selectedGroupIds: {} },
|
|
||||||
getNonDeletedElements(nextElements),
|
|
||||||
),
|
|
||||||
elements: nextElements,
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
keyTest: (event) =>
|
|
||||||
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
|
||||||
contextMenuOrder: 5,
|
|
||||||
contextItemLabel: "labels.ungroup",
|
|
||||||
contextItemPredicate: (elements, appState) =>
|
|
||||||
getSelectedGroupIds(appState).length > 0,
|
|
||||||
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
hidden={getSelectedGroupIds(appState).length === 0}
|
|
||||||
icon={<UngroupIcon appearance={appState.appearance} />}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
title={`${t("labels.ungroup")} — ${getShortcutKey("CtrlOrCmd+Shift+G")}`}
|
|
||||||
aria-label={t("labels.ungroup")}
|
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
|
||||||
></ToolButton>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
@@ -3,18 +3,20 @@ import React from "react";
|
|||||||
import { undo, redo } from "../components/icons";
|
import { undo, redo } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { SceneHistory, HistoryEntry } from "../history";
|
import { SceneHistory } from "../history";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getElementMap } from "../element";
|
import { getElementMap } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
|
||||||
|
|
||||||
const writeData = (
|
const writeData = (
|
||||||
prevElements: readonly ExcalidrawElement[],
|
prevElements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
updater: () => HistoryEntry | null,
|
updater: () => {
|
||||||
|
elements: ExcalidrawElement[];
|
||||||
|
appState: AppState;
|
||||||
|
} | null,
|
||||||
): ActionResult => {
|
): ActionResult => {
|
||||||
const commitToHistory = false;
|
const commitToHistory = false;
|
||||||
if (
|
if (
|
||||||
@@ -31,29 +33,25 @@ const writeData = (
|
|||||||
const prevElementMap = getElementMap(prevElements);
|
const prevElementMap = getElementMap(prevElements);
|
||||||
const nextElements = data.elements;
|
const nextElements = data.elements;
|
||||||
const nextElementMap = getElementMap(nextElements);
|
const nextElementMap = getElementMap(nextElements);
|
||||||
|
|
||||||
const deletedElements = prevElements.filter(
|
|
||||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
|
||||||
);
|
|
||||||
const elements = nextElements
|
|
||||||
.map((nextElement) =>
|
|
||||||
newElementWith(
|
|
||||||
prevElementMap[nextElement.id] || nextElement,
|
|
||||||
nextElement,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.concat(
|
|
||||||
deletedElements.map((prevElement) =>
|
|
||||||
newElementWith(prevElement, { isDeleted: true }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
fixBindingsAfterDeletion(elements, deletedElements);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements: nextElements
|
||||||
|
.map((nextElement) =>
|
||||||
|
newElementWith(
|
||||||
|
prevElementMap[nextElement.id] || nextElement,
|
||||||
|
nextElement,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.concat(
|
||||||
|
prevElements
|
||||||
|
.filter(
|
||||||
|
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
||||||
|
)
|
||||||
|
.map((prevElement) =>
|
||||||
|
newElementWith(prevElement, { isDeleted: true }),
|
||||||
|
),
|
||||||
|
),
|
||||||
appState: { ...appState, ...data.appState },
|
appState: { ...appState, ...data.appState },
|
||||||
commitToHistory,
|
commitToHistory,
|
||||||
syncHistory: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { commitToHistory };
|
return { commitToHistory };
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import { t } from "../i18n";
|
|||||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { HelpIcon } from "../components/HelpIcon";
|
import { HelpIcon } from "../components/HelpIcon";
|
||||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
name: "toggleCanvasMenu",
|
name: "toggleCanvasMenu",
|
||||||
@@ -66,13 +65,12 @@ export const actionFullScreen = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.code === CODES.F && !event[KEYS.CTRL_OR_CMD],
|
keyTest: (event) => event.keyCode === KEYS.F_KEY_CODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
trackEvent(EVENT_DIALOG, "shortcuts");
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Avatar } from "../components/Avatar";
|
|
||||||
import { register } from "./register";
|
|
||||||
import { getClientColors, getClientInitials } from "../clients";
|
|
||||||
import { Collaborator } from "../types";
|
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
|
||||||
import { EVENT_SHARE, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
export const actionGoToCollaborator = register({
|
|
||||||
name: "goToCollaborator",
|
|
||||||
perform: (_elements, appState, value) => {
|
|
||||||
const point = value as Collaborator["pointer"];
|
|
||||||
trackEvent(EVENT_SHARE, "go to collaborator");
|
|
||||||
if (!point) {
|
|
||||||
return { appState, commitToHistory: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
...centerScrollOn({
|
|
||||||
scenePoint: point,
|
|
||||||
viewportDimensions: {
|
|
||||||
width: appState.width,
|
|
||||||
height: appState.height,
|
|
||||||
},
|
|
||||||
zoom: appState.zoom,
|
|
||||||
}),
|
|
||||||
// Close mobile menu
|
|
||||||
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
|
||||||
},
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ appState, updateData, id }) => {
|
|
||||||
const clientId = id;
|
|
||||||
if (!clientId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collaborator = appState.collaborators.get(clientId);
|
|
||||||
|
|
||||||
if (!collaborator) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { background, stroke } = getClientColors(clientId);
|
|
||||||
const shortName = getClientInitials(collaborator.username);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Avatar
|
|
||||||
color={background}
|
|
||||||
border={stroke}
|
|
||||||
onClick={() => updateData(collaborator.pointer)}
|
|
||||||
>
|
|
||||||
{shortName}
|
|
||||||
</Avatar>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,56 +1,25 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { getLanguage } from "../i18n";
|
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
TextAlign,
|
TextAlign,
|
||||||
FontFamily,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
Arrowhead,
|
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import {
|
import {
|
||||||
getCommonAttributeOfSelectedElements,
|
getCommonAttributeOfSelectedElements,
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
getTargetElements,
|
|
||||||
canChangeSharpness,
|
|
||||||
canHaveArrowheads,
|
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { ButtonSelect } from "../components/ButtonSelect";
|
import { ButtonSelect } from "../components/ButtonSelect";
|
||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
|
||||||
import { IconPicker } from "../components/IconPicker";
|
|
||||||
import {
|
import {
|
||||||
isTextElement,
|
isTextElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
getNonDeletedElements,
|
getNonDeletedElements,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { AppState } from "../../src/types";
|
import { AppState } from "../../src/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { DEFAULT_FONT } from "../appState";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../constants";
|
|
||||||
import { randomInteger } from "../random";
|
|
||||||
import {
|
|
||||||
FillHachureIcon,
|
|
||||||
FillCrossHatchIcon,
|
|
||||||
FillSolidIcon,
|
|
||||||
StrokeWidthIcon,
|
|
||||||
StrokeStyleSolidIcon,
|
|
||||||
StrokeStyleDashedIcon,
|
|
||||||
StrokeStyleDottedIcon,
|
|
||||||
EdgeSharpIcon,
|
|
||||||
EdgeRoundIcon,
|
|
||||||
SloppinessArchitectIcon,
|
|
||||||
SloppinessArtistIcon,
|
|
||||||
SloppinessCartoonistIcon,
|
|
||||||
ArrowheadArrowIcon,
|
|
||||||
ArrowheadBarIcon,
|
|
||||||
ArrowheadDotIcon,
|
|
||||||
ArrowheadNoneIcon,
|
|
||||||
} from "../components/icons";
|
|
||||||
import { EVENT_CHANGE, trackEvent } from "../analytics";
|
|
||||||
import colors from "../colors";
|
|
||||||
|
|
||||||
const changeProperty = (
|
const changeProperty = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -92,15 +61,6 @@ const getFormValue = function <T>(
|
|||||||
export const actionChangeStrokeColor = register({
|
export const actionChangeStrokeColor = register({
|
||||||
name: "changeStrokeColor",
|
name: "changeStrokeColor",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
if (value !== appState.currentItemStrokeColor) {
|
|
||||||
trackEvent(
|
|
||||||
EVENT_CHANGE,
|
|
||||||
"stroke color",
|
|
||||||
colors.elementStroke.includes(value)
|
|
||||||
? `${value} (picker ${colors.elementStroke.indexOf(value)})`
|
|
||||||
: value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -132,16 +92,6 @@ export const actionChangeStrokeColor = register({
|
|||||||
export const actionChangeBackgroundColor = register({
|
export const actionChangeBackgroundColor = register({
|
||||||
name: "changeBackgroundColor",
|
name: "changeBackgroundColor",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
if (value !== appState.currentItemBackgroundColor) {
|
|
||||||
trackEvent(
|
|
||||||
EVENT_CHANGE,
|
|
||||||
"background color",
|
|
||||||
colors.elementBackground.includes(value)
|
|
||||||
? `${value} (picker ${colors.elementBackground.indexOf(value)})`
|
|
||||||
: value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -173,7 +123,6 @@ export const actionChangeBackgroundColor = register({
|
|||||||
export const actionChangeFillStyle = register({
|
export const actionChangeFillStyle = register({
|
||||||
name: "changeFillStyle",
|
name: "changeFillStyle",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "fill", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -187,23 +136,11 @@ export const actionChangeFillStyle = register({
|
|||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fill")}</legend>
|
<legend>{t("labels.fill")}</legend>
|
||||||
<ButtonIconSelect
|
<ButtonSelect
|
||||||
options={[
|
options={[
|
||||||
{
|
{ value: "hachure", text: t("labels.hachure") },
|
||||||
value: "hachure",
|
{ value: "cross-hatch", text: t("labels.crossHatch") },
|
||||||
text: t("labels.hachure"),
|
{ value: "solid", text: t("labels.solid") },
|
||||||
icon: <FillHachureIcon appearance={appState.appearance} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "cross-hatch",
|
|
||||||
text: t("labels.crossHatch"),
|
|
||||||
icon: <FillCrossHatchIcon appearance={appState.appearance} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "solid",
|
|
||||||
text: t("labels.solid"),
|
|
||||||
icon: <FillSolidIcon appearance={appState.appearance} />,
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
group="fill"
|
group="fill"
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
@@ -223,7 +160,6 @@ export const actionChangeFillStyle = register({
|
|||||||
export const actionChangeStrokeWidth = register({
|
export const actionChangeStrokeWidth = register({
|
||||||
name: "changeStrokeWidth",
|
name: "changeStrokeWidth",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "stroke", "width", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -237,39 +173,12 @@ export const actionChangeStrokeWidth = register({
|
|||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.strokeWidth")}</legend>
|
<legend>{t("labels.strokeWidth")}</legend>
|
||||||
<ButtonIconSelect
|
<ButtonSelect
|
||||||
group="stroke-width"
|
group="stroke-width"
|
||||||
options={[
|
options={[
|
||||||
{
|
{ value: 1, text: t("labels.thin") },
|
||||||
value: 1,
|
{ value: 2, text: t("labels.bold") },
|
||||||
text: t("labels.thin"),
|
{ value: 4, text: t("labels.extraBold") },
|
||||||
icon: (
|
|
||||||
<StrokeWidthIcon
|
|
||||||
appearance={appState.appearance}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 2,
|
|
||||||
text: t("labels.bold"),
|
|
||||||
icon: (
|
|
||||||
<StrokeWidthIcon
|
|
||||||
appearance={appState.appearance}
|
|
||||||
strokeWidth={6}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 4,
|
|
||||||
text: t("labels.extraBold"),
|
|
||||||
icon: (
|
|
||||||
<StrokeWidthIcon
|
|
||||||
appearance={appState.appearance}
|
|
||||||
strokeWidth={10}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
@@ -286,11 +195,9 @@ export const actionChangeStrokeWidth = register({
|
|||||||
export const actionChangeSloppiness = register({
|
export const actionChangeSloppiness = register({
|
||||||
name: "changeSloppiness",
|
name: "changeSloppiness",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "stroke", "sloppiness", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
seed: randomInteger(),
|
|
||||||
roughness: value,
|
roughness: value,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -301,24 +208,12 @@ export const actionChangeSloppiness = register({
|
|||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.sloppiness")}</legend>
|
<legend>{t("labels.sloppiness")}</legend>
|
||||||
<ButtonIconSelect
|
<ButtonSelect
|
||||||
group="sloppiness"
|
group="sloppiness"
|
||||||
options={[
|
options={[
|
||||||
{
|
{ value: 0, text: t("labels.architect") },
|
||||||
value: 0,
|
{ value: 1, text: t("labels.artist") },
|
||||||
text: t("labels.architect"),
|
{ value: 2, text: t("labels.cartoonist") },
|
||||||
icon: <SloppinessArchitectIcon appearance={appState.appearance} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 1,
|
|
||||||
text: t("labels.artist"),
|
|
||||||
icon: <SloppinessArtistIcon appearance={appState.appearance} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 2,
|
|
||||||
text: t("labels.cartoonist"),
|
|
||||||
icon: <SloppinessCartoonistIcon appearance={appState.appearance} />,
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
@@ -332,58 +227,9 @@ export const actionChangeSloppiness = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeStrokeStyle = register({
|
|
||||||
name: "changeStrokeStyle",
|
|
||||||
perform: (elements, appState, value) => {
|
|
||||||
trackEvent(EVENT_CHANGE, "style", value);
|
|
||||||
return {
|
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
|
||||||
newElementWith(el, {
|
|
||||||
strokeStyle: value,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
appState: { ...appState, currentItemStrokeStyle: value },
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<fieldset>
|
|
||||||
<legend>{t("labels.strokeStyle")}</legend>
|
|
||||||
<ButtonIconSelect
|
|
||||||
group="strokeStyle"
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: "solid",
|
|
||||||
text: t("labels.strokeStyle_solid"),
|
|
||||||
icon: <StrokeStyleSolidIcon appearance={appState.appearance} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "dashed",
|
|
||||||
text: t("labels.strokeStyle_dashed"),
|
|
||||||
icon: <StrokeStyleDashedIcon appearance={appState.appearance} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "dotted",
|
|
||||||
text: t("labels.strokeStyle_dotted"),
|
|
||||||
icon: <StrokeStyleDottedIcon appearance={appState.appearance} />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={getFormValue(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(element) => element.strokeStyle,
|
|
||||||
appState.currentItemStrokeStyle,
|
|
||||||
)}
|
|
||||||
onChange={(value) => updateData(value)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionChangeOpacity = register({
|
export const actionChangeOpacity = register({
|
||||||
name: "changeOpacity",
|
name: "changeOpacity",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "opacity", "value", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -437,7 +283,7 @@ export const actionChangeFontSize = register({
|
|||||||
elements: changeProperty(elements, appState, (el) => {
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
if (isTextElement(el)) {
|
if (isTextElement(el)) {
|
||||||
const element: ExcalidrawTextElement = newElementWith(el, {
|
const element: ExcalidrawTextElement = newElementWith(el, {
|
||||||
fontSize: value,
|
font: `${value}px ${el.font.split("px ")[1]}`,
|
||||||
});
|
});
|
||||||
redrawTextBoundingBox(element);
|
redrawTextBoundingBox(element);
|
||||||
return element;
|
return element;
|
||||||
@@ -447,7 +293,9 @@ export const actionChangeFontSize = register({
|
|||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemFontSize: value,
|
currentItemFont: `${value}px ${
|
||||||
|
appState.currentItemFont.split("px ")[1]
|
||||||
|
}`,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -466,8 +314,8 @@ export const actionChangeFontSize = register({
|
|||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(element) => isTextElement(element) && element.fontSize,
|
(element) => isTextElement(element) && +element.font.split("px ")[0],
|
||||||
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
+(appState.currentItemFont || DEFAULT_FONT).split("px ")[0],
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
/>
|
/>
|
||||||
@@ -482,7 +330,7 @@ export const actionChangeFontFamily = register({
|
|||||||
elements: changeProperty(elements, appState, (el) => {
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
if (isTextElement(el)) {
|
if (isTextElement(el)) {
|
||||||
const element: ExcalidrawTextElement = newElementWith(el, {
|
const element: ExcalidrawTextElement = newElementWith(el, {
|
||||||
fontFamily: value,
|
font: `${el.font.split("px ")[0]}px ${value}`,
|
||||||
});
|
});
|
||||||
redrawTextBoundingBox(element);
|
redrawTextBoundingBox(element);
|
||||||
return element;
|
return element;
|
||||||
@@ -492,35 +340,33 @@ export const actionChangeFontFamily = register({
|
|||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemFontFamily: value,
|
currentItemFont: `${
|
||||||
|
appState.currentItemFont.split("px ")[0]
|
||||||
|
}px ${value}`,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
const options: { value: FontFamily; text: string }[] = [
|
<fieldset>
|
||||||
{ value: 1, text: t("labels.handDrawn") },
|
<legend>{t("labels.fontFamily")}</legend>
|
||||||
{ value: 2, text: t("labels.normal") },
|
<ButtonSelect
|
||||||
{ value: 3, text: t("labels.code") },
|
group="font-family"
|
||||||
];
|
options={[
|
||||||
|
{ value: "Virgil", text: t("labels.handDrawn") },
|
||||||
return (
|
{ value: "Helvetica", text: t("labels.normal") },
|
||||||
<fieldset>
|
{ value: "Cascadia", text: t("labels.code") },
|
||||||
<legend>{t("labels.fontFamily")}</legend>
|
]}
|
||||||
<ButtonSelect<FontFamily | false>
|
value={getFormValue(
|
||||||
group="font-family"
|
elements,
|
||||||
options={options}
|
appState,
|
||||||
value={getFormValue(
|
(element) => isTextElement(element) && element.font.split("px ")[1],
|
||||||
elements,
|
(appState.currentItemFont || DEFAULT_FONT).split("px ")[1],
|
||||||
appState,
|
)}
|
||||||
(element) => isTextElement(element) && element.fontFamily,
|
onChange={(value) => updateData(value)}
|
||||||
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
/>
|
||||||
)}
|
</fieldset>
|
||||||
onChange={(value) => updateData(value)}
|
),
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeTextAlign = register({
|
export const actionChangeTextAlign = register({
|
||||||
@@ -566,232 +412,3 @@ export const actionChangeTextAlign = register({
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeSharpness = register({
|
|
||||||
name: "changeSharpness",
|
|
||||||
perform: (elements, appState, value) => {
|
|
||||||
const targetElements = getTargetElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
const shouldUpdateForNonLinearElements = targetElements.length
|
|
||||||
? targetElements.every((el) => !isLinearElement(el))
|
|
||||||
: !isLinearElementType(appState.elementType);
|
|
||||||
const shouldUpdateForLinearElements = targetElements.length
|
|
||||||
? targetElements.every(isLinearElement)
|
|
||||||
: isLinearElementType(appState.elementType);
|
|
||||||
trackEvent(EVENT_CHANGE, "edge", value);
|
|
||||||
return {
|
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
|
||||||
newElementWith(el, {
|
|
||||||
strokeSharpness: value,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
currentItemStrokeSharpness: shouldUpdateForNonLinearElements
|
|
||||||
? value
|
|
||||||
: appState.currentItemStrokeSharpness,
|
|
||||||
currentItemLinearStrokeSharpness: shouldUpdateForLinearElements
|
|
||||||
? value
|
|
||||||
: appState.currentItemLinearStrokeSharpness,
|
|
||||||
},
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<fieldset>
|
|
||||||
<legend>{t("labels.edges")}</legend>
|
|
||||||
<ButtonIconSelect
|
|
||||||
group="edges"
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: "sharp",
|
|
||||||
text: t("labels.sharp"),
|
|
||||||
icon: <EdgeSharpIcon appearance={appState.appearance} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "round",
|
|
||||||
text: t("labels.round"),
|
|
||||||
icon: <EdgeRoundIcon appearance={appState.appearance} />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={getFormValue(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(element) => element.strokeSharpness,
|
|
||||||
(canChangeSharpness(appState.elementType) &&
|
|
||||||
(isLinearElementType(appState.elementType)
|
|
||||||
? appState.currentItemLinearStrokeSharpness
|
|
||||||
: appState.currentItemStrokeSharpness)) ||
|
|
||||||
null,
|
|
||||||
)}
|
|
||||||
onChange={(value) => updateData(value)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionChangeArrowhead = register({
|
|
||||||
name: "changeArrowhead",
|
|
||||||
perform: (
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
value: { position: "start" | "end"; type: Arrowhead },
|
|
||||||
) => {
|
|
||||||
return {
|
|
||||||
elements: changeProperty(elements, appState, (el) => {
|
|
||||||
if (isLinearElement(el)) {
|
|
||||||
trackEvent(
|
|
||||||
EVENT_CHANGE,
|
|
||||||
`arrowhead ${value.position}`,
|
|
||||||
value.type || "none",
|
|
||||||
);
|
|
||||||
|
|
||||||
const { position, type } = value;
|
|
||||||
|
|
||||||
if (position === "start") {
|
|
||||||
const element: ExcalidrawLinearElement = newElementWith(el, {
|
|
||||||
startArrowhead: type,
|
|
||||||
});
|
|
||||||
return element;
|
|
||||||
} else if (position === "end") {
|
|
||||||
const element: ExcalidrawLinearElement = newElementWith(el, {
|
|
||||||
endArrowhead: type,
|
|
||||||
});
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return el;
|
|
||||||
}),
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
[value.position === "start"
|
|
||||||
? "currentItemStartArrowhead"
|
|
||||||
: "currentItemEndArrowhead"]: value.type,
|
|
||||||
},
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
|
||||||
const isRTL = getLanguage().rtl;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<fieldset>
|
|
||||||
<legend>{t("labels.arrowheads")}</legend>
|
|
||||||
<div className="iconSelectList">
|
|
||||||
<IconPicker
|
|
||||||
label="arrowhead_start"
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: null,
|
|
||||||
text: t("labels.arrowhead_none"),
|
|
||||||
icon: <ArrowheadNoneIcon appearance={appState.appearance} />,
|
|
||||||
keyBinding: "q",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "arrow",
|
|
||||||
text: t("labels.arrowhead_arrow"),
|
|
||||||
icon: (
|
|
||||||
<ArrowheadArrowIcon
|
|
||||||
appearance={appState.appearance}
|
|
||||||
flip={!isRTL}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
keyBinding: "w",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "bar",
|
|
||||||
text: t("labels.arrowhead_bar"),
|
|
||||||
icon: (
|
|
||||||
<ArrowheadBarIcon
|
|
||||||
appearance={appState.appearance}
|
|
||||||
flip={!isRTL}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
keyBinding: "e",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "dot",
|
|
||||||
text: t("labels.arrowhead_dot"),
|
|
||||||
icon: (
|
|
||||||
<ArrowheadDotIcon
|
|
||||||
appearance={appState.appearance}
|
|
||||||
flip={!isRTL}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
keyBinding: "r",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={getFormValue<Arrowhead | null>(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(element) =>
|
|
||||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
|
||||||
? element.startArrowhead
|
|
||||||
: appState.currentItemStartArrowhead,
|
|
||||||
appState.currentItemStartArrowhead,
|
|
||||||
)}
|
|
||||||
onChange={(value) => updateData({ position: "start", type: value })}
|
|
||||||
/>
|
|
||||||
<IconPicker
|
|
||||||
label="arrowhead_end"
|
|
||||||
group="arrowheads"
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: null,
|
|
||||||
text: t("labels.arrowhead_none"),
|
|
||||||
keyBinding: "q",
|
|
||||||
icon: <ArrowheadNoneIcon appearance={appState.appearance} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "arrow",
|
|
||||||
text: t("labels.arrowhead_arrow"),
|
|
||||||
keyBinding: "w",
|
|
||||||
icon: (
|
|
||||||
<ArrowheadArrowIcon
|
|
||||||
appearance={appState.appearance}
|
|
||||||
flip={isRTL}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "bar",
|
|
||||||
text: t("labels.arrowhead_bar"),
|
|
||||||
keyBinding: "e",
|
|
||||||
icon: (
|
|
||||||
<ArrowheadBarIcon
|
|
||||||
appearance={appState.appearance}
|
|
||||||
flip={isRTL}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "dot",
|
|
||||||
text: t("labels.arrowhead_dot"),
|
|
||||||
keyBinding: "r",
|
|
||||||
icon: (
|
|
||||||
<ArrowheadDotIcon
|
|
||||||
appearance={appState.appearance}
|
|
||||||
flip={isRTL}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={getFormValue<Arrowhead | null>(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(element) =>
|
|
||||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
|
||||||
? element.endArrowhead
|
|
||||||
: appState.currentItemEndArrowhead,
|
|
||||||
appState.currentItemEndArrowhead,
|
|
||||||
)}
|
|
||||||
onChange={(value) => updateData({ position: "end", type: value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,31 +1,22 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { selectGroupsForSelectedElements } from "../groups";
|
|
||||||
import { getNonDeletedElements } from "../element";
|
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
if (appState.editingLinearElement) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
appState: selectGroupsForSelectedElements(
|
appState: {
|
||||||
{
|
...appState,
|
||||||
...appState,
|
selectedElementIds: elements.reduce((map, element) => {
|
||||||
editingGroupId: null,
|
if (!element.isDeleted) {
|
||||||
selectedElementIds: elements.reduce((map, element) => {
|
map[element.id] = true;
|
||||||
if (!element.isDeleted) {
|
}
|
||||||
map[element.id] = true;
|
return map;
|
||||||
}
|
}, {} as any),
|
||||||
return map;
|
},
|
||||||
}, {} as any),
|
|
||||||
},
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
),
|
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.selectAll",
|
contextItemLabel: "labels.selectAll",
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === "a",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,17 +3,12 @@ import {
|
|||||||
isExcalidrawElement,
|
isExcalidrawElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
import { DEFAULT_FONT, DEFAULT_TEXT_ALIGN } from "../appState";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import {
|
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
DEFAULT_FONT_FAMILY,
|
|
||||||
DEFAULT_TEXT_ALIGN,
|
|
||||||
} from "../constants";
|
|
||||||
|
|
||||||
// `copiedStyles` is exported only for tests.
|
let copiedStyles: string = "{}";
|
||||||
export let copiedStyles: string = "{}";
|
|
||||||
|
|
||||||
export const actionCopyStyles = register({
|
export const actionCopyStyles = register({
|
||||||
name: "copyStyles",
|
name: "copyStyles",
|
||||||
@@ -28,7 +23,7 @@ export const actionCopyStyles = register({
|
|||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyStyles",
|
contextItemLabel: "labels.copyStyles",
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "C",
|
||||||
contextMenuOrder: 0,
|
contextMenuOrder: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,15 +41,13 @@ export const actionPasteStyles = register({
|
|||||||
backgroundColor: pastedElement?.backgroundColor,
|
backgroundColor: pastedElement?.backgroundColor,
|
||||||
strokeWidth: pastedElement?.strokeWidth,
|
strokeWidth: pastedElement?.strokeWidth,
|
||||||
strokeColor: pastedElement?.strokeColor,
|
strokeColor: pastedElement?.strokeColor,
|
||||||
strokeStyle: pastedElement?.strokeStyle,
|
|
||||||
fillStyle: pastedElement?.fillStyle,
|
fillStyle: pastedElement?.fillStyle,
|
||||||
opacity: pastedElement?.opacity,
|
opacity: pastedElement?.opacity,
|
||||||
roughness: pastedElement?.roughness,
|
roughness: pastedElement?.roughness,
|
||||||
});
|
});
|
||||||
if (isTextElement(newElement)) {
|
if (isTextElement(newElement)) {
|
||||||
mutateElement(newElement, {
|
mutateElement(newElement, {
|
||||||
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
font: pastedElement?.font || DEFAULT_FONT,
|
||||||
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
|
||||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
});
|
});
|
||||||
redrawTextBoundingBox(newElement);
|
redrawTextBoundingBox(newElement);
|
||||||
@@ -68,6 +61,6 @@ export const actionPasteStyles = register({
|
|||||||
},
|
},
|
||||||
contextItemLabel: "labels.pasteStyles",
|
contextItemLabel: "labels.pasteStyles",
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "V",
|
||||||
contextMenuOrder: 1,
|
contextMenuOrder: 1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,22 +5,79 @@ import {
|
|||||||
moveAllLeft,
|
moveAllLeft,
|
||||||
moveAllRight,
|
moveAllRight,
|
||||||
} from "../zindex";
|
} from "../zindex";
|
||||||
import { KEYS, isDarwin, CODES } from "../keys";
|
import { KEYS, isDarwin } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import {
|
import {
|
||||||
SendBackwardIcon,
|
sendBackward,
|
||||||
BringToFrontIcon,
|
bringToFront,
|
||||||
SendToBackIcon,
|
sendToBack,
|
||||||
BringForwardIcon,
|
bringForward,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
|
||||||
|
function getElementIndices(
|
||||||
|
direction: "left" | "right",
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) {
|
||||||
|
const selectedIndices: number[] = [];
|
||||||
|
let deletedIndicesCache: number[] = [];
|
||||||
|
|
||||||
|
function cb(element: ExcalidrawElement, index: number) {
|
||||||
|
if (element.isDeleted) {
|
||||||
|
// we want to build an array of deleted elements that are preceeding
|
||||||
|
// a selected element so that we move them together
|
||||||
|
deletedIndicesCache.push(index);
|
||||||
|
} else {
|
||||||
|
if (appState.selectedElementIds[element.id]) {
|
||||||
|
selectedIndices.push(...deletedIndicesCache, index);
|
||||||
|
}
|
||||||
|
// always empty cache of deleted elements after either pushing a group
|
||||||
|
// of selected/deleted elements, of after encountering non-deleted elem
|
||||||
|
deletedIndicesCache = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sending back → select contiguous deleted elements that are to the left of
|
||||||
|
// selected element(s)
|
||||||
|
if (direction === "left") {
|
||||||
|
let i = -1;
|
||||||
|
const len = elements.length;
|
||||||
|
while (++i < len) {
|
||||||
|
cb(elements[i], i);
|
||||||
|
}
|
||||||
|
// moving to front → loop from right to left so that we don't need to
|
||||||
|
// backtrack when gathering deleted elements
|
||||||
|
} else {
|
||||||
|
let i = elements.length;
|
||||||
|
while (--i > -1) {
|
||||||
|
cb(elements[i], i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// sort in case we were gathering indexes from right to left
|
||||||
|
return selectedIndices.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveElements(
|
||||||
|
func: typeof moveOneLeft,
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) {
|
||||||
|
const _elements = elements.slice();
|
||||||
|
const direction =
|
||||||
|
func === moveOneLeft || func === moveAllLeft ? "left" : "right";
|
||||||
|
const indices = getElementIndices(direction, _elements, appState);
|
||||||
|
return func(_elements, indices);
|
||||||
|
}
|
||||||
|
|
||||||
export const actionSendBackward = register({
|
export const actionSendBackward = register({
|
||||||
name: "sendBackward",
|
name: "sendBackward",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveOneLeft(elements, appState),
|
elements: moveElements(moveOneLeft, elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -28,17 +85,15 @@ export const actionSendBackward = register({
|
|||||||
contextItemLabel: "labels.sendBackward",
|
contextItemLabel: "labels.sendBackward",
|
||||||
keyPriority: 40,
|
keyPriority: 40,
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] && !event.shiftKey && event.code === "BracketLeft",
|
||||||
!event.shiftKey &&
|
PanelComponent: ({ updateData }) => (
|
||||||
event.code === CODES.BRACKET_LEFT,
|
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="zIndexButton"
|
className="zIndexButton"
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`}
|
title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`}
|
||||||
>
|
>
|
||||||
<SendBackwardIcon appearance={appState.appearance} />
|
{sendBackward}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -47,7 +102,7 @@ export const actionBringForward = register({
|
|||||||
name: "bringForward",
|
name: "bringForward",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveOneRight(elements, appState),
|
elements: moveElements(moveOneRight, elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -55,17 +110,15 @@ export const actionBringForward = register({
|
|||||||
contextItemLabel: "labels.bringForward",
|
contextItemLabel: "labels.bringForward",
|
||||||
keyPriority: 40,
|
keyPriority: 40,
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] && !event.shiftKey && event.code === "BracketRight",
|
||||||
!event.shiftKey &&
|
PanelComponent: ({ updateData }) => (
|
||||||
event.code === CODES.BRACKET_RIGHT,
|
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="zIndexButton"
|
className="zIndexButton"
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`}
|
title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`}
|
||||||
>
|
>
|
||||||
<BringForwardIcon appearance={appState.appearance} />
|
{bringForward}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -74,21 +127,20 @@ export const actionSendToBack = register({
|
|||||||
name: "sendToBack",
|
name: "sendToBack",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveAllLeft(elements, appState),
|
elements: moveElements(moveAllLeft, elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.sendToBack",
|
contextItemLabel: "labels.sendToBack",
|
||||||
keyTest: (event) =>
|
keyTest: (event) => {
|
||||||
isDarwin
|
return isDarwin
|
||||||
? event[KEYS.CTRL_OR_CMD] &&
|
? event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === "BracketLeft"
|
||||||
event.altKey &&
|
|
||||||
event.code === CODES.BRACKET_LEFT
|
|
||||||
: event[KEYS.CTRL_OR_CMD] &&
|
: event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event.code === CODES.BRACKET_LEFT,
|
event.code === "BracketLeft";
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
},
|
||||||
|
PanelComponent: ({ updateData }) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="zIndexButton"
|
className="zIndexButton"
|
||||||
@@ -99,7 +151,7 @@ export const actionSendToBack = register({
|
|||||||
: getShortcutKey("CtrlOrCmd+Shift+[")
|
: getShortcutKey("CtrlOrCmd+Shift+[")
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<SendToBackIcon appearance={appState.appearance} />
|
{sendToBack}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -108,21 +160,20 @@ export const actionBringToFront = register({
|
|||||||
name: "bringToFront",
|
name: "bringToFront",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveAllRight(elements, appState),
|
elements: moveElements(moveAllRight, elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.bringToFront",
|
contextItemLabel: "labels.bringToFront",
|
||||||
keyTest: (event) =>
|
keyTest: (event) => {
|
||||||
isDarwin
|
return isDarwin
|
||||||
? event[KEYS.CTRL_OR_CMD] &&
|
? event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === "BracketRight"
|
||||||
event.altKey &&
|
|
||||||
event.code === CODES.BRACKET_RIGHT
|
|
||||||
: event[KEYS.CTRL_OR_CMD] &&
|
: event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event.code === CODES.BRACKET_RIGHT,
|
event.code === "BracketRight";
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
},
|
||||||
|
PanelComponent: ({ updateData }) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="zIndexButton"
|
className="zIndexButton"
|
||||||
@@ -133,7 +184,7 @@ export const actionBringToFront = register({
|
|||||||
: getShortcutKey("CtrlOrCmd+Shift+]")
|
: getShortcutKey("CtrlOrCmd+Shift+]")
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<BringToFrontIcon appearance={appState.appearance} />
|
{bringToFront}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export {
|
|||||||
actionChangeProjectName,
|
actionChangeProjectName,
|
||||||
actionChangeExportBackground,
|
actionChangeExportBackground,
|
||||||
actionSaveScene,
|
actionSaveScene,
|
||||||
actionSaveAsScene,
|
|
||||||
actionLoadScene,
|
actionLoadScene,
|
||||||
} from "./actionExport";
|
} from "./actionExport";
|
||||||
|
|
||||||
@@ -45,23 +44,3 @@ export {
|
|||||||
actionFullScreen,
|
actionFullScreen,
|
||||||
actionShortcuts,
|
actionShortcuts,
|
||||||
} from "./actionMenu";
|
} from "./actionMenu";
|
||||||
|
|
||||||
export { actionGroup, actionUngroup } from "./actionGroup";
|
|
||||||
|
|
||||||
export { actionGoToCollaborator } from "./actionNavigate";
|
|
||||||
|
|
||||||
export { actionAddToLibrary } from "./actionAddToLibrary";
|
|
||||||
|
|
||||||
export {
|
|
||||||
actionAlignTop,
|
|
||||||
actionAlignBottom,
|
|
||||||
actionAlignLeft,
|
|
||||||
actionAlignRight,
|
|
||||||
actionAlignVerticallyCentered,
|
|
||||||
actionAlignHorizontallyCentered,
|
|
||||||
} from "./actionAlign";
|
|
||||||
|
|
||||||
export {
|
|
||||||
distributeHorizontally,
|
|
||||||
distributeVertically,
|
|
||||||
} from "./actionDistribute";
|
|
||||||
|
|||||||
+8
-30
@@ -5,36 +5,29 @@ import {
|
|||||||
UpdaterFn,
|
UpdaterFn,
|
||||||
ActionFilterFn,
|
ActionFilterFn,
|
||||||
ActionName,
|
ActionName,
|
||||||
ActionResult,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { ShortcutName } from "./shortcuts";
|
import { globalSceneState } from "../scene";
|
||||||
|
|
||||||
export class ActionManager implements ActionsManagerInterface {
|
export class ActionManager implements ActionsManagerInterface {
|
||||||
actions = {} as ActionsManagerInterface["actions"];
|
actions = {} as ActionsManagerInterface["actions"];
|
||||||
|
|
||||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
updater: UpdaterFn;
|
||||||
|
|
||||||
getAppState: () => Readonly<AppState>;
|
getAppState: () => AppState;
|
||||||
|
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
updater: UpdaterFn,
|
updater: UpdaterFn,
|
||||||
getAppState: () => AppState,
|
getAppState: () => AppState,
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
getElementsIncludingDeleted: () => ReturnType<
|
||||||
|
typeof globalSceneState["getElementsIncludingDeleted"]
|
||||||
|
>,
|
||||||
) {
|
) {
|
||||||
this.updater = (actionResult) => {
|
this.updater = updater;
|
||||||
if (actionResult && "then" in actionResult) {
|
|
||||||
actionResult.then((actionResult) => {
|
|
||||||
return updater(actionResult);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return updater(actionResult);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.getAppState = getAppState;
|
this.getAppState = getAppState;
|
||||||
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
|
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
|
||||||
}
|
}
|
||||||
@@ -89,22 +82,12 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
return Object.values(this.actions)
|
return Object.values(this.actions)
|
||||||
.filter(actionFilter)
|
.filter(actionFilter)
|
||||||
.filter((action) => "contextItemLabel" in action)
|
.filter((action) => "contextItemLabel" in action)
|
||||||
.filter((action) =>
|
|
||||||
action.contextItemPredicate
|
|
||||||
? action.contextItemPredicate(
|
|
||||||
this.getElementsIncludingDeleted(),
|
|
||||||
this.getAppState(),
|
|
||||||
)
|
|
||||||
: true,
|
|
||||||
)
|
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
|
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
|
||||||
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
|
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
|
||||||
)
|
)
|
||||||
.map((action) => ({
|
.map((action) => ({
|
||||||
// take last bit of the label "labels.<shortcutName>"
|
|
||||||
shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName,
|
|
||||||
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
|
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
|
||||||
action: () => {
|
action: () => {
|
||||||
this.updater(
|
this.updater(
|
||||||
@@ -118,11 +101,7 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Id is an attribute that we can use to pass in data like keys.
|
renderAction = (name: ActionName) => {
|
||||||
// This is needed for dynamically generated action components
|
|
||||||
// like the user list. We can use this key to extract more
|
|
||||||
// data from app state. This is an alternative to generic prop hell!
|
|
||||||
renderAction = (name: ActionName, id?: string) => {
|
|
||||||
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
|
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
|
||||||
const action = this.actions[name];
|
const action = this.actions[name];
|
||||||
const PanelComponent = action.PanelComponent!;
|
const PanelComponent = action.PanelComponent!;
|
||||||
@@ -141,7 +120,6 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
elements={this.getElementsIncludingDeleted()}
|
elements={this.getElementsIncludingDeleted()}
|
||||||
appState={this.getAppState()}
|
appState={this.getAppState()}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
id={id}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Action } from "./types";
|
|||||||
|
|
||||||
export let actions: readonly Action[] = [];
|
export let actions: readonly Action[] = [];
|
||||||
|
|
||||||
export const register = (action: Action): Action => {
|
export function register(action: Action): Action {
|
||||||
actions = actions.concat(action);
|
actions = actions.concat(action);
|
||||||
return action;
|
return action;
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import { t } from "../i18n";
|
|
||||||
import { isDarwin } from "../keys";
|
|
||||||
import { getShortcutKey } from "../utils";
|
|
||||||
|
|
||||||
export type ShortcutName =
|
|
||||||
| "cut"
|
|
||||||
| "copy"
|
|
||||||
| "paste"
|
|
||||||
| "copyStyles"
|
|
||||||
| "pasteStyles"
|
|
||||||
| "selectAll"
|
|
||||||
| "delete"
|
|
||||||
| "duplicateSelection"
|
|
||||||
| "sendBackward"
|
|
||||||
| "bringForward"
|
|
||||||
| "sendToBack"
|
|
||||||
| "bringToFront"
|
|
||||||
| "copyAsPng"
|
|
||||||
| "copyAsSvg"
|
|
||||||
| "group"
|
|
||||||
| "ungroup"
|
|
||||||
| "gridMode"
|
|
||||||
| "stats"
|
|
||||||
| "addToLibrary";
|
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
|
||||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
|
||||||
copy: [getShortcutKey("CtrlOrCmd+C")],
|
|
||||||
paste: [getShortcutKey("CtrlOrCmd+V")],
|
|
||||||
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
|
|
||||||
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
|
|
||||||
selectAll: [getShortcutKey("CtrlOrCmd+A")],
|
|
||||||
delete: [getShortcutKey("Del")],
|
|
||||||
duplicateSelection: [
|
|
||||||
getShortcutKey("CtrlOrCmd+D"),
|
|
||||||
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
|
|
||||||
],
|
|
||||||
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
|
|
||||||
bringForward: [getShortcutKey("CtrlOrCmd+]")],
|
|
||||||
sendToBack: [
|
|
||||||
isDarwin
|
|
||||||
? getShortcutKey("CtrlOrCmd+Alt+[")
|
|
||||||
: getShortcutKey("CtrlOrCmd+Shift+["),
|
|
||||||
],
|
|
||||||
bringToFront: [
|
|
||||||
isDarwin
|
|
||||||
? getShortcutKey("CtrlOrCmd+Alt+]")
|
|
||||||
: getShortcutKey("CtrlOrCmd+Shift+]"),
|
|
||||||
],
|
|
||||||
copyAsPng: [getShortcutKey("Shift+Alt+C")],
|
|
||||||
copyAsSvg: [],
|
|
||||||
group: [getShortcutKey("CtrlOrCmd+G")],
|
|
||||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
|
||||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
|
||||||
stats: [],
|
|
||||||
addToLibrary: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
|
||||||
const shortcuts = shortcutMap[name];
|
|
||||||
// if multiple shortcuts availiable, take the first one
|
|
||||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
|
||||||
};
|
|
||||||
+12
-37
@@ -2,23 +2,19 @@ import React from "react";
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
|
||||||
/** if false, the action should be prevented */
|
export type ActionResult = {
|
||||||
export type ActionResult =
|
elements?: readonly ExcalidrawElement[] | null;
|
||||||
| {
|
appState?: AppState | null;
|
||||||
elements?: readonly ExcalidrawElement[] | null;
|
commitToHistory: boolean;
|
||||||
appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
|
};
|
||||||
commitToHistory: boolean;
|
|
||||||
syncHistory?: boolean;
|
|
||||||
}
|
|
||||||
| false;
|
|
||||||
|
|
||||||
type ActionFn = (
|
type ActionFn = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: Readonly<AppState>,
|
appState: AppState,
|
||||||
formData: any,
|
formData: any,
|
||||||
) => ActionResult | Promise<ActionResult>;
|
) => ActionResult;
|
||||||
|
|
||||||
export type UpdaterFn = (res: ActionResult) => void;
|
export type UpdaterFn = (res: ActionResult, commitToHistory?: boolean) => void;
|
||||||
export type ActionFilterFn = (action: Action) => void;
|
export type ActionFilterFn = (action: Action) => void;
|
||||||
|
|
||||||
export type ActionName =
|
export type ActionName =
|
||||||
@@ -34,8 +30,6 @@ export type ActionName =
|
|||||||
| "changeFillStyle"
|
| "changeFillStyle"
|
||||||
| "changeStrokeWidth"
|
| "changeStrokeWidth"
|
||||||
| "changeSloppiness"
|
| "changeSloppiness"
|
||||||
| "changeStrokeStyle"
|
|
||||||
| "changeArrowhead"
|
|
||||||
| "changeOpacity"
|
| "changeOpacity"
|
||||||
| "changeFontSize"
|
| "changeFontSize"
|
||||||
| "toggleCanvasMenu"
|
| "toggleCanvasMenu"
|
||||||
@@ -45,10 +39,8 @@ export type ActionName =
|
|||||||
| "finalize"
|
| "finalize"
|
||||||
| "changeProjectName"
|
| "changeProjectName"
|
||||||
| "changeExportBackground"
|
| "changeExportBackground"
|
||||||
| "changeExportEmbedScene"
|
|
||||||
| "changeShouldAddWatermark"
|
| "changeShouldAddWatermark"
|
||||||
| "saveScene"
|
| "saveScene"
|
||||||
| "saveAsScene"
|
|
||||||
| "loadScene"
|
| "loadScene"
|
||||||
| "duplicateSelection"
|
| "duplicateSelection"
|
||||||
| "deleteSelectedElements"
|
| "deleteSelectedElements"
|
||||||
@@ -58,24 +50,10 @@ export type ActionName =
|
|||||||
| "zoomOut"
|
| "zoomOut"
|
||||||
| "resetZoom"
|
| "resetZoom"
|
||||||
| "zoomToFit"
|
| "zoomToFit"
|
||||||
| "zoomToSelection"
|
|
||||||
| "changeFontFamily"
|
| "changeFontFamily"
|
||||||
| "changeTextAlign"
|
| "changeTextAlign"
|
||||||
| "toggleFullScreen"
|
| "toggleFullScreen"
|
||||||
| "toggleShortcuts"
|
| "toggleShortcuts";
|
||||||
| "group"
|
|
||||||
| "ungroup"
|
|
||||||
| "goToCollaborator"
|
|
||||||
| "addToLibrary"
|
|
||||||
| "changeSharpness"
|
|
||||||
| "alignTop"
|
|
||||||
| "alignBottom"
|
|
||||||
| "alignLeft"
|
|
||||||
| "alignRight"
|
|
||||||
| "alignVerticallyCentered"
|
|
||||||
| "alignHorizontallyCentered"
|
|
||||||
| "distributeHorizontally"
|
|
||||||
| "distributeVertically";
|
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: ActionName;
|
name: ActionName;
|
||||||
@@ -83,7 +61,6 @@ export interface Action {
|
|||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
id?: string;
|
|
||||||
}>;
|
}>;
|
||||||
perform: ActionFn;
|
perform: ActionFn;
|
||||||
keyPriority?: number;
|
keyPriority?: number;
|
||||||
@@ -94,14 +71,12 @@ export interface Action {
|
|||||||
) => boolean;
|
) => boolean;
|
||||||
contextItemLabel?: string;
|
contextItemLabel?: string;
|
||||||
contextMenuOrder?: number;
|
contextMenuOrder?: number;
|
||||||
contextItemPredicate?: (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
) => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionsManagerInterface {
|
export interface ActionsManagerInterface {
|
||||||
actions: Record<ActionName, Action>;
|
actions: {
|
||||||
|
[actionName in ActionName]: Action;
|
||||||
|
};
|
||||||
registerAction: (action: Action) => void;
|
registerAction: (action: Action) => void;
|
||||||
handleKeyDown: (event: KeyboardEvent) => boolean;
|
handleKeyDown: (event: KeyboardEvent) => boolean;
|
||||||
getContextMenuItems: (
|
getContextMenuItems: (
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import { ExcalidrawElement } from "./element/types";
|
|
||||||
import { newElementWith } from "./element/mutateElement";
|
|
||||||
import { getCommonBounds } from "./element";
|
|
||||||
|
|
||||||
interface Box {
|
|
||||||
minX: number;
|
|
||||||
minY: number;
|
|
||||||
maxX: number;
|
|
||||||
maxY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Alignment {
|
|
||||||
position: "start" | "center" | "end";
|
|
||||||
axis: "x" | "y";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const alignElements = (
|
|
||||||
selectedElements: ExcalidrawElement[],
|
|
||||||
alignment: Alignment,
|
|
||||||
): ExcalidrawElement[] => {
|
|
||||||
const groups: ExcalidrawElement[][] = getMaximumGroups(selectedElements);
|
|
||||||
|
|
||||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
|
||||||
|
|
||||||
return groups.flatMap((group) => {
|
|
||||||
const translation = calculateTranslation(
|
|
||||||
group,
|
|
||||||
selectionBoundingBox,
|
|
||||||
alignment,
|
|
||||||
);
|
|
||||||
return group.map((element) =>
|
|
||||||
newElementWith(element, {
|
|
||||||
x: element.x + translation.x,
|
|
||||||
y: element.y + translation.y,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMaximumGroups = (
|
|
||||||
elements: ExcalidrawElement[],
|
|
||||||
): ExcalidrawElement[][] => {
|
|
||||||
const groups: Map<String, ExcalidrawElement[]> = new Map<
|
|
||||||
String,
|
|
||||||
ExcalidrawElement[]
|
|
||||||
>();
|
|
||||||
|
|
||||||
elements.forEach((element: ExcalidrawElement) => {
|
|
||||||
const groupId =
|
|
||||||
element.groupIds.length === 0
|
|
||||||
? element.id
|
|
||||||
: element.groupIds[element.groupIds.length - 1];
|
|
||||||
|
|
||||||
const currentGroupMembers = groups.get(groupId) || [];
|
|
||||||
|
|
||||||
groups.set(groupId, [...currentGroupMembers, element]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(groups.values());
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateTranslation = (
|
|
||||||
group: ExcalidrawElement[],
|
|
||||||
selectionBoundingBox: Box,
|
|
||||||
{ axis, position }: Alignment,
|
|
||||||
): { x: number; y: number } => {
|
|
||||||
const groupBoundingBox = getCommonBoundingBox(group);
|
|
||||||
|
|
||||||
const [min, max]: ["minX" | "minY", "maxX" | "maxY"] =
|
|
||||||
axis === "x" ? ["minX", "maxX"] : ["minY", "maxY"];
|
|
||||||
|
|
||||||
const noTranslation = { x: 0, y: 0 };
|
|
||||||
if (position === "start") {
|
|
||||||
return {
|
|
||||||
...noTranslation,
|
|
||||||
[axis]: selectionBoundingBox[min] - groupBoundingBox[min],
|
|
||||||
};
|
|
||||||
} else if (position === "end") {
|
|
||||||
return {
|
|
||||||
...noTranslation,
|
|
||||||
[axis]: selectionBoundingBox[max] - groupBoundingBox[max],
|
|
||||||
};
|
|
||||||
} // else if (position === "center") {
|
|
||||||
return {
|
|
||||||
...noTranslation,
|
|
||||||
[axis]:
|
|
||||||
(selectionBoundingBox[min] + selectionBoundingBox[max]) / 2 -
|
|
||||||
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
|
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
|
||||||
return { minX, minY, maxX, maxY };
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
export const EVENT_ACTION = "action";
|
|
||||||
export const EVENT_ALIGN = "align";
|
|
||||||
export const EVENT_CHANGE = "change";
|
|
||||||
export const EVENT_DIALOG = "dialog";
|
|
||||||
export const EVENT_EXIT = "exit";
|
|
||||||
export const EVENT_IO = "io";
|
|
||||||
export const EVENT_LAYER = "layer";
|
|
||||||
export const EVENT_LIBRARY = "library";
|
|
||||||
export const EVENT_LOAD = "load";
|
|
||||||
export const EVENT_SHAPE = "shape";
|
|
||||||
export const EVENT_SHARE = "share";
|
|
||||||
export const EVENT_MAGIC = "magic";
|
|
||||||
|
|
||||||
export const trackEvent =
|
|
||||||
typeof window !== "undefined" && window.gtag
|
|
||||||
? (category: string, name: string, label?: string, value?: number) => {
|
|
||||||
window.gtag("event", name, {
|
|
||||||
event_category: category,
|
|
||||||
event_label: label,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
: typeof process !== "undefined" && process?.env?.JEST_WORKER_ID
|
|
||||||
? (category: string, name: string, label?: string, value?: number) => {}
|
|
||||||
: (category: string, name: string, label?: string, value?: number) => {
|
|
||||||
console.info("Track Event", category, name, label, value);
|
|
||||||
};
|
|
||||||
+80
-172
@@ -1,188 +1,96 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import {
|
import { AppState, FlooredNumber } from "./types";
|
||||||
DEFAULT_FONT_FAMILY,
|
import { getDateTime } from "./utils";
|
||||||
DEFAULT_FONT_SIZE,
|
import { t } from "./i18n";
|
||||||
SCENE_NAME_FALLBACK,
|
|
||||||
DEFAULT_TEXT_ALIGN,
|
|
||||||
} from "./constants";
|
|
||||||
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
|
|
||||||
|
|
||||||
type DefaultAppState = Omit<AppState, "offsetTop" | "offsetLeft" | "name"> & {
|
export const DEFAULT_FONT = "20px Virgil";
|
||||||
/**
|
export const DEFAULT_TEXT_ALIGN = "left";
|
||||||
* You should override this with current appState.name, or whatever is
|
|
||||||
* applicable at a given place where you get default appState.
|
|
||||||
*/
|
|
||||||
name: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDefaultAppState = (): DefaultAppState => {
|
export function getDefaultAppState(): AppState {
|
||||||
return {
|
return {
|
||||||
appearance: "light",
|
|
||||||
collaborators: new Map(),
|
|
||||||
currentChartType: "bar",
|
|
||||||
currentItemBackgroundColor: "transparent",
|
|
||||||
currentItemEndArrowhead: "arrow",
|
|
||||||
currentItemFillStyle: "hachure",
|
|
||||||
currentItemFontFamily: DEFAULT_FONT_FAMILY,
|
|
||||||
currentItemFontSize: DEFAULT_FONT_SIZE,
|
|
||||||
currentItemLinearStrokeSharpness: "round",
|
|
||||||
currentItemOpacity: 100,
|
|
||||||
currentItemRoughness: 1,
|
|
||||||
currentItemStartArrowhead: null,
|
|
||||||
currentItemStrokeColor: oc.black,
|
|
||||||
currentItemStrokeSharpness: "sharp",
|
|
||||||
currentItemStrokeStyle: "solid",
|
|
||||||
currentItemStrokeWidth: 1,
|
|
||||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
|
||||||
cursorButton: "up",
|
|
||||||
draggingElement: null,
|
|
||||||
editingElement: null,
|
|
||||||
editingGroupId: null,
|
|
||||||
editingLinearElement: null,
|
|
||||||
elementLocked: false,
|
|
||||||
elementType: "selection",
|
|
||||||
errorMessage: null,
|
|
||||||
exportBackground: true,
|
|
||||||
exportEmbedScene: false,
|
|
||||||
fileHandle: null,
|
|
||||||
gridSize: null,
|
|
||||||
height: window.innerHeight,
|
|
||||||
isBindingEnabled: true,
|
|
||||||
isLibraryOpen: false,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isResizing: false,
|
errorMessage: null,
|
||||||
isRotating: false,
|
draggingElement: null,
|
||||||
lastPointerDownWith: "mouse",
|
|
||||||
multiElement: null,
|
|
||||||
// for safety (because TS mostly doesn't distinguish optional types and
|
|
||||||
// undefined values), we set `name` to the fallback name, but we cast it to
|
|
||||||
// `undefined` so that TS forces us to explicitly specify it wherever
|
|
||||||
// possible
|
|
||||||
name: (SCENE_NAME_FALLBACK as unknown) as undefined,
|
|
||||||
openMenu: null,
|
|
||||||
pasteDialog: { shown: false, data: null },
|
|
||||||
previousSelectedElementIds: {},
|
|
||||||
resizingElement: null,
|
resizingElement: null,
|
||||||
scrolledOutside: false,
|
multiElement: null,
|
||||||
|
editingElement: null,
|
||||||
|
elementType: "selection",
|
||||||
|
elementLocked: false,
|
||||||
|
exportBackground: true,
|
||||||
|
shouldAddWatermark: false,
|
||||||
|
currentItemStrokeColor: oc.black,
|
||||||
|
currentItemBackgroundColor: "transparent",
|
||||||
|
currentItemFillStyle: "hachure",
|
||||||
|
currentItemStrokeWidth: 1,
|
||||||
|
currentItemRoughness: 1,
|
||||||
|
currentItemOpacity: 100,
|
||||||
|
currentItemFont: DEFAULT_FONT,
|
||||||
|
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||||
|
viewBackgroundColor: oc.white,
|
||||||
scrollX: 0 as FlooredNumber,
|
scrollX: 0 as FlooredNumber,
|
||||||
scrollY: 0 as FlooredNumber,
|
scrollY: 0 as FlooredNumber,
|
||||||
selectedElementIds: {},
|
cursorX: 0,
|
||||||
selectedGroupIds: {},
|
cursorY: 0,
|
||||||
|
cursorButton: "up",
|
||||||
|
scrolledOutside: false,
|
||||||
|
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||||
|
username: "",
|
||||||
|
isCollaborating: false,
|
||||||
|
isResizing: false,
|
||||||
|
isRotating: false,
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
shouldAddWatermark: false,
|
zoom: 1,
|
||||||
|
openMenu: null,
|
||||||
|
lastPointerDownWith: "mouse",
|
||||||
|
selectedElementIds: {},
|
||||||
|
collaborators: new Map(),
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
showShortcutsDialog: false,
|
showShortcutsDialog: false,
|
||||||
showStats: false,
|
|
||||||
startBoundElement: null,
|
|
||||||
suggestedBindings: [],
|
|
||||||
viewBackgroundColor: oc.white,
|
|
||||||
width: window.innerWidth,
|
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
export function clearAppStateForLocalStorage(appState: AppState) {
|
||||||
* Config containing all AppState keys. Used to determine whether given state
|
const {
|
||||||
* prop should be stripped when exporting to given storage type.
|
draggingElement,
|
||||||
*/
|
resizingElement,
|
||||||
const APP_STATE_STORAGE_CONF = (<
|
multiElement,
|
||||||
Values extends {
|
editingElement,
|
||||||
/** whether to keep when storing to browser storage (localStorage/IDB) */
|
selectionElement,
|
||||||
browser: boolean;
|
isResizing,
|
||||||
/** whether to keep when exporting to file/database */
|
isRotating,
|
||||||
export: boolean;
|
collaborators,
|
||||||
},
|
isCollaborating,
|
||||||
T extends Record<keyof AppState, Values>
|
isLoading,
|
||||||
>(
|
errorMessage,
|
||||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
showShortcutsDialog,
|
||||||
) => config)({
|
...exportedState
|
||||||
appearance: { browser: true, export: false },
|
} = appState;
|
||||||
collaborators: { browser: false, export: false },
|
return exportedState;
|
||||||
currentChartType: { browser: true, export: false },
|
}
|
||||||
currentItemBackgroundColor: { browser: true, export: false },
|
|
||||||
currentItemEndArrowhead: { browser: true, export: false },
|
|
||||||
currentItemFillStyle: { browser: true, export: false },
|
|
||||||
currentItemFontFamily: { browser: true, export: false },
|
|
||||||
currentItemFontSize: { browser: true, export: false },
|
|
||||||
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
|
||||||
currentItemOpacity: { browser: true, export: false },
|
|
||||||
currentItemRoughness: { browser: true, export: false },
|
|
||||||
currentItemStartArrowhead: { browser: true, export: false },
|
|
||||||
currentItemStrokeColor: { browser: true, export: false },
|
|
||||||
currentItemStrokeSharpness: { browser: true, export: false },
|
|
||||||
currentItemStrokeStyle: { browser: true, export: false },
|
|
||||||
currentItemStrokeWidth: { browser: true, export: false },
|
|
||||||
currentItemTextAlign: { browser: true, export: false },
|
|
||||||
cursorButton: { browser: true, export: false },
|
|
||||||
draggingElement: { browser: false, export: false },
|
|
||||||
editingElement: { browser: false, export: false },
|
|
||||||
editingGroupId: { browser: true, export: false },
|
|
||||||
editingLinearElement: { browser: false, export: false },
|
|
||||||
elementLocked: { browser: true, export: false },
|
|
||||||
elementType: { browser: true, export: false },
|
|
||||||
errorMessage: { browser: false, export: false },
|
|
||||||
exportBackground: { browser: true, export: false },
|
|
||||||
exportEmbedScene: { browser: true, export: false },
|
|
||||||
fileHandle: { browser: false, export: false },
|
|
||||||
gridSize: { browser: true, export: true },
|
|
||||||
height: { browser: false, export: false },
|
|
||||||
isBindingEnabled: { browser: false, export: false },
|
|
||||||
isLibraryOpen: { browser: false, export: false },
|
|
||||||
isLoading: { browser: false, export: false },
|
|
||||||
isResizing: { browser: false, export: false },
|
|
||||||
isRotating: { browser: false, export: false },
|
|
||||||
lastPointerDownWith: { browser: true, export: false },
|
|
||||||
multiElement: { browser: false, export: false },
|
|
||||||
name: { browser: true, export: false },
|
|
||||||
offsetLeft: { browser: false, export: false },
|
|
||||||
offsetTop: { browser: false, export: false },
|
|
||||||
openMenu: { browser: true, export: false },
|
|
||||||
pasteDialog: { browser: false, export: false },
|
|
||||||
previousSelectedElementIds: { browser: true, export: false },
|
|
||||||
resizingElement: { browser: false, export: false },
|
|
||||||
scrolledOutside: { browser: true, export: false },
|
|
||||||
scrollX: { browser: true, export: false },
|
|
||||||
scrollY: { browser: true, export: false },
|
|
||||||
selectedElementIds: { browser: true, export: false },
|
|
||||||
selectedGroupIds: { browser: true, export: false },
|
|
||||||
selectionElement: { browser: false, export: false },
|
|
||||||
shouldAddWatermark: { browser: true, export: false },
|
|
||||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
|
||||||
showShortcutsDialog: { browser: false, export: false },
|
|
||||||
showStats: { browser: true, export: false },
|
|
||||||
startBoundElement: { browser: false, export: false },
|
|
||||||
suggestedBindings: { browser: false, export: false },
|
|
||||||
viewBackgroundColor: { browser: true, export: true },
|
|
||||||
width: { browser: false, export: false },
|
|
||||||
zenModeEnabled: { browser: true, export: false },
|
|
||||||
zoom: { browser: true, export: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
export function clearAppStatePropertiesForHistory(
|
||||||
appState: Partial<AppState>,
|
appState: AppState,
|
||||||
exportType: ExportType,
|
): Partial<AppState> {
|
||||||
) => {
|
return {
|
||||||
type ExportableKeys = {
|
selectedElementIds: appState.selectedElementIds,
|
||||||
[K in keyof typeof APP_STATE_STORAGE_CONF]: typeof APP_STATE_STORAGE_CONF[K][ExportType] extends true
|
exportBackground: appState.exportBackground,
|
||||||
? K
|
shouldAddWatermark: appState.shouldAddWatermark,
|
||||||
: never;
|
currentItemStrokeColor: appState.currentItemStrokeColor,
|
||||||
}[keyof typeof APP_STATE_STORAGE_CONF];
|
currentItemBackgroundColor: appState.currentItemBackgroundColor,
|
||||||
const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] };
|
currentItemFillStyle: appState.currentItemFillStyle,
|
||||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
currentItemStrokeWidth: appState.currentItemStrokeWidth,
|
||||||
const propConfig = APP_STATE_STORAGE_CONF[key];
|
currentItemRoughness: appState.currentItemRoughness,
|
||||||
if (propConfig?.[exportType]) {
|
currentItemOpacity: appState.currentItemOpacity,
|
||||||
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
currentItemFont: appState.currentItemFont,
|
||||||
stateForExport[key] = appState[key];
|
currentItemTextAlign: appState.currentItemTextAlign,
|
||||||
}
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
}
|
name: appState.name,
|
||||||
return stateForExport;
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
|
export function cleanAppStateForExport(appState: AppState) {
|
||||||
return _clearAppStateForStorage(appState, "browser");
|
return {
|
||||||
};
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
|
};
|
||||||
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
}
|
||||||
return _clearAppStateForStorage(appState, "export");
|
|
||||||
};
|
|
||||||
|
|||||||
-481
@@ -1,481 +0,0 @@
|
|||||||
import { EVENT_MAGIC, trackEvent } from "./analytics";
|
|
||||||
import colors from "./colors";
|
|
||||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
|
|
||||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
|
||||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
|
||||||
import { randomId } from "./random";
|
|
||||||
|
|
||||||
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
|
||||||
|
|
||||||
const BAR_WIDTH = 32;
|
|
||||||
const BAR_GAP = 12;
|
|
||||||
const BAR_HEIGHT = 256;
|
|
||||||
const GRID_OPACITY = 50;
|
|
||||||
|
|
||||||
export interface Spreadsheet {
|
|
||||||
title: string | null;
|
|
||||||
labels: string[] | null;
|
|
||||||
values: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
|
|
||||||
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
|
|
||||||
|
|
||||||
type ParseSpreadsheetResult =
|
|
||||||
| { type: typeof NOT_SPREADSHEET; reason: string }
|
|
||||||
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
|
||||||
|
|
||||||
const tryParseNumber = (s: string): number | null => {
|
|
||||||
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
|
|
||||||
if (!match) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parseFloat(match[1].replace(/,/g, ""));
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
|
||||||
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
|
||||||
|
|
||||||
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|
||||||
const numCols = cells[0].length;
|
|
||||||
|
|
||||||
if (numCols > 2) {
|
|
||||||
return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (numCols === 1) {
|
|
||||||
if (!isNumericColumn(cells, 0)) {
|
|
||||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasHeader = tryParseNumber(cells[0][0]) === null;
|
|
||||||
const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
|
|
||||||
tryParseNumber(line[0]),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (values.length < 2) {
|
|
||||||
return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: VALID_SPREADSHEET,
|
|
||||||
spreadsheet: {
|
|
||||||
title: hasHeader ? cells[0][0] : null,
|
|
||||||
labels: null,
|
|
||||||
values: values as number[],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
|
||||||
|
|
||||||
if (!isNumericColumn(cells, valueColumnIndex)) {
|
|
||||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
|
||||||
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
|
|
||||||
const rows = hasHeader ? cells.slice(1) : cells;
|
|
||||||
|
|
||||||
if (rows.length < 2) {
|
|
||||||
return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: VALID_SPREADSHEET,
|
|
||||||
spreadsheet: {
|
|
||||||
title: hasHeader ? cells[0][valueColumnIndex] : null,
|
|
||||||
labels: rows.map((row) => row[labelColumnIndex]),
|
|
||||||
values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const transposeCells = (cells: string[][]) => {
|
|
||||||
const nextCells: string[][] = [];
|
|
||||||
for (let col = 0; col < cells[0].length; col++) {
|
|
||||||
const nextCellRow: string[] = [];
|
|
||||||
for (let row = 0; row < cells.length; row++) {
|
|
||||||
nextCellRow.push(cells[row][col]);
|
|
||||||
}
|
|
||||||
nextCells.push(nextCellRow);
|
|
||||||
}
|
|
||||||
return nextCells;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
|
||||||
// Copy/paste from excel, spreadhseets, tsv, csv.
|
|
||||||
// For now we only accept 2 columns with an optional header
|
|
||||||
|
|
||||||
// Check for tab separated values
|
|
||||||
let lines = text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim().split("\t"));
|
|
||||||
|
|
||||||
// Check for comma separated files
|
|
||||||
if (lines.length && lines[0].length !== 2) {
|
|
||||||
lines = text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim().split(","));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lines.length === 0) {
|
|
||||||
return { type: NOT_SPREADSHEET, reason: "No values" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const numColsFirstLine = lines[0].length;
|
|
||||||
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
|
|
||||||
|
|
||||||
if (!isSpreadsheet) {
|
|
||||||
return {
|
|
||||||
type: NOT_SPREADSHEET,
|
|
||||||
reason: "All rows don't have same number of columns",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = tryParseCells(lines);
|
|
||||||
if (result.type !== VALID_SPREADSHEET) {
|
|
||||||
const transposedResults = tryParseCells(transposeCells(lines));
|
|
||||||
if (transposedResults.type === VALID_SPREADSHEET) {
|
|
||||||
return transposedResults;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const bgColors = colors.elementBackground.slice(
|
|
||||||
2,
|
|
||||||
colors.elementBackground.length,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Put all the common properties here so when the whole chart is selected
|
|
||||||
// the properties dialog shows the correct selected values
|
|
||||||
const commonProps = {
|
|
||||||
fillStyle: "hachure",
|
|
||||||
fontFamily: DEFAULT_FONT_FAMILY,
|
|
||||||
fontSize: DEFAULT_FONT_SIZE,
|
|
||||||
opacity: 100,
|
|
||||||
roughness: 1,
|
|
||||||
strokeColor: colors.elementStroke[0],
|
|
||||||
strokeSharpness: "sharp",
|
|
||||||
strokeStyle: "solid",
|
|
||||||
strokeWidth: 1,
|
|
||||||
verticalAlign: "middle",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
|
||||||
const chartWidth =
|
|
||||||
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
|
|
||||||
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
|
|
||||||
return { chartWidth, chartHeight };
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartXLabels = (
|
|
||||||
spreadsheet: Spreadsheet,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
groupId: string,
|
|
||||||
backgroundColor: string,
|
|
||||||
): ChartElements => {
|
|
||||||
return (
|
|
||||||
spreadsheet.labels?.map((label, index) => {
|
|
||||||
return newTextElement({
|
|
||||||
groupIds: [groupId],
|
|
||||||
backgroundColor,
|
|
||||||
...commonProps,
|
|
||||||
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
|
|
||||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
|
||||||
y: y + BAR_GAP / 2,
|
|
||||||
width: BAR_WIDTH,
|
|
||||||
angle: 5.87,
|
|
||||||
fontSize: 16,
|
|
||||||
textAlign: "center",
|
|
||||||
verticalAlign: "top",
|
|
||||||
});
|
|
||||||
}) || []
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartYLabels = (
|
|
||||||
spreadsheet: Spreadsheet,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
groupId: string,
|
|
||||||
backgroundColor: string,
|
|
||||||
): ChartElements => {
|
|
||||||
const minYLabel = newTextElement({
|
|
||||||
groupIds: [groupId],
|
|
||||||
backgroundColor,
|
|
||||||
...commonProps,
|
|
||||||
x: x - BAR_GAP,
|
|
||||||
y: y - BAR_GAP,
|
|
||||||
text: "0",
|
|
||||||
textAlign: "right",
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxYLabel = newTextElement({
|
|
||||||
groupIds: [groupId],
|
|
||||||
backgroundColor,
|
|
||||||
...commonProps,
|
|
||||||
x: x - BAR_GAP,
|
|
||||||
y: y - BAR_HEIGHT - minYLabel.height / 2,
|
|
||||||
text: Math.max(...spreadsheet.values).toLocaleString(),
|
|
||||||
textAlign: "right",
|
|
||||||
});
|
|
||||||
|
|
||||||
return [minYLabel, maxYLabel];
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartLines = (
|
|
||||||
spreadsheet: Spreadsheet,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
groupId: string,
|
|
||||||
backgroundColor: string,
|
|
||||||
): ChartElements => {
|
|
||||||
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
|
||||||
const xLine = newLinearElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
type: "line",
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
width: chartWidth,
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[chartWidth, 0],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const yLine = newLinearElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
type: "line",
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
height: chartHeight,
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[0, -chartHeight],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxLine = newLinearElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
type: "line",
|
|
||||||
x,
|
|
||||||
y: y - BAR_HEIGHT - BAR_GAP,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
strokeStyle: "dotted",
|
|
||||||
width: chartWidth,
|
|
||||||
opacity: GRID_OPACITY,
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[chartWidth, 0],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return [xLine, yLine, maxLine];
|
|
||||||
};
|
|
||||||
|
|
||||||
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
|
|
||||||
const chartBaseElements = (
|
|
||||||
spreadsheet: Spreadsheet,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
groupId: string,
|
|
||||||
backgroundColor: string,
|
|
||||||
debug?: boolean,
|
|
||||||
): ChartElements => {
|
|
||||||
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
|
||||||
|
|
||||||
const title = spreadsheet.title
|
|
||||||
? newTextElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
text: spreadsheet.title,
|
|
||||||
x: x + chartWidth / 2,
|
|
||||||
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
|
|
||||||
strokeSharpness: "sharp",
|
|
||||||
strokeStyle: "solid",
|
|
||||||
textAlign: "center",
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const debugRect = debug
|
|
||||||
? newElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
type: "rectangle",
|
|
||||||
x,
|
|
||||||
y: y - chartHeight,
|
|
||||||
width: chartWidth,
|
|
||||||
height: chartHeight,
|
|
||||||
strokeColor: colors.elementStroke[0],
|
|
||||||
fillStyle: "solid",
|
|
||||||
opacity: 6,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
...(debugRect ? [debugRect] : []),
|
|
||||||
...(title ? [title] : []),
|
|
||||||
...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
|
|
||||||
...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
|
|
||||||
...chartLines(spreadsheet, x, y, groupId, backgroundColor),
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartTypeBar = (
|
|
||||||
spreadsheet: Spreadsheet,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
): ChartElements => {
|
|
||||||
const max = Math.max(...spreadsheet.values);
|
|
||||||
const groupId = randomId();
|
|
||||||
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
|
||||||
|
|
||||||
const bars = spreadsheet.values.map((value, index) => {
|
|
||||||
const barHeight = (value / max) * BAR_HEIGHT;
|
|
||||||
return newElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
type: "rectangle",
|
|
||||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
|
|
||||||
y: y - barHeight - BAR_GAP,
|
|
||||||
width: BAR_WIDTH,
|
|
||||||
height: barHeight,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return [
|
|
||||||
...bars,
|
|
||||||
...chartBaseElements(
|
|
||||||
spreadsheet,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
groupId,
|
|
||||||
backgroundColor,
|
|
||||||
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartTypeLine = (
|
|
||||||
spreadsheet: Spreadsheet,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
): ChartElements => {
|
|
||||||
const max = Math.max(...spreadsheet.values);
|
|
||||||
const groupId = randomId();
|
|
||||||
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
|
||||||
|
|
||||||
let index = 0;
|
|
||||||
const points = [];
|
|
||||||
for (const value of spreadsheet.values) {
|
|
||||||
const cx = index * (BAR_WIDTH + BAR_GAP);
|
|
||||||
const cy = -(value / max) * BAR_HEIGHT;
|
|
||||||
points.push([cx, cy]);
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxX = Math.max(...points.map((element) => element[0]));
|
|
||||||
const maxY = Math.max(...points.map((element) => element[1]));
|
|
||||||
const minX = Math.min(...points.map((element) => element[0]));
|
|
||||||
const minY = Math.min(...points.map((element) => element[1]));
|
|
||||||
|
|
||||||
const line = newLinearElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
type: "line",
|
|
||||||
x: x + BAR_GAP + BAR_WIDTH / 2,
|
|
||||||
y: y - BAR_GAP,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
height: maxY - minY,
|
|
||||||
width: maxX - minX,
|
|
||||||
strokeWidth: 2,
|
|
||||||
points: points as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dots = spreadsheet.values.map((value, index) => {
|
|
||||||
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
|
||||||
const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
|
|
||||||
return newElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
fillStyle: "solid",
|
|
||||||
strokeWidth: 2,
|
|
||||||
type: "ellipse",
|
|
||||||
x: x + cx + BAR_WIDTH / 2,
|
|
||||||
y: y + cy - BAR_GAP * 2,
|
|
||||||
width: BAR_GAP,
|
|
||||||
height: BAR_GAP,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const lines = spreadsheet.values.map((value, index) => {
|
|
||||||
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
|
||||||
const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
|
|
||||||
return newLinearElement({
|
|
||||||
backgroundColor,
|
|
||||||
groupIds: [groupId],
|
|
||||||
...commonProps,
|
|
||||||
type: "line",
|
|
||||||
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
|
|
||||||
y: y - cy,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
height: cy,
|
|
||||||
strokeStyle: "dotted",
|
|
||||||
opacity: GRID_OPACITY,
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[0, cy],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return [
|
|
||||||
...chartBaseElements(
|
|
||||||
spreadsheet,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
groupId,
|
|
||||||
backgroundColor,
|
|
||||||
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
|
||||||
),
|
|
||||||
line,
|
|
||||||
...lines,
|
|
||||||
...dots,
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const renderSpreadsheet = (
|
|
||||||
chartType: string,
|
|
||||||
spreadsheet: Spreadsheet,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
): ChartElements => {
|
|
||||||
trackEvent(EVENT_MAGIC, "chart", chartType, spreadsheet.values.length);
|
|
||||||
if (chartType === "line") {
|
|
||||||
return chartTypeLine(spreadsheet, x, y);
|
|
||||||
}
|
|
||||||
return chartTypeBar(spreadsheet, x, y);
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import colors from "./colors";
|
|
||||||
|
|
||||||
export const getClientColors = (clientId: string) => {
|
|
||||||
// Naive way of getting an integer out of the clientId
|
|
||||||
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
|
|
||||||
|
|
||||||
// Skip transparent background.
|
|
||||||
const backgrounds = colors.elementBackground.slice(1);
|
|
||||||
const strokes = colors.elementStroke.slice(1);
|
|
||||||
return {
|
|
||||||
background: backgrounds[sum % backgrounds.length],
|
|
||||||
stroke: strokes[sum % strokes.length],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getClientInitials = (username?: string | null) => {
|
|
||||||
if (!username) {
|
|
||||||
return "?";
|
|
||||||
}
|
|
||||||
const names = username.trim().split(" ");
|
|
||||||
|
|
||||||
if (names.length < 2) {
|
|
||||||
return names[0].substring(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstName = names[0];
|
|
||||||
const lastName = names[names.length - 1];
|
|
||||||
|
|
||||||
return (firstName[0] + lastName[0]).toUpperCase();
|
|
||||||
};
|
|
||||||
+70
-115
@@ -5,16 +5,6 @@ import {
|
|||||||
import { getSelectedElements } from "./scene";
|
import { getSelectedElements } from "./scene";
|
||||||
import { AppState } from "./types";
|
import { AppState } from "./types";
|
||||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
|
||||||
import { canvasToBlob } from "./data/blob";
|
|
||||||
|
|
||||||
const TYPE_ELEMENTS = "excalidraw/elements";
|
|
||||||
|
|
||||||
type ElementsClipboard = {
|
|
||||||
type: typeof TYPE_ELEMENTS;
|
|
||||||
created: number;
|
|
||||||
elements: ExcalidrawElement[];
|
|
||||||
};
|
|
||||||
|
|
||||||
let CLIPBOARD = "";
|
let CLIPBOARD = "";
|
||||||
let PREFER_APP_CLIPBOARD = false;
|
let PREFER_APP_CLIPBOARD = false;
|
||||||
@@ -31,139 +21,104 @@ export const probablySupportsClipboardBlob =
|
|||||||
"ClipboardItem" in window &&
|
"ClipboardItem" in window &&
|
||||||
"toBlob" in HTMLCanvasElement.prototype;
|
"toBlob" in HTMLCanvasElement.prototype;
|
||||||
|
|
||||||
const isElementsClipboard = (contents: any): contents is ElementsClipboard => {
|
export async function copyToAppClipboard(
|
||||||
if (contents?.type === TYPE_ELEMENTS) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const copyToClipboard = async (
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) {
|
||||||
const contents: ElementsClipboard = {
|
CLIPBOARD = JSON.stringify(getSelectedElements(elements, appState));
|
||||||
type: TYPE_ELEMENTS,
|
|
||||||
created: Date.now(),
|
|
||||||
elements: getSelectedElements(elements, appState),
|
|
||||||
};
|
|
||||||
const json = JSON.stringify(contents);
|
|
||||||
CLIPBOARD = json;
|
|
||||||
try {
|
try {
|
||||||
|
// when copying to in-app clipboard, clear system clipboard so that if
|
||||||
|
// system clip contains text on paste we know it was copied *after* user
|
||||||
|
// copied elements, and thus we should prefer the text content.
|
||||||
|
await copyTextToSystemClipboard(null);
|
||||||
PREFER_APP_CLIPBOARD = false;
|
PREFER_APP_CLIPBOARD = false;
|
||||||
await copyTextToSystemClipboard(json);
|
} catch {
|
||||||
} catch (error) {
|
// if clearing system clipboard didn't work, we should prefer in-app
|
||||||
|
// clipboard even if there's text in system clipboard on paste, because
|
||||||
|
// we can't be sure of the order of copy operations
|
||||||
PREFER_APP_CLIPBOARD = true;
|
PREFER_APP_CLIPBOARD = true;
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const getAppClipboard = (): Partial<ElementsClipboard> => {
|
export function getAppClipboard(): {
|
||||||
|
elements?: readonly ExcalidrawElement[];
|
||||||
|
} {
|
||||||
if (!CLIPBOARD) {
|
if (!CLIPBOARD) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(CLIPBOARD);
|
const clipboardElements = JSON.parse(CLIPBOARD);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Array.isArray(clipboardElements) &&
|
||||||
|
clipboardElements.length > 0 &&
|
||||||
|
clipboardElements[0].type // need to implement a better check here...
|
||||||
|
) {
|
||||||
|
return { elements: clipboardElements };
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const parsePotentialSpreadsheet = (
|
return {};
|
||||||
text: string,
|
}
|
||||||
): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
|
|
||||||
const result = tryParseSpreadsheet(text);
|
|
||||||
if (result.type === VALID_SPREADSHEET) {
|
|
||||||
return { spreadsheet: result.spreadsheet };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
export async function getClipboardContent(
|
||||||
* Retrieves content from system clipboard (either from ClipboardEvent or
|
|
||||||
* via async clipboard API if supported)
|
|
||||||
*/
|
|
||||||
const getSystemClipboard = async (
|
|
||||||
event: ClipboardEvent | null,
|
event: ClipboardEvent | null,
|
||||||
): Promise<string> => {
|
): Promise<{
|
||||||
|
text?: string;
|
||||||
|
elements?: readonly ExcalidrawElement[];
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const text = event
|
const text = event
|
||||||
? event.clipboardData?.getData("text/plain").trim()
|
? event.clipboardData?.getData("text/plain").trim()
|
||||||
: probablySupportsClipboardReadText &&
|
: probablySupportsClipboardReadText &&
|
||||||
(await navigator.clipboard.readText());
|
(await navigator.clipboard.readText());
|
||||||
|
|
||||||
return text || "";
|
if (text && !PREFER_APP_CLIPBOARD && !text.includes(SVG_EXPORT_TAG)) {
|
||||||
} catch {
|
return { text };
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attemps to parse clipboard. Prefers system clipboard.
|
|
||||||
*/
|
|
||||||
export const parseClipboard = async (
|
|
||||||
event: ClipboardEvent | null,
|
|
||||||
): Promise<{
|
|
||||||
spreadsheet?: Spreadsheet;
|
|
||||||
elements?: readonly ExcalidrawElement[];
|
|
||||||
text?: string;
|
|
||||||
errorMessage?: string;
|
|
||||||
}> => {
|
|
||||||
const systemClipboard = await getSystemClipboard(event);
|
|
||||||
|
|
||||||
// if system clipboard empty, couldn't be resolved, or contains previously
|
|
||||||
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
|
|
||||||
// elements
|
|
||||||
if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) {
|
|
||||||
return getAppClipboard();
|
|
||||||
}
|
|
||||||
|
|
||||||
// if system clipboard contains spreadsheet, use it even though it's
|
|
||||||
// technically possible it's staler than in-app clipboard
|
|
||||||
const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard);
|
|
||||||
if (spreadsheetResult) {
|
|
||||||
return spreadsheetResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
const appClipboardData = getAppClipboard();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const systemClipboardData = JSON.parse(systemClipboard);
|
|
||||||
// system clipboard elements are newer than in-app clipboard
|
|
||||||
if (
|
|
||||||
isElementsClipboard(systemClipboardData) &&
|
|
||||||
(!appClipboardData?.created ||
|
|
||||||
appClipboardData.created < systemClipboardData.created)
|
|
||||||
) {
|
|
||||||
return { elements: systemClipboardData.elements };
|
|
||||||
}
|
}
|
||||||
// in-app clipboard is newer than system clipboard
|
} catch (error) {
|
||||||
return appClipboardData;
|
console.error(error);
|
||||||
} catch {
|
|
||||||
// system clipboard doesn't contain excalidraw elements → return plaintext
|
|
||||||
// unless we set a flag to prefer in-app clipboard because browser didn't
|
|
||||||
// support storing to system clipboard on copy
|
|
||||||
return PREFER_APP_CLIPBOARD && appClipboardData.elements
|
|
||||||
? appClipboardData
|
|
||||||
: { text: systemClipboard };
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
|
return getAppClipboard();
|
||||||
const blob = await canvasToBlob(canvas);
|
}
|
||||||
await navigator.clipboard.write([
|
|
||||||
new window.ClipboardItem({ "image/png": blob }),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const copyTextToSystemClipboard = async (text: string | null) => {
|
export async function copyCanvasToClipboardAsPng(canvas: HTMLCanvasElement) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
canvas.toBlob(async function (blob: any) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new window.ClipboardItem({ "image/png": blob }),
|
||||||
|
]);
|
||||||
|
resolve();
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyCanvasToClipboardAsSvg(svgroot: SVGSVGElement) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(svgroot.outerHTML);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyTextToSystemClipboard(text: string | null) {
|
||||||
let copied = false;
|
let copied = false;
|
||||||
if (probablySupportsClipboardWriteText) {
|
if (probablySupportsClipboardWriteText) {
|
||||||
try {
|
try {
|
||||||
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
|
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
|
||||||
// not focused
|
// not focused
|
||||||
await navigator.clipboard.writeText(text || "");
|
await navigator.clipboard.writeText(text || "");
|
||||||
copied = true;
|
copied = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -172,14 +127,14 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Note that execCommand doesn't allow copying empty strings, so if we're
|
// Note that execCommand doesn't allow copying empty strings, so if we're
|
||||||
// clearing clipboard using this API, we must copy at least an empty char
|
// clearing clipboard using this API, we must copy at least an empty char
|
||||||
if (!copied && !copyTextViaExecCommand(text || " ")) {
|
if (!copied && !copyTextViaExecCommand(text || " ")) {
|
||||||
throw new Error("couldn't copy");
|
throw new Error("couldn't copy");
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
|
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
|
||||||
const copyTextViaExecCommand = (text: string) => {
|
function copyTextViaExecCommand(text: string) {
|
||||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
const textarea = document.createElement("textarea");
|
const textarea = document.createElement("textarea");
|
||||||
@@ -213,4 +168,4 @@ const copyTextViaExecCommand = (text: string) => {
|
|||||||
textarea.remove();
|
textarea.remove();
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
};
|
}
|
||||||
|
|||||||
+13
-13
@@ -1,18 +1,18 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
|
|
||||||
const shades = (index: number) => [
|
const shades = (i: number) => [
|
||||||
oc.red[index],
|
oc.red[i],
|
||||||
oc.pink[index],
|
oc.pink[i],
|
||||||
oc.grape[index],
|
oc.grape[i],
|
||||||
oc.violet[index],
|
oc.violet[i],
|
||||||
oc.indigo[index],
|
oc.indigo[i],
|
||||||
oc.blue[index],
|
oc.blue[i],
|
||||||
oc.cyan[index],
|
oc.cyan[i],
|
||||||
oc.teal[index],
|
oc.teal[i],
|
||||||
oc.green[index],
|
oc.green[i],
|
||||||
oc.lime[index],
|
oc.lime[i],
|
||||||
oc.yellow[index],
|
oc.yellow[i],
|
||||||
oc.orange[index],
|
oc.orange[i],
|
||||||
];
|
];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
+67
-152
@@ -1,25 +1,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { AppState, Zoom } from "../types";
|
import { AppState } from "../types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import {
|
import { hasBackground, hasStroke, hasText, getTargetElement } from "../scene";
|
||||||
hasBackground,
|
|
||||||
hasStroke,
|
|
||||||
canChangeSharpness,
|
|
||||||
hasText,
|
|
||||||
canHaveArrowheads,
|
|
||||||
getTargetElements,
|
|
||||||
} from "../scene";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { SHAPES } from "../shapes";
|
import { SHAPES } from "../shapes";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
import { capitalizeString, setCursorForShape } from "../utils";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { trackEvent, EVENT_SHAPE, EVENT_DIALOG } from "../analytics";
|
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export function SelectedShapeActions({
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
renderAction,
|
renderAction,
|
||||||
@@ -29,45 +21,35 @@ export const SelectedShapeActions = ({
|
|||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
renderAction: ActionManager["renderAction"];
|
renderAction: ActionManager["renderAction"];
|
||||||
elementType: ExcalidrawElement["type"];
|
elementType: ExcalidrawElement["type"];
|
||||||
}) => {
|
}) {
|
||||||
const targetElements = getTargetElements(
|
const targetElements = getTargetElement(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
const isEditing = Boolean(appState.editingElement);
|
const isEditing = Boolean(appState.editingElement);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
|
||||||
|
|
||||||
const showFillIcons =
|
|
||||||
hasBackground(elementType) ||
|
|
||||||
targetElements.some(
|
|
||||||
(element) =>
|
|
||||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
|
||||||
);
|
|
||||||
const showChangeBackgroundIcons =
|
|
||||||
hasBackground(elementType) ||
|
|
||||||
targetElements.some((element) => hasBackground(element.type));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
{renderAction("changeStrokeColor")}
|
{renderAction("changeStrokeColor")}
|
||||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
{(hasBackground(elementType) ||
|
||||||
{showFillIcons && renderAction("changeFillStyle")}
|
targetElements.some((element) => hasBackground(element.type))) && (
|
||||||
|
<>
|
||||||
|
{renderAction("changeBackgroundColor")}
|
||||||
|
|
||||||
|
{renderAction("changeFillStyle")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{(hasStroke(elementType) ||
|
{(hasStroke(elementType) ||
|
||||||
targetElements.some((element) => hasStroke(element.type))) && (
|
targetElements.some((element) => hasStroke(element.type))) && (
|
||||||
<>
|
<>
|
||||||
{renderAction("changeStrokeWidth")}
|
{renderAction("changeStrokeWidth")}
|
||||||
{renderAction("changeStrokeStyle")}
|
|
||||||
{renderAction("changeSloppiness")}
|
{renderAction("changeSloppiness")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(canChangeSharpness(elementType) ||
|
|
||||||
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
|
||||||
<>{renderAction("changeSharpness")}</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(hasText(elementType) ||
|
{(hasText(elementType) ||
|
||||||
targetElements.some((element) => hasText(element.type))) && (
|
targetElements.some((element) => hasText(element.type))) && (
|
||||||
<>
|
<>
|
||||||
@@ -79,11 +61,6 @@ export const SelectedShapeActions = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(canHaveArrowheads(elementType) ||
|
|
||||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
|
||||||
<>{renderAction("changeArrowhead")}</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderAction("changeOpacity")}
|
{renderAction("changeOpacity")}
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -95,138 +72,76 @@ export const SelectedShapeActions = ({
|
|||||||
{renderAction("bringForward")}
|
{renderAction("bringForward")}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{targetElements.length > 1 && (
|
|
||||||
<fieldset>
|
|
||||||
<legend>{t("labels.align")}</legend>
|
|
||||||
<div className="buttonList">
|
|
||||||
{
|
|
||||||
// swap this order for RTL so the button positions always match their action
|
|
||||||
// (i.e. the leftmost button aligns left)
|
|
||||||
}
|
|
||||||
{isRTL ? (
|
|
||||||
<>
|
|
||||||
{renderAction("alignRight")}
|
|
||||||
{renderAction("alignHorizontallyCentered")}
|
|
||||||
{renderAction("alignLeft")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{renderAction("alignLeft")}
|
|
||||||
{renderAction("alignHorizontallyCentered")}
|
|
||||||
{renderAction("alignRight")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{targetElements.length > 2 &&
|
|
||||||
renderAction("distributeHorizontally")}
|
|
||||||
<div className="iconRow">
|
|
||||||
{renderAction("alignTop")}
|
|
||||||
{renderAction("alignVerticallyCentered")}
|
|
||||||
{renderAction("alignBottom")}
|
|
||||||
{targetElements.length > 2 &&
|
|
||||||
renderAction("distributeVertically")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
)}
|
|
||||||
{!isMobile && !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">
|
||||||
{renderAction("duplicateSelection")}
|
{renderAction("duplicateSelection")}
|
||||||
{renderAction("deleteSelectedElements")}
|
{renderAction("deleteSelectedElements")}
|
||||||
{renderAction("group")}
|
|
||||||
{renderAction("ungroup")}
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
const LIBRARY_ICON = (
|
export function ShapesSwitcher({
|
||||||
// fa-th-large
|
|
||||||
<svg viewBox="0 0 512 512">
|
|
||||||
<path d="M296 32h192c13.255 0 24 10.745 24 24v160c0 13.255-10.745 24-24 24H296c-13.255 0-24-10.745-24-24V56c0-13.255 10.745-24 24-24zm-80 0H24C10.745 32 0 42.745 0 56v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V56c0-13.255-10.745-24-24-24zM0 296v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H24c-13.255 0-24 10.745-24 24zm296 184h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H296c-13.255 0-24 10.745-24 24v160c0 13.255 10.745 24 24 24z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ShapesSwitcher = ({
|
|
||||||
elementType,
|
elementType,
|
||||||
setAppState,
|
setAppState,
|
||||||
isLibraryOpen,
|
|
||||||
}: {
|
}: {
|
||||||
elementType: ExcalidrawElement["type"];
|
elementType: ExcalidrawElement["type"];
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: any;
|
||||||
isLibraryOpen: boolean;
|
}) {
|
||||||
}) => (
|
return (
|
||||||
<>
|
<>
|
||||||
{SHAPES.map(({ value, icon, key }, index) => {
|
{SHAPES.map(({ value, icon, key }, index) => {
|
||||||
const label = t(`toolBar.${value}`);
|
const label = t(`toolBar.${value}`);
|
||||||
const letter = typeof key === "string" ? key : key[0];
|
const shortcut = `${capitalizeString(key)} ${t("shortcutsDialog.or")} ${
|
||||||
const shortcut = `${capitalizeString(letter)} ${t(
|
index + 1
|
||||||
"shortcutsDialog.or",
|
}`;
|
||||||
)} ${index + 1}`;
|
return (
|
||||||
return (
|
<ToolButton
|
||||||
<ToolButton
|
key={value}
|
||||||
className="Shape"
|
type="radio"
|
||||||
key={value}
|
icon={icon}
|
||||||
type="radio"
|
checked={elementType === value}
|
||||||
icon={icon}
|
name="editor-current-shape"
|
||||||
checked={elementType === value}
|
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||||
name="editor-current-shape"
|
keyBindingLabel={`${index + 1}`}
|
||||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
aria-label={capitalizeString(label)}
|
||||||
keyBindingLabel={`${index + 1}`}
|
aria-keyshortcuts={`${key} ${index + 1}`}
|
||||||
aria-label={capitalizeString(label)}
|
data-testid={value}
|
||||||
aria-keyshortcuts={shortcut}
|
onChange={() => {
|
||||||
data-testid={value}
|
setAppState({
|
||||||
onChange={() => {
|
elementType: value,
|
||||||
trackEvent(EVENT_SHAPE, value, "toolbar");
|
multiElement: null,
|
||||||
setAppState({
|
selectedElementIds: {},
|
||||||
elementType: value,
|
});
|
||||||
multiElement: null,
|
setCursorForShape(value);
|
||||||
selectedElementIds: {},
|
setAppState({});
|
||||||
});
|
}}
|
||||||
setCursorForShape(value);
|
></ToolButton>
|
||||||
setAppState({});
|
);
|
||||||
}}
|
})}
|
||||||
/>
|
</>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
<ToolButton
|
|
||||||
className="Shape ToolIcon_type_button__library"
|
|
||||||
type="button"
|
|
||||||
icon={LIBRARY_ICON}
|
|
||||||
name="editor-library"
|
|
||||||
keyBindingLabel="9"
|
|
||||||
aria-keyshortcuts="9"
|
|
||||||
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
|
||||||
aria-label={capitalizeString(t("toolBar.library"))}
|
|
||||||
onClick={() => {
|
|
||||||
if (!isLibraryOpen) {
|
|
||||||
trackEvent(EVENT_DIALOG, "library");
|
|
||||||
}
|
|
||||||
setAppState({ isLibraryOpen: !isLibraryOpen });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ZoomActions = ({
|
export function ZoomActions({
|
||||||
renderAction,
|
renderAction,
|
||||||
zoom,
|
zoom,
|
||||||
}: {
|
}: {
|
||||||
renderAction: ActionManager["renderAction"];
|
renderAction: ActionManager["renderAction"];
|
||||||
zoom: Zoom;
|
zoom: number;
|
||||||
}) => (
|
}) {
|
||||||
<Stack.Col gap={1}>
|
return (
|
||||||
<Stack.Row gap={1} align="center">
|
<Stack.Col gap={1}>
|
||||||
{renderAction("zoomIn")}
|
<Stack.Row gap={1} align="center">
|
||||||
{renderAction("zoomOut")}
|
{renderAction("zoomIn")}
|
||||||
{renderAction("resetZoom")}
|
{renderAction("zoomOut")}
|
||||||
<div style={{ marginInlineStart: 4 }}>
|
{renderAction("resetZoom")}
|
||||||
{(zoom.value * 100).toFixed(0)}%
|
<div style={{ marginInlineStart: 4 }}>{(zoom * 100).toFixed(0)}%</div>
|
||||||
</div>
|
</Stack.Row>
|
||||||
</Stack.Row>
|
</Stack.Col>
|
||||||
</Stack.Col>
|
);
|
||||||
);
|
}
|
||||||
|
|||||||
+1550
-2668
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
|||||||
@import "../css/_variables";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.Avatar {
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-radius: 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
color: $oc-white;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import "./Avatar.scss";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
type AvatarProps = {
|
|
||||||
children: string;
|
|
||||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
|
||||||
color: string;
|
|
||||||
border: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
|
|
||||||
<div
|
|
||||||
className="Avatar"
|
|
||||||
style={{ background: color, border: `1px solid ${border}` }}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { ActionManager } from "../actions/manager";
|
|
||||||
import { EVENT_CHANGE, trackEvent } from "../analytics";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { DarkModeToggle } from "./DarkModeToggle";
|
|
||||||
|
|
||||||
export const BackgroundPickerAndDarkModeToggle = ({
|
|
||||||
appState,
|
|
||||||
setAppState,
|
|
||||||
actionManager,
|
|
||||||
}: {
|
|
||||||
actionManager: ActionManager;
|
|
||||||
appState: AppState;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
}) => (
|
|
||||||
<div style={{ display: "flex" }}>
|
|
||||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
|
||||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
|
||||||
<DarkModeToggle
|
|
||||||
value={appState.appearance}
|
|
||||||
onChange={(appearance) => {
|
|
||||||
// TODO: track the theme on the first load too
|
|
||||||
trackEvent(EVENT_CHANGE, "theme", appearance);
|
|
||||||
setAppState({ appearance });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
export const ButtonIconCycle = <T extends any>({
|
|
||||||
options,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
group,
|
|
||||||
}: {
|
|
||||||
options: { value: T; text: string; icon: JSX.Element }[];
|
|
||||||
value: T | null;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
group: string;
|
|
||||||
}) => {
|
|
||||||
const current = options.find((op) => op.value === value);
|
|
||||||
|
|
||||||
function cycle() {
|
|
||||||
const index = options.indexOf(current!);
|
|
||||||
const next = (index + 1) % options.length;
|
|
||||||
onChange(options[next].value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label key={group} className={clsx({ active: current!.value !== null })}>
|
|
||||||
<input type="button" name={group} onClick={cycle} />
|
|
||||||
{current!.icon}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
|
||||||
export const ButtonIconSelect = <T extends Object>({
|
|
||||||
options,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
group,
|
|
||||||
}: {
|
|
||||||
options: { value: T; text: string; icon: JSX.Element }[];
|
|
||||||
value: T | null;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
group: string;
|
|
||||||
}) => (
|
|
||||||
<div className="buttonList buttonListIcon">
|
|
||||||
{options.map((option) => (
|
|
||||||
<label
|
|
||||||
key={option.text}
|
|
||||||
className={clsx({ active: value === option.value })}
|
|
||||||
title={option.text}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name={group}
|
|
||||||
onChange={() => onChange(option.value)}
|
|
||||||
checked={value === option.value}
|
|
||||||
/>
|
|
||||||
{option.icon}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
export const ButtonSelect = <T extends Object>({
|
export function ButtonSelect<T>({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -11,21 +10,23 @@ export const ButtonSelect = <T extends Object>({
|
|||||||
value: T | null;
|
value: T | null;
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
group: string;
|
group: string;
|
||||||
}) => (
|
}) {
|
||||||
<div className="buttonList">
|
return (
|
||||||
{options.map((option) => (
|
<div className="buttonList">
|
||||||
<label
|
{options.map((option) => (
|
||||||
key={option.text}
|
<label
|
||||||
className={clsx({ active: value === option.value })}
|
key={option.text}
|
||||||
>
|
className={value === option.value ? "active" : ""}
|
||||||
<input
|
>
|
||||||
type="radio"
|
<input
|
||||||
name={group}
|
type="radio"
|
||||||
onChange={() => onChange(option.value)}
|
name={group}
|
||||||
checked={value === option.value}
|
onChange={() => onChange(option.value)}
|
||||||
/>
|
checked={value === option.value ? true : false}
|
||||||
{option.text}
|
/>
|
||||||
</label>
|
{option.text}
|
||||||
))}
|
</label>
|
||||||
</div>
|
))}
|
||||||
);
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
@import "../css/_variables";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.CollabButton.is-collaborating {
|
|
||||||
background-color: var(--button-special-active-background-color);
|
|
||||||
|
|
||||||
.ToolIcon__icon svg {
|
|
||||||
color: var(--icon-green-fill-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.CollabButton-collaborators {
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
right: -5px;
|
|
||||||
}
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: -5px;
|
|
||||||
}
|
|
||||||
min-width: 1em;
|
|
||||||
position: absolute;
|
|
||||||
bottom: -5px;
|
|
||||||
padding: 3px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: $oc-green-6;
|
|
||||||
color: $oc-white;
|
|
||||||
font-size: 0.7em;
|
|
||||||
font-family: var(--ui-font);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import useIsMobile from "../is-mobile";
|
|
||||||
import { users } from "./icons";
|
|
||||||
|
|
||||||
import "./CollabButton.scss";
|
|
||||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
const CollabButton = ({
|
|
||||||
isCollaborating,
|
|
||||||
collaboratorCount,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
isCollaborating: boolean;
|
|
||||||
collaboratorCount: number;
|
|
||||||
onClick: () => void;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ToolButton
|
|
||||||
className={clsx("CollabButton", {
|
|
||||||
"is-collaborating": isCollaborating,
|
|
||||||
})}
|
|
||||||
onClick={() => {
|
|
||||||
trackEvent(EVENT_DIALOG, "collaboration");
|
|
||||||
onClick();
|
|
||||||
}}
|
|
||||||
icon={users}
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.roomDialog")}
|
|
||||||
aria-label={t("buttons.roomDialog")}
|
|
||||||
showAriaLabel={useIsMobile()}
|
|
||||||
>
|
|
||||||
{collaboratorCount > 0 && (
|
|
||||||
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
|
||||||
)}
|
|
||||||
</ToolButton>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CollabButton;
|
|
||||||
+187
-246
@@ -1,250 +1,191 @@
|
|||||||
@import "../css/_variables";
|
@import "open-color/open-color.scss";
|
||||||
|
|
||||||
.excalidraw {
|
.color-picker {
|
||||||
.color-picker {
|
background: $oc-white;
|
||||||
background: var(--popup-background-color);
|
border: 0px solid transparentize($oc-white, 0.75);
|
||||||
border: 0px solid transparentize($oc-white, 0.75);
|
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
|
||||||
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
|
border-radius: 4px;
|
||||||
border-radius: 4px;
|
position: absolute;
|
||||||
position: absolute;
|
:root[dir="ltr"] & {
|
||||||
|
left: -5.5px;
|
||||||
:root[dir="ltr"] & {
|
|
||||||
left: -5.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
right: -5.5px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
:root[dir="rtl"] & {
|
||||||
.color-picker-control-container {
|
right: -5.5px;
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-triangle {
|
|
||||||
width: 0px;
|
|
||||||
height: 0px;
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 0px 9px 10px;
|
|
||||||
border-color: transparent transparent var(--popup-background-color);
|
|
||||||
position: absolute;
|
|
||||||
top: -10px;
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
left: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
right: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-triangle-shadow {
|
|
||||||
border-color: transparent transparent transparentize($oc-black, 0.9);
|
|
||||||
top: -11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-content {
|
|
||||||
padding: 0.5rem;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, auto);
|
|
||||||
grid-gap: 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-content .color-input-container {
|
|
||||||
grid-column: 1 / span 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-swatch {
|
|
||||||
position: relative;
|
|
||||||
height: 1.875rem;
|
|
||||||
width: 1.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
background-color: currentColor !important;
|
|
||||||
filter: var(--appearance-filter);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
/* TODO: only show the border when the color is too light to see as a shadow */
|
|
||||||
box-shadow: 0 0 4px 1px currentColor;
|
|
||||||
border-color: var(--select-highlight-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-transparent {
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: transparentize($oc-black, 0.9) 0px 0px 0px 1px inset;
|
|
||||||
position: absolute;
|
|
||||||
top: 0px;
|
|
||||||
right: 0px;
|
|
||||||
bottom: 0px;
|
|
||||||
left: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-transparent,
|
|
||||||
.color-picker-label-swatch {
|
|
||||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
|
||||||
left center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-hash {
|
|
||||||
background: var(--input-border-color);
|
|
||||||
height: 1.875rem;
|
|
||||||
width: 1.875rem;
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
border-radius: 4px 0px 0px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
border-radius: 0px 4px 4px 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
color: var(--input-label-color);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-input-container:focus-within .color-picker-hash {
|
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-input-container:focus-within .color-picker-hash::before,
|
|
||||||
.color-input-container:focus-within .color-picker-hash::after {
|
|
||||||
content: "";
|
|
||||||
width: 1px;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-input-container:focus-within .color-picker-hash::before {
|
|
||||||
background: var(--input-border-color);
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
right: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: -1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-input-container:focus-within .color-picker-hash::after {
|
|
||||||
background: var(--input-background-color);
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
right: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: -2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-input-container {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-input {
|
|
||||||
width: 12ch; /* length of `transparent` + 1 */
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
background-color: var(--input-background-color);
|
|
||||||
color: var(--text-color-primary);
|
|
||||||
border: 0px;
|
|
||||||
outline: none;
|
|
||||||
height: 1.75em;
|
|
||||||
box-shadow: var(--input-border-color) 0px 0px 0px 1px inset;
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
border-radius: 0px 4px 4px 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
border-radius: 4px 0px 0px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
float: left;
|
|
||||||
padding: 1px;
|
|
||||||
padding-inline-start: 0.5em;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-label-swatch {
|
|
||||||
height: 1.875rem;
|
|
||||||
width: 1.875rem;
|
|
||||||
margin-inline-end: 0.25rem;
|
|
||||||
border: 1px solid $oc-gray-3;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: transparent !important;
|
|
||||||
filter: var(--appearance-filter);
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--swatch-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-keybinding {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 2px;
|
|
||||||
font-size: 0.7em;
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
right: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media #{$is-mobile-query} {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-type-canvasBackground .color-picker-keybinding {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-type-elementBackground .color-picker-keybinding {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-type-elementStroke .color-picker-keybinding {
|
|
||||||
color: #d4d4d4;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Appearance_dark {
|
|
||||||
.color-picker-type-elementBackground .color-picker-keybinding {
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-picker-control-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-triangle {
|
||||||
|
width: 0px;
|
||||||
|
height: 0px;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0px 9px 10px;
|
||||||
|
border-color: transparent transparent $oc-white;
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-triangle-shadow {
|
||||||
|
border-color: transparent transparent transparentize($oc-black, 0.9);
|
||||||
|
top: -11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-content {
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, auto);
|
||||||
|
grid-gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-content .color-input-container {
|
||||||
|
grid-column: 1 / span 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-swatch {
|
||||||
|
position: relative;
|
||||||
|
height: 1.875rem;
|
||||||
|
width: 1.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background-color: currentColor !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-swatch:focus {
|
||||||
|
/* TODO: only show the border when the color is too light to see as a shadow */
|
||||||
|
box-shadow: 0 0 4px 1px currentColor;
|
||||||
|
border-color: $oc-blue-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-transparent {
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: transparentize($oc-black, 0.9) 0px 0px 0px 1px inset;
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
right: 0px;
|
||||||
|
bottom: 0px;
|
||||||
|
left: 0px;
|
||||||
|
}
|
||||||
|
.color-picker-transparent,
|
||||||
|
.color-picker-label-swatch {
|
||||||
|
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||||
|
left center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-hash {
|
||||||
|
background: $oc-gray-3;
|
||||||
|
height: 1.875rem;
|
||||||
|
width: 1.875rem;
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
border-radius: 4px 0px 0px 4px;
|
||||||
|
}
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
border-radius: 0px 4px 4px 0px;
|
||||||
|
}
|
||||||
|
color: $oc-gray-7;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.color-input-container:focus-within .color-picker-hash {
|
||||||
|
box-shadow: 0 0 0 2px $oc-blue-2;
|
||||||
|
}
|
||||||
|
.color-input-container:focus-within .color-picker-hash::before,
|
||||||
|
.color-input-container:focus-within .color-picker-hash::after {
|
||||||
|
content: "";
|
||||||
|
width: 1px;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.color-input-container:focus-within .color-picker-hash::before {
|
||||||
|
background: $oc-gray-3;
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
right: -1px;
|
||||||
|
}
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: -1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.color-input-container:focus-within .color-picker-hash::after {
|
||||||
|
background: #fff;
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
right: -2px;
|
||||||
|
}
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-input {
|
||||||
|
width: 12ch; /* length of `transparent` + 1 */
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: $oc-gray-8;
|
||||||
|
border: 0px;
|
||||||
|
outline: none;
|
||||||
|
height: 1.75em;
|
||||||
|
box-shadow: $oc-gray-3 0px 0px 0px 1px inset;
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
border-radius: 0px 4px 4px 0px;
|
||||||
|
}
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
border-radius: 4px 0px 0px 4px;
|
||||||
|
}
|
||||||
|
float: left;
|
||||||
|
padding: 1px;
|
||||||
|
padding-inline-start: 0.5em;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-label-swatch {
|
||||||
|
height: 1.875rem;
|
||||||
|
width: 1.875rem;
|
||||||
|
margin-inline-end: 0.25rem;
|
||||||
|
border: 1px solid $oc-gray-3;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-label-swatch::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--swatch-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-keybinding {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
right: 2px;
|
||||||
|
}
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: 2px;
|
||||||
|
}
|
||||||
|
font-size: 0.7em;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ import React from "react";
|
|||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
|
|
||||||
import "./ColorPicker.scss";
|
import "./ColorPicker.scss";
|
||||||
import { isArrowKey, KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { t, getLanguage } from "../i18n";
|
import { t, getLanguage } from "../i18n";
|
||||||
import { isWritableElement } from "../utils";
|
import { isWritableElement } from "../utils";
|
||||||
import colors from "../colors";
|
import colors from "../colors";
|
||||||
|
|
||||||
const isValidColor = (color: string) => {
|
function isValidColor(color: string) {
|
||||||
const style = new Option().style;
|
const style = new Option().style;
|
||||||
style.color = color;
|
style.color = color;
|
||||||
return !!style.color;
|
return !!style.color;
|
||||||
};
|
}
|
||||||
|
|
||||||
const getColor = (color: string): string | null => {
|
const getColor = (color: string): string | null => {
|
||||||
if (color === "transparent") {
|
if (color === "transparent") {
|
||||||
@@ -36,14 +36,13 @@ const keyBindings = [
|
|||||||
["a", "s", "d", "f", "g"],
|
["a", "s", "d", "f", "g"],
|
||||||
].flat();
|
].flat();
|
||||||
|
|
||||||
const Picker = ({
|
const Picker = function ({
|
||||||
colors,
|
colors,
|
||||||
color,
|
color,
|
||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
label,
|
label,
|
||||||
showInput = true,
|
showInput = true,
|
||||||
type,
|
|
||||||
}: {
|
}: {
|
||||||
colors: string[];
|
colors: string[];
|
||||||
color: string | null;
|
color: string | null;
|
||||||
@@ -51,21 +50,19 @@ const Picker = ({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
showInput: boolean;
|
showInput: boolean;
|
||||||
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
}) {
|
||||||
}) => {
|
|
||||||
const firstItem = React.useRef<HTMLButtonElement>();
|
const firstItem = React.useRef<HTMLButtonElement>();
|
||||||
const activeItem = React.useRef<HTMLButtonElement>();
|
const activeItem = React.useRef<HTMLButtonElement>();
|
||||||
const gallery = React.useRef<HTMLDivElement>();
|
const gallery = React.useRef<HTMLDivElement>();
|
||||||
const colorInput = React.useRef<HTMLInputElement>();
|
const colorInput = React.useRef<HTMLInputElement>();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// After the component is first mounted focus on first input
|
// After the component is first mounted
|
||||||
|
// focus on first input
|
||||||
if (activeItem.current) {
|
if (activeItem.current) {
|
||||||
activeItem.current.focus();
|
activeItem.current.focus();
|
||||||
} else if (colorInput.current) {
|
} else if (colorInput.current) {
|
||||||
colorInput.current.focus();
|
colorInput.current.focus();
|
||||||
} else if (gallery.current) {
|
|
||||||
gallery.current.focus();
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -77,11 +74,18 @@ const Picker = ({
|
|||||||
colorInput.current?.focus();
|
colorInput.current?.focus();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
} else if (activeElement === colorInput.current) {
|
} else {
|
||||||
firstItem.current?.focus();
|
if (activeElement === colorInput.current) {
|
||||||
event.preventDefault();
|
firstItem.current?.focus();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (isArrowKey(event.key)) {
|
} else if (
|
||||||
|
event.key === KEYS.ARROW_RIGHT ||
|
||||||
|
event.key === KEYS.ARROW_LEFT ||
|
||||||
|
event.key === KEYS.ARROW_UP ||
|
||||||
|
event.key === KEYS.ARROW_DOWN
|
||||||
|
) {
|
||||||
const { activeElement } = document;
|
const { activeElement } = document;
|
||||||
const isRTL = getLanguage().rtl;
|
const isRTL = getLanguage().rtl;
|
||||||
const index = Array.prototype.indexOf.call(
|
const index = Array.prototype.indexOf.call(
|
||||||
@@ -119,7 +123,7 @@ const Picker = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`color-picker color-picker-type-${type}`}
|
className="color-picker"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t("labels.colorPicker")}
|
aria-label={t("labels.colorPicker")}
|
||||||
@@ -134,7 +138,6 @@ const Picker = ({
|
|||||||
gallery.current = el;
|
gallery.current = el;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
tabIndex={0}
|
|
||||||
>
|
>
|
||||||
{colors.map((_color, i) => (
|
{colors.map((_color, i) => (
|
||||||
<button
|
<button
|
||||||
@@ -232,7 +235,7 @@ const ColorInput = React.forwardRef(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ColorPicker = ({
|
export function ColorPicker({
|
||||||
type,
|
type,
|
||||||
color,
|
color,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -242,7 +245,7 @@ export const ColorPicker = ({
|
|||||||
color: string | null;
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
}) => {
|
}) {
|
||||||
const [isActive, setActive] = React.useState(false);
|
const [isActive, setActive] = React.useState(false);
|
||||||
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
@@ -252,7 +255,11 @@ export const ColorPicker = ({
|
|||||||
<button
|
<button
|
||||||
className="color-picker-label-swatch"
|
className="color-picker-label-swatch"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
style={color ? { "--swatch-color": color } : undefined}
|
style={
|
||||||
|
color
|
||||||
|
? ({ "--swatch-color": color } as React.CSSProperties)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClick={() => setActive(!isActive)}
|
onClick={() => setActive(!isActive)}
|
||||||
ref={pickerButton}
|
ref={pickerButton}
|
||||||
/>
|
/>
|
||||||
@@ -283,11 +290,10 @@ export const ColorPicker = ({
|
|||||||
}}
|
}}
|
||||||
label={label}
|
label={label}
|
||||||
showInput={false}
|
showInput={false}
|
||||||
type={type}
|
|
||||||
/>
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
) : null}
|
) : null}
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,90 +1,36 @@
|
|||||||
@import "../css/_variables";
|
@import "open-color/open-color.scss";
|
||||||
|
|
||||||
.excalidraw {
|
.context-menu {
|
||||||
.context-menu {
|
position: relative;
|
||||||
position: relative;
|
border-radius: 4px;
|
||||||
border-radius: 4px;
|
box-shadow: 0px 3px 10px transparentize($oc-black, 0.8);
|
||||||
box-shadow: 0px 3px 10px transparentize($oc-black, 0.8);
|
padding: 0;
|
||||||
padding: 0;
|
list-style: none;
|
||||||
list-style: none;
|
user-select: none;
|
||||||
user-select: none;
|
margin: -0.25rem 0 0 0.125rem;
|
||||||
margin: -0.25rem 0 0 0.125rem;
|
padding: 0.25rem 0;
|
||||||
padding: 0.25rem 0;
|
background-color: $oc-gray-1;
|
||||||
background-color: var(--popup-secondary-background-color);
|
border: 1px solid $oc-gray-5;
|
||||||
border: 1px solid var(--button-gray-3);
|
}
|
||||||
}
|
|
||||||
|
.context-menu-option {
|
||||||
.context-menu button {
|
position: relative;
|
||||||
color: var(--popup-text-color);
|
width: 100%;
|
||||||
}
|
min-width: 9.5rem;
|
||||||
|
margin: 0;
|
||||||
.context-menu-option {
|
padding: 0.25rem 1rem 0.25rem 1.25rem;
|
||||||
position: relative;
|
text-align: start;
|
||||||
width: 100%;
|
border-radius: 0;
|
||||||
min-width: 9.5rem;
|
background-color: transparent;
|
||||||
margin: 0;
|
border: none;
|
||||||
padding: 0.25rem 1rem 0.25rem 1.25rem;
|
white-space: nowrap;
|
||||||
text-align: start;
|
}
|
||||||
border-radius: 0;
|
|
||||||
background-color: transparent;
|
.context-menu-option:hover {
|
||||||
border: none;
|
color: $oc-white;
|
||||||
white-space: nowrap;
|
background-color: $oc-blue-5;
|
||||||
|
}
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 0.2fr;
|
.context-menu-option:focus {
|
||||||
align-items: center;
|
z-index: 1;
|
||||||
|
|
||||||
&.checkmark::before {
|
|
||||||
position: absolute;
|
|
||||||
left: 6px;
|
|
||||||
margin-bottom: 1px;
|
|
||||||
content: "\2713";
|
|
||||||
}
|
|
||||||
|
|
||||||
&.dangerous {
|
|
||||||
.context-menu-option__label {
|
|
||||||
color: $oc-red-7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-option__label {
|
|
||||||
justify-self: start;
|
|
||||||
margin-inline-end: 20px;
|
|
||||||
}
|
|
||||||
.context-menu-option__shortcut {
|
|
||||||
justify-self: end;
|
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-option:hover {
|
|
||||||
color: var(--popup-background-color);
|
|
||||||
background-color: var(--select-highlight-color);
|
|
||||||
|
|
||||||
&.dangerous {
|
|
||||||
.context-menu-option__label {
|
|
||||||
color: var(--popup-background-color);
|
|
||||||
}
|
|
||||||
background-color: $oc-red-6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-option:focus {
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media #{$is-mobile-query} {
|
|
||||||
.context-menu-option {
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
.context-menu-option__label {
|
|
||||||
margin-inline-end: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-option__shortcut {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, unmountComponentAtNode } from "react-dom";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
|
import { render, unmountComponentAtNode } from "react-dom";
|
||||||
|
|
||||||
import "./ContextMenu.scss";
|
import "./ContextMenu.scss";
|
||||||
import {
|
|
||||||
getShortcutFromShortcutName,
|
|
||||||
ShortcutName,
|
|
||||||
} from "../actions/shortcuts";
|
|
||||||
|
|
||||||
type ContextMenuOption = {
|
type ContextMenuOption = {
|
||||||
checked?: boolean;
|
|
||||||
shortcutName: ShortcutName;
|
|
||||||
label: string;
|
label: string;
|
||||||
action(): void;
|
action(): void;
|
||||||
};
|
};
|
||||||
@@ -23,58 +16,45 @@ type Props = {
|
|||||||
left: number;
|
left: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
|
function ContextMenu({ options, onCloseRequest, top, left }: Props) {
|
||||||
const isDarkTheme = !!document
|
|
||||||
.querySelector(".excalidraw")
|
|
||||||
?.classList.contains("Appearance_dark");
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Popover
|
||||||
className={clsx("excalidraw", {
|
onCloseRequest={onCloseRequest}
|
||||||
"Appearance_dark Appearance_dark-background-none": isDarkTheme,
|
top={top}
|
||||||
})}
|
left={left}
|
||||||
|
fitInViewport={true}
|
||||||
>
|
>
|
||||||
<Popover
|
<ul
|
||||||
onCloseRequest={onCloseRequest}
|
className="context-menu"
|
||||||
top={top}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
left={left}
|
|
||||||
fitInViewport={true}
|
|
||||||
>
|
>
|
||||||
<ul
|
{options.map((option, idx) => (
|
||||||
className="context-menu"
|
<li key={idx} onClick={onCloseRequest}>
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
<ContextMenuOption {...option} />
|
||||||
>
|
</li>
|
||||||
{options.map(({ action, checked, shortcutName, label }, idx) => (
|
))}
|
||||||
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
|
</ul>
|
||||||
<button
|
</Popover>
|
||||||
className={`context-menu-option
|
|
||||||
${shortcutName === "delete" ? "dangerous" : ""}
|
|
||||||
${checked ? "checkmark" : ""}`}
|
|
||||||
onClick={action}
|
|
||||||
>
|
|
||||||
<div className="context-menu-option__label">{label}</div>
|
|
||||||
<div className="context-menu-option__shortcut">
|
|
||||||
{shortcutName
|
|
||||||
? getShortcutFromShortcutName(shortcutName)
|
|
||||||
: ""}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function ContextMenuOption({ label, action }: ContextMenuOption) {
|
||||||
|
return (
|
||||||
|
<button className="context-menu-option" onClick={action}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let contextMenuNode: HTMLDivElement;
|
let contextMenuNode: HTMLDivElement;
|
||||||
const getContextMenuNode = (): HTMLDivElement => {
|
function getContextMenuNode(): HTMLDivElement {
|
||||||
if (contextMenuNode) {
|
if (contextMenuNode) {
|
||||||
return contextMenuNode;
|
return contextMenuNode;
|
||||||
}
|
}
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
document.body.appendChild(div);
|
document.body.appendChild(div);
|
||||||
return (contextMenuNode = div);
|
return (contextMenuNode = div);
|
||||||
};
|
}
|
||||||
|
|
||||||
type ContextMenuParams = {
|
type ContextMenuParams = {
|
||||||
options: (ContextMenuOption | false | null | undefined)[];
|
options: (ContextMenuOption | false | null | undefined)[];
|
||||||
@@ -82,9 +62,9 @@ type ContextMenuParams = {
|
|||||||
left: number;
|
left: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
function handleClose() {
|
||||||
unmountComponentAtNode(getContextMenuNode());
|
unmountComponentAtNode(getContextMenuNode());
|
||||||
};
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
push(params: ContextMenuParams) {
|
push(params: ContextMenuParams) {
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import "./ToolIcon.scss";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
export type Appearence = "light" | "dark";
|
|
||||||
|
|
||||||
// We chose to use only explicit toggle and not a third option for system value,
|
|
||||||
// but this could be added in the future.
|
|
||||||
export const DarkModeToggle = (props: {
|
|
||||||
value: Appearence;
|
|
||||||
onChange: (value: Appearence) => void;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
className={`ToolIcon ToolIcon_type_floating ToolIcon_size_M`}
|
|
||||||
title={
|
|
||||||
props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
|
|
||||||
type="checkbox"
|
|
||||||
onChange={(event) =>
|
|
||||||
props.onChange(event.target.checked ? "dark" : "light")
|
|
||||||
}
|
|
||||||
checked={props.value === "dark"}
|
|
||||||
aria-label={
|
|
||||||
props.value === "dark"
|
|
||||||
? t("buttons.lightMode")
|
|
||||||
: t("buttons.darkMode")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="ToolIcon__icon">
|
|
||||||
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ICONS = {
|
|
||||||
SUN: (
|
|
||||||
<svg width="512" height="512" className="rtl-mirror" viewBox="0 0 512 512">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M256 160c-52.9 0-96 43.1-96 96s43.1 96 96 96 96-43.1 96-96-43.1-96-96-96zm246.4 80.5l-94.7-47.3 33.5-100.4c4.5-13.6-8.4-26.5-21.9-21.9l-100.4 33.5-47.4-94.8c-6.4-12.8-24.6-12.8-31 0l-47.3 94.7L92.7 70.8c-13.6-4.5-26.5 8.4-21.9 21.9l33.5 100.4-94.7 47.4c-12.8 6.4-12.8 24.6 0 31l94.7 47.3-33.5 100.5c-4.5 13.6 8.4 26.5 21.9 21.9l100.4-33.5 47.3 94.7c6.4 12.8 24.6 12.8 31 0l47.3-94.7 100.4 33.5c13.6 4.5 26.5-8.4 21.9-21.9l-33.5-100.4 94.7-47.3c13-6.5 13-24.7.2-31.1zm-155.9 106c-49.9 49.9-131.1 49.9-181 0-49.9-49.9-49.9-131.1 0-181 49.9-49.9 131.1-49.9 181 0 49.9 49.9 49.9 131.1 0 181z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
MOON: (
|
|
||||||
<svg width="512" height="512" className="rtl-mirror" viewBox="0 0 512 512">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M283.211 512c78.962 0 151.079-35.925 198.857-94.792 7.068-8.708-.639-21.43-11.562-19.35-124.203 23.654-238.262-71.576-238.262-196.954 0-72.222 38.662-138.635 101.498-174.394 9.686-5.512 7.25-20.197-3.756-22.23A258.156 258.156 0 0 0 283.211 0c-141.309 0-256 114.511-256 256 0 141.309 114.511 256 256 256z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
+51
-59
@@ -1,68 +1,60 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/_variables";
|
||||||
|
|
||||||
.excalidraw {
|
.Dialog__title {
|
||||||
.Dialog__title {
|
display: grid;
|
||||||
display: grid;
|
align-items: center;
|
||||||
align-items: center;
|
margin-top: 0;
|
||||||
margin-top: 0;
|
grid-template-columns: 1fr calc(var(--space-factor) * 7);
|
||||||
grid-template-columns: 1fr calc(var(--space-factor) * 7);
|
grid-gap: var(--metric);
|
||||||
grid-gap: var(--metric);
|
}
|
||||||
padding: calc(var(--space-factor) * 2);
|
|
||||||
text-align: center;
|
|
||||||
font-variant: small-caps;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.Dialog__titleContent {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Dialog .Modal__close {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media #{$media-query} {
|
||||||
|
.Dialog {
|
||||||
|
--metric: calc(var(--space-factor) * 4);
|
||||||
|
--inset-left: #{"max(var(--metric), var(--sal))"};
|
||||||
|
--inset-right: #{"max(var(--metric), var(--sar))"};
|
||||||
|
}
|
||||||
|
.Dialog__title {
|
||||||
|
grid-template-columns: calc(var(--space-factor) * 7) 1fr calc(
|
||||||
|
var(--space-factor) * 7
|
||||||
|
);
|
||||||
|
position: sticky;
|
||||||
|
top: calc(-1 * var(--metric));
|
||||||
|
margin: calc(-1 * var(--inset-right));
|
||||||
|
margin-top: calc(-1 * var(--metric));
|
||||||
|
margin-bottom: var(--metric);
|
||||||
|
padding: calc(var(--space-factor) * 2);
|
||||||
|
padding-left: var(--inset-left);
|
||||||
|
padding-right: var(--inset-right);
|
||||||
|
background: white;
|
||||||
|
font-size: 1.25em;
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-bottom: 1px solid $oc-gray-4;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
.Dialog__titleContent {
|
.Dialog__titleContent {
|
||||||
flex: 1;
|
text-align: center;
|
||||||
|
}
|
||||||
|
.Dialog .Island {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-left: #{"max(calc(var(--padding) * var(--space-factor)), var(--sal))"};
|
||||||
|
padding-right: #{"max(calc(var(--padding) * var(--space-factor)), var(--sar))"};
|
||||||
|
padding-bottom: #{"max(calc(var(--padding) * var(--space-factor)), var(--sab))"};
|
||||||
}
|
}
|
||||||
|
|
||||||
.Dialog .Modal__close {
|
.Dialog .Modal__close {
|
||||||
color: var(--icon-fill-color);
|
order: -1;
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Dialog__content {
|
|
||||||
padding: 0 16px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media #{$is-mobile-query} {
|
|
||||||
.Dialog {
|
|
||||||
--metric: calc(var(--space-factor) * 4);
|
|
||||||
--inset-left: #{"max(var(--metric), var(--sal))"};
|
|
||||||
--inset-right: #{"max(var(--metric), var(--sar))"};
|
|
||||||
}
|
|
||||||
|
|
||||||
.Dialog__title {
|
|
||||||
grid-template-columns: calc(var(--space-factor) * 7) 1fr calc(
|
|
||||||
var(--space-factor) * 7
|
|
||||||
);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
padding: calc(var(--space-factor) * 2);
|
|
||||||
background: var(--bg-color-island);
|
|
||||||
font-size: 1.25em;
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-bottom: 1px solid var(--button-gray-2);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Dialog__titleContent {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Dialog .Island {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-left: #{"max(calc(var(--padding) * var(--space-factor)), var(--sal))"};
|
|
||||||
padding-right: #{"max(calc(var(--padding) * var(--space-factor)), var(--sar))"};
|
|
||||||
padding-bottom: #{"max(calc(var(--padding) * var(--space-factor)), var(--sab))"};
|
|
||||||
}
|
|
||||||
|
|
||||||
.Dialog .Modal__close {
|
|
||||||
order: -1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-40
@@ -1,46 +1,39 @@
|
|||||||
import clsx from "clsx";
|
import React, { useEffect, useRef } from "react";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import { Modal } from "./Modal";
|
||||||
|
import { Island } from "./Island";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { KEYS } from "../keys";
|
|
||||||
import "./Dialog.scss";
|
|
||||||
import { back, close } from "./icons";
|
import { back, close } from "./icons";
|
||||||
import { Island } from "./Island";
|
import { KEYS } from "../keys";
|
||||||
import { Modal } from "./Modal";
|
|
||||||
|
|
||||||
const useRefState = <T,>() => {
|
import "./Dialog.scss";
|
||||||
const [refValue, setRefValue] = useState<T | null>(null);
|
|
||||||
const refCallback = useCallback((value: T) => {
|
|
||||||
setRefValue(value);
|
|
||||||
}, []);
|
|
||||||
return [refValue, refCallback] as const;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Dialog = (props: {
|
export function Dialog(props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
small?: boolean;
|
maxWidth?: number;
|
||||||
onCloseRequest(): void;
|
onCloseRequest(): void;
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
autofocus?: boolean;
|
}) {
|
||||||
}) => {
|
const islandRef = useRef<HTMLDivElement>(null);
|
||||||
const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!islandNode) {
|
const focusableElements = queryFocusableElements();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusableElements = queryFocusableElements(islandNode);
|
if (focusableElements.length > 0) {
|
||||||
|
|
||||||
if (focusableElements.length > 0 && props.autofocus !== false) {
|
|
||||||
// If there's an element other than close, focus it.
|
// If there's an element other than close, focus it.
|
||||||
(focusableElements[1] || focusableElements[0]).focus();
|
(focusableElements[1] || focusableElements[0]).focus();
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
useEffect(() => {
|
||||||
|
if (!islandRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key === KEYS.TAB) {
|
if (event.key === KEYS.TAB) {
|
||||||
const focusableElements = queryFocusableElements(islandNode);
|
const focusableElements = queryFocusableElements();
|
||||||
const { activeElement } = document;
|
const { activeElement } = document;
|
||||||
const currentIndex = focusableElements.findIndex(
|
const currentIndex = focusableElements.findIndex(
|
||||||
(element) => element === activeElement,
|
(element) => element === activeElement,
|
||||||
@@ -57,30 +50,31 @@ export const Dialog = (props: {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
islandNode.addEventListener("keydown", handleKeyDown);
|
const node = islandRef.current;
|
||||||
|
node.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
return () => node.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [islandNode, props.autofocus]);
|
}, []);
|
||||||
|
|
||||||
const queryFocusableElements = (node: HTMLElement) => {
|
function queryFocusableElements() {
|
||||||
const focusableElements = node.querySelectorAll<HTMLElement>(
|
const focusableElements = islandRef.current?.querySelectorAll<HTMLElement>(
|
||||||
"button, a, input, select, textarea, div[tabindex]",
|
"button, a, input, select, textarea, div[tabindex]",
|
||||||
);
|
);
|
||||||
|
|
||||||
return focusableElements ? Array.from(focusableElements) : [];
|
return focusableElements ? Array.from(focusableElements) : [];
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
className={clsx("Dialog", props.className)}
|
className={`${props.className ?? ""} Dialog`}
|
||||||
labelledBy="dialog-title"
|
labelledBy="dialog-title"
|
||||||
maxWidth={props.small ? 550 : 800}
|
maxWidth={props.maxWidth}
|
||||||
onCloseRequest={props.onCloseRequest}
|
onCloseRequest={props.onCloseRequest}
|
||||||
>
|
>
|
||||||
<Island ref={setIslandNode}>
|
<Island padding={4} ref={islandRef}>
|
||||||
<h3 id="dialog-title" className="Dialog__title">
|
<h2 id="dialog-title" className="Dialog__title">
|
||||||
<span className="Dialog__titleContent">{props.title}</span>
|
<span className="Dialog__titleContent">{props.title}</span>
|
||||||
<button
|
<button
|
||||||
className="Modal__close"
|
className="Modal__close"
|
||||||
@@ -89,9 +83,9 @@ export const Dialog = (props: {
|
|||||||
>
|
>
|
||||||
{useIsMobile() ? back : close}
|
{useIsMobile() ? back : close}
|
||||||
</button>
|
</button>
|
||||||
</h3>
|
</h2>
|
||||||
<div className="Dialog__content">{props.children}</div>
|
{props.children}
|
||||||
</Island>
|
</Island>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
|
|
||||||
export const ErrorDialog = ({
|
export function ErrorDialog({
|
||||||
message,
|
message,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
message: string;
|
message: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) => {
|
}) {
|
||||||
const [modalIsShown, setModalIsShown] = useState(!!message);
|
const [modalIsShown, setModalIsShown] = useState(!!message);
|
||||||
|
|
||||||
const handleClose = React.useCallback(() => {
|
const handleClose = React.useCallback(() => {
|
||||||
@@ -24,20 +24,13 @@ export const ErrorDialog = ({
|
|||||||
<>
|
<>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
<Dialog
|
<Dialog
|
||||||
small
|
maxWidth={500}
|
||||||
onCloseRequest={handleClose}
|
onCloseRequest={handleClose}
|
||||||
title={t("errorDialog.title")}
|
title={t("errorDialog.title")}
|
||||||
>
|
>
|
||||||
<div>
|
<div>{message}</div>
|
||||||
{message.split("\n").map((line) => (
|
|
||||||
<>
|
|
||||||
{line}
|
|
||||||
<br />
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,69 +1,57 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/_variables";
|
||||||
|
|
||||||
.excalidraw {
|
.ExportDialog__preview {
|
||||||
.ExportDialog__preview {
|
--preview-padding: calc(var(--space-factor) * 4);
|
||||||
--preview-padding: calc(var(--space-factor) * 4);
|
|
||||||
|
|
||||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||||
left center;
|
left center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--preview-padding);
|
padding: var(--preview-padding);
|
||||||
|
margin-bottom: calc(var(--space-factor) * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExportDialog__preview canvas {
|
||||||
|
max-width: calc(100% - var(--preview-padding) * 2);
|
||||||
|
max-height: 25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExportDialog__actions {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
grid-gap: calc(var(--space-factor) * 2);
|
||||||
|
align-items: top;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExportDialog__name {
|
||||||
|
grid-column: project-name;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 550px) {
|
||||||
|
.ExportDialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.ExportDialog__actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ExportDialog__actions > * {
|
||||||
margin-bottom: calc(var(--space-factor) * 3);
|
margin-bottom: calc(var(--space-factor) * 3);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media #{$media-query} {
|
||||||
.ExportDialog__preview canvas {
|
.ExportDialog__preview canvas {
|
||||||
max-width: calc(100% - var(--preview-padding) * 2);
|
max-height: 30vh;
|
||||||
max-height: 25rem;
|
|
||||||
}
|
}
|
||||||
|
.ExportDialog__dialog,
|
||||||
&.Appearance_dark .ExportDialog__preview canvas {
|
.ExportDialog__dialog .Island {
|
||||||
filter: none;
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
.ExportDialog__dialog .Island {
|
||||||
.ExportDialog__actions {
|
overflow-y: auto;
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
grid-gap: calc(var(--space-factor) * 2);
|
|
||||||
align-items: top;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ExportDialog__name {
|
|
||||||
grid-column: project-name;
|
|
||||||
margin: auto;
|
|
||||||
|
|
||||||
.TextInput {
|
|
||||||
height: calc(1rem - 3px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media #{$is-mobile-query} {
|
|
||||||
.ExportDialog {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ExportDialog__actions {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ExportDialog__actions > * {
|
|
||||||
margin-bottom: calc(var(--space-factor) * 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ExportDialog__preview canvas {
|
|
||||||
max-height: 30vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ExportDialog__dialog,
|
|
||||||
.ExportDialog__dialog .Island {
|
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ExportDialog__dialog .Island {
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-118
@@ -1,56 +1,30 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { render, unmountComponentAtNode } from "react-dom";
|
|
||||||
import { ActionsManagerInterface } from "../actions/types";
|
|
||||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
|
||||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
|
||||||
import { canvasToBlob } from "../data/blob";
|
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
|
||||||
import { CanvasError } from "../errors";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import useIsMobile from "../is-mobile";
|
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|
||||||
import { exportToCanvas, getExportSize } from "../scene/export";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { Dialog } from "./Dialog";
|
|
||||||
import "./ExportDialog.scss";
|
import "./ExportDialog.scss";
|
||||||
import { clipboard, exportFile, link } from "./icons";
|
|
||||||
import Stack from "./Stack";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { clipboard, exportFile, link } from "./icons";
|
||||||
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { exportToCanvas } from "../scene/export";
|
||||||
|
import { ActionsManagerInterface } from "../actions/types";
|
||||||
|
import Stack from "./Stack";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||||
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
|
import useIsMobile from "../is-mobile";
|
||||||
|
import { Dialog } from "./Dialog";
|
||||||
|
|
||||||
const scales = [1, 2, 3];
|
const scales = [1, 2, 3];
|
||||||
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
|
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
|
||||||
|
|
||||||
export const ErrorCanvasPreview = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3>{t("canvasError.cannotShowPreview")}</h3>
|
|
||||||
<p>
|
|
||||||
<span>{t("canvasError.canvasTooBig")}</span>
|
|
||||||
</p>
|
|
||||||
<em>({t("canvasError.canvasTooBigTip")})</em>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPreview = (
|
|
||||||
content: HTMLCanvasElement | Error,
|
|
||||||
previewNode: HTMLDivElement,
|
|
||||||
) => {
|
|
||||||
unmountComponentAtNode(previewNode);
|
|
||||||
previewNode.innerHTML = "";
|
|
||||||
if (content instanceof HTMLCanvasElement) {
|
|
||||||
previewNode.appendChild(content);
|
|
||||||
} else {
|
|
||||||
render(<ErrorCanvasPreview />, previewNode);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ExportCB = (
|
export type ExportCB = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
scale?: number,
|
scale?: number,
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
const ExportModal = ({
|
function ExportModal({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
exportPadding = 10,
|
exportPadding = 10,
|
||||||
@@ -67,9 +41,9 @@ const ExportModal = ({
|
|||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
onExportToSvg: ExportCB;
|
onExportToSvg: ExportCB;
|
||||||
onExportToClipboard: ExportCB;
|
onExportToClipboard: ExportCB;
|
||||||
onExportToBackend?: ExportCB;
|
onExportToBackend: ExportCB;
|
||||||
onCloseRequest: () => void;
|
onCloseRequest: () => void;
|
||||||
}) => {
|
}) {
|
||||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||||
const [scale, setScale] = useState(defaultScale);
|
const [scale, setScale] = useState(defaultScale);
|
||||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||||
@@ -90,32 +64,17 @@ const ExportModal = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previewNode = previewRef.current;
|
const previewNode = previewRef.current;
|
||||||
if (!previewNode) {
|
const canvas = exportToCanvas(exportedElements, appState, {
|
||||||
return;
|
exportBackground,
|
||||||
}
|
viewBackgroundColor,
|
||||||
try {
|
exportPadding,
|
||||||
const canvas = exportToCanvas(exportedElements, appState, {
|
scale,
|
||||||
exportBackground,
|
shouldAddWatermark,
|
||||||
viewBackgroundColor,
|
});
|
||||||
exportPadding,
|
previewNode?.appendChild(canvas);
|
||||||
scale,
|
return () => {
|
||||||
shouldAddWatermark,
|
previewNode?.removeChild(canvas);
|
||||||
});
|
};
|
||||||
|
|
||||||
// if converting to blob fails, there's some problem that will
|
|
||||||
// likely prevent preview and export (e.g. canvas too big)
|
|
||||||
canvasToBlob(canvas)
|
|
||||||
.then(() => {
|
|
||||||
renderPreview(canvas, previewNode);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
renderPreview(new CanvasError(), previewNode);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
renderPreview(new CanvasError(), previewNode);
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
appState,
|
appState,
|
||||||
exportedElements,
|
exportedElements,
|
||||||
@@ -128,7 +87,7 @@ const ExportModal = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ExportDialog">
|
<div className="ExportDialog">
|
||||||
<div className="ExportDialog__preview" ref={previewRef} />
|
<div className="ExportDialog__preview" ref={previewRef}></div>
|
||||||
<Stack.Col gap={2} align="center">
|
<Stack.Col gap={2} align="center">
|
||||||
<div className="ExportDialog__actions">
|
<div className="ExportDialog__actions">
|
||||||
<Stack.Row gap={2}>
|
<Stack.Row gap={2}>
|
||||||
@@ -155,47 +114,31 @@ const ExportModal = ({
|
|||||||
onClick={() => onExportToClipboard(exportedElements, scale)}
|
onClick={() => onExportToClipboard(exportedElements, scale)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{onExportToBackend && (
|
<ToolButton
|
||||||
<ToolButton
|
type="button"
|
||||||
type="button"
|
icon={link}
|
||||||
icon={link}
|
title={t("buttons.getShareableLink")}
|
||||||
title={t("buttons.getShareableLink")}
|
aria-label={t("buttons.getShareableLink")}
|
||||||
aria-label={t("buttons.getShareableLink")}
|
onClick={() => onExportToBackend(exportedElements)}
|
||||||
onClick={() => onExportToBackend(exportedElements)}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
<div className="ExportDialog__name">
|
<div className="ExportDialog__name">
|
||||||
{actionManager.renderAction("changeProjectName")}
|
{actionManager.renderAction("changeProjectName")}
|
||||||
</div>
|
</div>
|
||||||
<Stack.Row gap={2}>
|
<Stack.Row gap={2}>
|
||||||
{scales.map((s) => {
|
{scales.map((s) => (
|
||||||
const [width, height] = getExportSize(
|
<ToolButton
|
||||||
exportedElements,
|
key={s}
|
||||||
exportPadding,
|
size="s"
|
||||||
shouldAddWatermark,
|
type="radio"
|
||||||
s,
|
icon={`x${s}`}
|
||||||
);
|
name="export-canvas-scale"
|
||||||
|
aria-label={`Scale ${s} x`}
|
||||||
const scaleButtonTitle = `${t(
|
id="export-canvas-scale"
|
||||||
"buttons.scale",
|
checked={s === scale}
|
||||||
)} ${s}x (${width}x${height})`;
|
onChange={() => setScale(s)}
|
||||||
|
/>
|
||||||
return (
|
))}
|
||||||
<ToolButton
|
|
||||||
key={s}
|
|
||||||
size="s"
|
|
||||||
type="radio"
|
|
||||||
icon={`${s}x`}
|
|
||||||
name="export-canvas-scale"
|
|
||||||
title={scaleButtonTitle}
|
|
||||||
aria-label={scaleButtonTitle}
|
|
||||||
id="export-canvas-scale"
|
|
||||||
checked={s === scale}
|
|
||||||
onChange={() => setScale(s)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</div>
|
</div>
|
||||||
{actionManager.renderAction("changeExportBackground")}
|
{actionManager.renderAction("changeExportBackground")}
|
||||||
@@ -213,14 +156,13 @@ const ExportModal = ({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{actionManager.renderAction("changeExportEmbedScene")}
|
|
||||||
{actionManager.renderAction("changeShouldAddWatermark")}
|
{actionManager.renderAction("changeShouldAddWatermark")}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export const ExportDialog = ({
|
export function ExportDialog({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
exportPadding = 10,
|
exportPadding = 10,
|
||||||
@@ -237,8 +179,8 @@ export const ExportDialog = ({
|
|||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
onExportToSvg: ExportCB;
|
onExportToSvg: ExportCB;
|
||||||
onExportToClipboard: ExportCB;
|
onExportToClipboard: ExportCB;
|
||||||
onExportToBackend?: ExportCB;
|
onExportToBackend: ExportCB;
|
||||||
}) => {
|
}) {
|
||||||
const [modalIsShown, setModalIsShown] = useState(false);
|
const [modalIsShown, setModalIsShown] = useState(false);
|
||||||
const triggerButton = useRef<HTMLButtonElement>(null);
|
const triggerButton = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
@@ -250,10 +192,7 @@ export const ExportDialog = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
onClick={() => {
|
onClick={() => setModalIsShown(true)}
|
||||||
trackEvent(EVENT_DIALOG, "export");
|
|
||||||
setModalIsShown(true);
|
|
||||||
}}
|
|
||||||
icon={exportFile}
|
icon={exportFile}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("buttons.export")}
|
aria-label={t("buttons.export")}
|
||||||
@@ -262,7 +201,11 @@ export const ExportDialog = ({
|
|||||||
ref={triggerButton}
|
ref={triggerButton}
|
||||||
/>
|
/>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
<Dialog
|
||||||
|
maxWidth={800}
|
||||||
|
onCloseRequest={handleClose}
|
||||||
|
title={t("buttons.export")}
|
||||||
|
>
|
||||||
<ExportModal
|
<ExportModal
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
@@ -278,4 +221,4 @@ export const ExportDialog = ({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
.FixedSideContainer {
|
||||||
|
--margin: 0.25rem;
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FixedSideContainer > * {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FixedSideContainer_side_top {
|
||||||
|
left: var(--margin);
|
||||||
|
top: var(--margin);
|
||||||
|
right: var(--margin);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FixedSideContainer_side_top.zen-mode {
|
||||||
|
right: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: if these are used, make sure to implement RTL support
|
||||||
|
.FixedSideContainer_side_left {
|
||||||
|
left: var(--margin);
|
||||||
|
top: var(--margin);
|
||||||
|
bottom: var(--margin);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FixedSideContainer_side_right {
|
||||||
|
right: var(--margin);
|
||||||
|
top: var(--margin);
|
||||||
|
bottom: var(--margin);
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
*/
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
.excalidraw {
|
|
||||||
.FixedSideContainer {
|
|
||||||
--margin: 0.25rem;
|
|
||||||
position: absolute;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.FixedSideContainer > * {
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.FixedSideContainer_side_top {
|
|
||||||
left: var(--margin);
|
|
||||||
top: var(--margin);
|
|
||||||
right: var(--margin);
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.FixedSideContainer_side_top.zen-mode {
|
|
||||||
right: 42px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* TODO: if these are used, make sure to implement RTL support
|
|
||||||
.FixedSideContainer_side_left {
|
|
||||||
left: var(--margin);
|
|
||||||
top: var(--margin);
|
|
||||||
bottom: var(--margin);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.FixedSideContainer_side_right {
|
|
||||||
right: var(--margin);
|
|
||||||
top: var(--margin);
|
|
||||||
bottom: var(--margin);
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import "./FixedSideContainer.scss";
|
import "./FixedSideContainer.css";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
type FixedSideContainerProps = {
|
type FixedSideContainerProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -9,18 +8,16 @@ type FixedSideContainerProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FixedSideContainer = ({
|
export function FixedSideContainer({
|
||||||
children,
|
children,
|
||||||
side,
|
side,
|
||||||
className,
|
className,
|
||||||
}: FixedSideContainerProps) => (
|
}: FixedSideContainerProps) {
|
||||||
<div
|
return (
|
||||||
className={clsx(
|
<div
|
||||||
"FixedSideContainer",
|
className={`FixedSideContainer FixedSideContainer_side_${side} ${className}`}
|
||||||
`FixedSideContainer_side_${side}`,
|
>
|
||||||
className,
|
{children}
|
||||||
)}
|
</div>
|
||||||
>
|
);
|
||||||
{children}
|
}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,42 +1,33 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { EVENT_EXIT, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
// https://github.com/tholman/github-corners
|
// https://github.com/tholman/github-corners
|
||||||
export const GitHubCorner = React.memo(
|
export const GitHubCorner = React.memo(() => (
|
||||||
({ appearance }: { appearance: "light" | "dark" }) => (
|
<svg
|
||||||
<svg
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
width="40"
|
||||||
width="40"
|
height="40"
|
||||||
height="40"
|
viewBox="0 0 250 250"
|
||||||
viewBox="0 0 250 250"
|
className="github-corner rtl-mirror"
|
||||||
className="github-corner rtl-mirror"
|
>
|
||||||
|
<a
|
||||||
|
href="https://github.com/excalidraw/excalidraw"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="GitHub repository"
|
||||||
>
|
>
|
||||||
<a
|
<path d="M0 0l115 115h15l12 27 108 108V0z" fill={oc.gray[6]} />
|
||||||
href="https://github.com/excalidraw/excalidraw"
|
<path
|
||||||
target="_blank"
|
className="octo-arm"
|
||||||
rel="noopener noreferrer"
|
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
|
||||||
aria-label="GitHub repository"
|
style={{ transformOrigin: "130px 106px" }}
|
||||||
onClick={() => {
|
fill={oc.white}
|
||||||
trackEvent(EVENT_EXIT, "github");
|
/>
|
||||||
}}
|
<path
|
||||||
>
|
className="octo-body"
|
||||||
<path
|
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
|
||||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
fill={oc.white}
|
||||||
fill={appearance === "light" ? oc.gray[6] : oc.gray[8]}
|
/>
|
||||||
/>
|
</a>
|
||||||
<path
|
</svg>
|
||||||
className="octo-arm"
|
));
|
||||||
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
|
|
||||||
style={{ transformOrigin: "130px 106px" }}
|
|
||||||
fill={appearance === "light" ? oc.white : oc.black}
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="octo-body"
|
|
||||||
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
|
|
||||||
fill={appearance === "light" ? oc.white : oc.black}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ const ICON = (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const HelpIcon = (props: HelpIconProps) => (
|
export function HelpIcon(props: HelpIconProps) {
|
||||||
<label title={`${props.title} — ?`} className="help-icon">
|
return (
|
||||||
<div onClick={props.onClick}>{ICON}</div>
|
<label title={`${props.title} — ?`} className="help-icon">
|
||||||
</label>
|
<div onClick={props.onClick}>{ICON}</div>
|
||||||
);
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,33 +1,24 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/_variables";
|
||||||
|
|
||||||
// this is loosely based on the longest hint text
|
.HintViewer {
|
||||||
$wide-viewport-width: 1000px;
|
color: $oc-gray-6;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
left: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 54px;
|
||||||
|
transform: translateX(calc(-50% - 16px)); /* 16px is half of lock icon */
|
||||||
|
white-space: pre;
|
||||||
|
text-align: center;
|
||||||
|
@media #{$media-query} {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.excalidraw {
|
> span {
|
||||||
.HintViewer {
|
background-color: transparentize($oc-white, 0.12);
|
||||||
pointer-events: none;
|
padding: 0.2rem 0.4rem;
|
||||||
box-sizing: border-box;
|
border-radius: 3px;
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
left: 0;
|
|
||||||
top: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 6px;
|
|
||||||
text-align: center;
|
|
||||||
color: $oc-gray-6;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
|
|
||||||
@media #{$is-mobile-query} {
|
|
||||||
position: static;
|
|
||||||
padding-right: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
> span {
|
|
||||||
padding: 0.2rem 0.4rem;
|
|
||||||
background-color: var(--overlay-background-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { getSelectedElements } from "../scene";
|
|||||||
import "./HintViewer.scss";
|
import "./HintViewer.scss";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { getShortcutKey } from "../utils";
|
|
||||||
|
|
||||||
interface Hint {
|
interface Hint {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
@@ -27,10 +26,6 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
return t("hints.freeDraw");
|
return t("hints.freeDraw");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elementType === "text") {
|
|
||||||
return t("hints.text");
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
if (
|
if (
|
||||||
isResizing &&
|
isResizing &&
|
||||||
@@ -38,8 +33,8 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
selectedElements.length === 1
|
selectedElements.length === 1
|
||||||
) {
|
) {
|
||||||
const targetElement = selectedElements[0];
|
const targetElement = selectedElements[0];
|
||||||
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
if (isLinearElement(targetElement) && targetElement.points.length > 2) {
|
||||||
return t("hints.lockAngle");
|
return null;
|
||||||
}
|
}
|
||||||
return t("hints.resize");
|
return t("hints.resize");
|
||||||
}
|
}
|
||||||
@@ -48,20 +43,11 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HintViewer = ({ appState, elements }: Hint) => {
|
export const HintViewer = ({ appState, elements }: Hint) => {
|
||||||
let hint = getHints({
|
const hint = getHints({
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
});
|
});
|
||||||
@@ -69,8 +55,6 @@ export const HintViewer = ({ appState, elements }: Hint) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
hint = getShortcutKey(hint);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="HintViewer">
|
<div className="HintViewer">
|
||||||
<span>{hint}</span>
|
<span>{hint}</span>
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
@import "../css/_variables";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.picker-container {
|
|
||||||
display: inline-block;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker {
|
|
||||||
background: var(--popup-background-color);
|
|
||||||
border: 0px solid transparentize($oc-white, 0.75);
|
|
||||||
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-container button,
|
|
||||||
.picker button {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: transparent;
|
|
||||||
background-color: var(--button-gray-2);
|
|
||||||
& svg {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--button-gray-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background-color: var(--button-gray-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
margin: 0;
|
|
||||||
width: 36px;
|
|
||||||
height: 18px;
|
|
||||||
opacity: 0.6;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker button {
|
|
||||||
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-triangle {
|
|
||||||
width: 0px;
|
|
||||||
height: 0px;
|
|
||||||
position: relative;
|
|
||||||
top: -10px;
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
left: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
right: 12px;
|
|
||||||
}
|
|
||||||
z-index: 10;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 0px 9px 10px;
|
|
||||||
border-color: transparent transparent transparentize($oc-black, 0.9);
|
|
||||||
top: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 0px 9px 10px;
|
|
||||||
border-color: transparent transparent var(--popup-background-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-content {
|
|
||||||
padding: 0.5rem;
|
|
||||||
display: grid;
|
|
||||||
grid-auto-flow: column;
|
|
||||||
grid-gap: 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
padding: 0.4rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-keybinding {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 2px;
|
|
||||||
font-size: 0.7em;
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
right: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: 2px;
|
|
||||||
}
|
|
||||||
@media #{$is-mobile-query} {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-type-canvasBackground .picker-keybinding {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-type-elementBackground .picker-keybinding {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-swatch[aria-label="transparent"] .picker-keybinding {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-type-elementStroke .picker-keybinding {
|
|
||||||
color: #d4d4d4;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Appearance_dark {
|
|
||||||
.picker-type-elementBackground .picker-keybinding {
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
.picker-swatch[aria-label="transparent"] .picker-keybinding {
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Popover } from "./Popover";
|
|
||||||
|
|
||||||
import "./IconPicker.scss";
|
|
||||||
import { isArrowKey, KEYS } from "../keys";
|
|
||||||
import { getLanguage } from "../i18n";
|
|
||||||
|
|
||||||
function Picker<T>({
|
|
||||||
options,
|
|
||||||
value,
|
|
||||||
label,
|
|
||||||
onChange,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: T;
|
|
||||||
options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const rFirstItem = React.useRef<HTMLButtonElement>();
|
|
||||||
const rActiveItem = React.useRef<HTMLButtonElement>();
|
|
||||||
const rGallery = React.useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
// After the component is first mounted focus on first input
|
|
||||||
if (rActiveItem.current) {
|
|
||||||
rActiveItem.current.focus();
|
|
||||||
} else if (rGallery.current) {
|
|
||||||
rGallery.current.focus();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
||||||
const pressedOption = options.find(
|
|
||||||
(option) => option.keyBinding === event.key.toLowerCase(),
|
|
||||||
)!;
|
|
||||||
|
|
||||||
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
|
||||||
// Keybinding navigation
|
|
||||||
const index = options.indexOf(pressedOption);
|
|
||||||
(rGallery!.current!.children![index] as any).focus();
|
|
||||||
event.preventDefault();
|
|
||||||
} else if (event.key === KEYS.TAB) {
|
|
||||||
// Tab navigation cycle through options. If the user tabs
|
|
||||||
// away from the picker, close the picker. We need to use
|
|
||||||
// a timeout here to let the stack clear before checking.
|
|
||||||
setTimeout(() => {
|
|
||||||
const active = rActiveItem.current;
|
|
||||||
const docActive = document.activeElement;
|
|
||||||
if (active !== docActive) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
} else if (isArrowKey(event.key)) {
|
|
||||||
// Arrow navigation
|
|
||||||
const { activeElement } = document;
|
|
||||||
const isRTL = getLanguage().rtl;
|
|
||||||
const index = Array.prototype.indexOf.call(
|
|
||||||
rGallery!.current!.children,
|
|
||||||
activeElement,
|
|
||||||
);
|
|
||||||
if (index !== -1) {
|
|
||||||
const length = options.length;
|
|
||||||
let nextIndex = index;
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
// Select the next option
|
|
||||||
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
|
|
||||||
case KEYS.ARROW_DOWN: {
|
|
||||||
nextIndex = (index + 1) % length;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Select the previous option
|
|
||||||
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
|
|
||||||
case KEYS.ARROW_UP: {
|
|
||||||
nextIndex = (length + index - 1) % length;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(rGallery.current!.children![nextIndex] as any).focus();
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
|
||||||
// Close on escape or enter
|
|
||||||
event.preventDefault();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
event.nativeEvent.stopImmediatePropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`picker`}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={label}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
<div className="picker-content" ref={rGallery}>
|
|
||||||
{options.map((option, i) => (
|
|
||||||
<button
|
|
||||||
className="picker-option"
|
|
||||||
onClick={(event) => {
|
|
||||||
(event.currentTarget as HTMLButtonElement).focus();
|
|
||||||
onChange(option.value);
|
|
||||||
}}
|
|
||||||
title={`${option.text} — ${option.keyBinding.toUpperCase()}`}
|
|
||||||
aria-label={option.text || "none"}
|
|
||||||
aria-keyshortcuts={option.keyBinding}
|
|
||||||
key={option.text}
|
|
||||||
ref={(el) => {
|
|
||||||
if (el && i === 0) {
|
|
||||||
rFirstItem.current = el;
|
|
||||||
}
|
|
||||||
if (el && option.value === value) {
|
|
||||||
rActiveItem.current = el;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
onChange(option.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.icon}
|
|
||||||
<span className="picker-keybinding">{option.keyBinding}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IconPicker<T>({
|
|
||||||
value,
|
|
||||||
label,
|
|
||||||
options,
|
|
||||||
onChange,
|
|
||||||
group = "",
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: T;
|
|
||||||
options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
group?: string;
|
|
||||||
}) {
|
|
||||||
const [isActive, setActive] = React.useState(false);
|
|
||||||
const rPickerButton = React.useRef<any>(null);
|
|
||||||
const isRTL = getLanguage().rtl;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className={"picker-container"}>
|
|
||||||
<button
|
|
||||||
name={group}
|
|
||||||
className={isActive ? "active" : ""}
|
|
||||||
aria-label={label}
|
|
||||||
onClick={() => setActive(!isActive)}
|
|
||||||
ref={rPickerButton}
|
|
||||||
>
|
|
||||||
{options.find((option) => option.value === value)?.icon}
|
|
||||||
</button>
|
|
||||||
<React.Suspense fallback="">
|
|
||||||
{isActive ? (
|
|
||||||
<>
|
|
||||||
<Popover
|
|
||||||
onCloseRequest={(event) =>
|
|
||||||
event.target !== rPickerButton.current && setActive(false)
|
|
||||||
}
|
|
||||||
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
|
|
||||||
>
|
|
||||||
<Picker
|
|
||||||
options={options}
|
|
||||||
value={value}
|
|
||||||
label={label}
|
|
||||||
onChange={onChange}
|
|
||||||
onClose={() => {
|
|
||||||
setActive(false);
|
|
||||||
rPickerButton.current?.focus();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
<div className="picker-triangle" />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</React.Suspense>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
|
||||||
import {
|
|
||||||
defaultLang,
|
|
||||||
Language,
|
|
||||||
languages,
|
|
||||||
setLanguageFirstTime,
|
|
||||||
} from "../i18n";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
langCode: Language["code"];
|
|
||||||
}
|
|
||||||
interface State {
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
export class InitializeApp extends React.Component<Props, State> {
|
|
||||||
public state: { isLoading: boolean } = {
|
|
||||||
isLoading: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
async componentDidMount() {
|
|
||||||
const currentLang =
|
|
||||||
languages.find((lang) => lang.code === this.props.langCode) ||
|
|
||||||
defaultLang;
|
|
||||||
await setLanguageFirstTime(currentLang);
|
|
||||||
this.setState({
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+11
-13
@@ -1,16 +1,14 @@
|
|||||||
.excalidraw {
|
.Island {
|
||||||
.Island {
|
--padding: 0;
|
||||||
--padding: 0;
|
background-color: var(--bg-color-island);
|
||||||
background-color: var(--bg-color-island);
|
backdrop-filter: saturate(100%) blur(10px);
|
||||||
backdrop-filter: saturate(100%) blur(10px);
|
box-shadow: var(--shadow-island);
|
||||||
box-shadow: var(--shadow-island);
|
border-radius: var(--border-radius-m);
|
||||||
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;
|
|
||||||
|
|
||||||
&.zen-mode {
|
&.zen-mode {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import "./Island.scss";
|
import "./Island.scss";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
type IslandProps = {
|
type IslandProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -13,8 +12,8 @@ type IslandProps = {
|
|||||||
export const Island = React.forwardRef<HTMLDivElement, IslandProps>(
|
export const Island = React.forwardRef<HTMLDivElement, IslandProps>(
|
||||||
({ children, padding, className, style }, ref) => (
|
({ children, padding, className, style }, ref) => (
|
||||||
<div
|
<div
|
||||||
className={clsx("Island", className)}
|
className={`${className ?? ""} Island`}
|
||||||
style={{ "--padding": padding, ...style }}
|
style={{ "--padding": padding, ...style } as React.CSSProperties}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React from "react";
|
||||||
|
import * as i18n from "../i18n";
|
||||||
|
|
||||||
|
export function LanguageList({
|
||||||
|
onChange,
|
||||||
|
languages = i18n.languages,
|
||||||
|
currentLanguage = i18n.getLanguage().lng,
|
||||||
|
floating,
|
||||||
|
}: {
|
||||||
|
languages?: { lng: string; label: string }[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
currentLanguage?: string;
|
||||||
|
floating?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<select
|
||||||
|
className={`dropdown-select dropdown-select__language${
|
||||||
|
floating ? " dropdown-select--floating" : ""
|
||||||
|
}`}
|
||||||
|
onChange={({ target }) => onChange(target.value)}
|
||||||
|
value={currentLanguage}
|
||||||
|
aria-label={i18n.t("buttons.selectLanguage")}
|
||||||
|
>
|
||||||
|
{languages.map((language) => (
|
||||||
|
<option key={language.lng} value={language.lng}>
|
||||||
|
{language.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
+121
-127
@@ -1,139 +1,133 @@
|
|||||||
@import "open-color/open-color";
|
@import "open-color/open-color";
|
||||||
|
|
||||||
.excalidraw {
|
.layer-ui__wrapper {
|
||||||
.layer-ui__library {
|
.encrypted-icon {
|
||||||
margin: auto;
|
position: relative;
|
||||||
|
margin-inline-start: 15px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--space-factor);
|
||||||
|
color: $oc-green-9;
|
||||||
|
|
||||||
.layer-ui__library-header {
|
svg {
|
||||||
display: flex;
|
width: 1.2rem;
|
||||||
align-items: center;
|
height: 1.2rem;
|
||||||
width: 100%;
|
|
||||||
margin: 2px 0;
|
|
||||||
|
|
||||||
button {
|
|
||||||
// 2px from the left to account for focus border of left-most button
|
|
||||||
margin: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
margin-left: auto;
|
|
||||||
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
|
||||||
padding-right: 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 {
|
|
||||||
.encrypted-icon {
|
|
||||||
position: relative;
|
|
||||||
margin-inline-start: 15px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: var(--space-factor);
|
|
||||||
color: $oc-green-9;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 1.2rem;
|
|
||||||
height: 1.2rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__github-corner {
|
&.tooltip .tooltip-text {
|
||||||
top: 0;
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__footer {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 100;
|
|
||||||
bottom: 0px;
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
width: 190px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zen-mode-transition {
|
|
||||||
transition: transform 0.5s ease-in-out;
|
|
||||||
|
|
||||||
:root[dir="ltr"] &.transition-left {
|
|
||||||
transform: translate(-999px, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="ltr"] &.transition-right {
|
|
||||||
transform: translate(999px, 0px);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] &.transition-left {
|
|
||||||
transform: translate(999px, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] &.transition-right {
|
|
||||||
transform: translate(-999px, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="ltr"] &.App-menu_bottom--transition-left {
|
|
||||||
transform: translate(-92px, 0);
|
|
||||||
}
|
|
||||||
:root[dir="rtl"] &.App-menu_bottom--transition-left {
|
|
||||||
transform: translate(92px, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.disable-zen-mode {
|
|
||||||
height: 30px;
|
|
||||||
position: absolute;
|
|
||||||
bottom: 10px;
|
|
||||||
[dir="ltr"] & {
|
|
||||||
right: 15px;
|
|
||||||
}
|
|
||||||
[dir="rtl"] & {
|
|
||||||
left: 15px;
|
|
||||||
}
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition: visibility 0s linear 0s, opacity 0.5s;
|
width: 20rem;
|
||||||
|
bottom: calc(50% + 0.8rem + 6px);
|
||||||
&--visible {
|
:root[dir="ltr"] & {
|
||||||
opacity: 1;
|
left: -5px;
|
||||||
visibility: visible;
|
|
||||||
transition: visibility 0s linear 300ms, opacity 0.5s;
|
|
||||||
transition-delay: 0.8s;
|
|
||||||
}
|
}
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
right: -5px;
|
||||||
|
}
|
||||||
|
background-color: $oc-black;
|
||||||
|
color: $oc-white;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
--size: 6px;
|
||||||
|
content: "";
|
||||||
|
border: var(--size) solid transparent;
|
||||||
|
border-top-color: $oc-black;
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(-2 * var(--size));
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
left: calc(5px + var(--size) / 2);
|
||||||
|
}
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
right: calc(5px + var(--size) / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the following 3 rules ensure that the tooltip doesn't show (nor affect
|
||||||
|
// the cursor) when you drag over when you draw on canvas, but at the same
|
||||||
|
// time it still works when clicking on the link/shield
|
||||||
|
|
||||||
|
body:active &.tooltip:not(:hover) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
body:not(:active) &.tooltip:hover .tooltip-text {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.tooltip-text:hover {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__github-corner {
|
||||||
|
top: 0;
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
position: absolute;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0px;
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
width: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-mode-transition {
|
||||||
|
transition: transform 0.5s ease-in-out;
|
||||||
|
:root[dir="ltr"] &.transition-left {
|
||||||
|
transform: translate(-999px, 0);
|
||||||
|
}
|
||||||
|
:root[dir="ltr"] &.transition-right {
|
||||||
|
transform: translate(999px, 0px);
|
||||||
|
}
|
||||||
|
:root[dir="rtl"] &.transition-left {
|
||||||
|
transform: translate(999px, 0);
|
||||||
|
}
|
||||||
|
:root[dir="rtl"] &.transition-right {
|
||||||
|
transform: translate(-999px, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.App-menu_bottom--transition-left {
|
||||||
|
transform: translate(-92px, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.disable-zen-mode {
|
||||||
|
height: 30px;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
right: 15px;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: visibility 0s linear 0s, opacity 0.5s;
|
||||||
|
|
||||||
|
&--visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transition: visibility 0s linear 300ms, opacity 0.5s;
|
||||||
|
transition-delay: 0.8s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-453
@@ -1,25 +1,19 @@
|
|||||||
import React, {
|
import React from "react";
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
RefObject,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
} from "react";
|
|
||||||
import { showSelectedShapeActions } from "../element";
|
import { showSelectedShapeActions } from "../element";
|
||||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { exportCanvas } from "../data";
|
import { exportCanvas } from "../data";
|
||||||
|
|
||||||
import { AppState, LibraryItems, LibraryItem } from "../types";
|
import { AppState } from "../types";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
|
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
import { UserList } from "./UserList";
|
|
||||||
import { LockIcon } from "./LockIcon";
|
import { LockIcon } from "./LockIcon";
|
||||||
import { ExportDialog, ExportCB } from "./ExportDialog";
|
import { ExportDialog, ExportCB } from "./ExportDialog";
|
||||||
import { Language, t } from "../i18n";
|
import { LanguageList } from "./LanguageList";
|
||||||
|
import { t, languages, setLanguage } from "../i18n";
|
||||||
import { HintViewer } from "./HintViewer";
|
import { HintViewer } from "./HintViewer";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
|
|
||||||
@@ -27,338 +21,76 @@ import { ExportType } from "../scene/types";
|
|||||||
import { MobileMenu } from "./MobileMenu";
|
import { MobileMenu } from "./MobileMenu";
|
||||||
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import CollabButton from "./CollabButton";
|
import { RoomDialog } from "./RoomDialog";
|
||||||
import { ErrorDialog } from "./ErrorDialog";
|
import { ErrorDialog } from "./ErrorDialog";
|
||||||
import { ShortcutsDialog } from "./ShortcutsDialog";
|
import { ShortcutsDialog } from "./ShortcutsDialog";
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { CLASSES } from "../constants";
|
import { CLASSES } from "../constants";
|
||||||
import { shield, exportFile, load } from "./icons";
|
import { shield } from "./icons";
|
||||||
import { GitHubCorner } from "./GitHubCorner";
|
import { GitHubCorner } from "./GitHubCorner";
|
||||||
import { Tooltip } from "./Tooltip";
|
|
||||||
|
|
||||||
import "./LayerUI.scss";
|
import "./LayerUI.scss";
|
||||||
import { LibraryUnit } from "./LibraryUnit";
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
import { saveLibraryAsJSON, importLibraryFromJSON } from "../data/json";
|
|
||||||
import { muteFSAbortError } from "../utils";
|
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { Library } from "../data/library";
|
|
||||||
import {
|
|
||||||
EVENT_ACTION,
|
|
||||||
EVENT_EXIT,
|
|
||||||
EVENT_LIBRARY,
|
|
||||||
trackEvent,
|
|
||||||
} from "../analytics";
|
|
||||||
import { PasteChartDialog } from "./PasteChartDialog";
|
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: any;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
onCollabButtonClick?: () => void;
|
onRoomCreate: () => void;
|
||||||
|
onUsernameChange: (username: string) => void;
|
||||||
|
onRoomDestroy: () => void;
|
||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
|
||||||
zenModeEnabled: boolean;
|
zenModeEnabled: boolean;
|
||||||
toggleZenMode: () => void;
|
toggleZenMode: () => void;
|
||||||
langCode: Language["code"];
|
|
||||||
isCollaborating: boolean;
|
|
||||||
onExportToBackend?: (
|
|
||||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
canvas: HTMLCanvasElement | null,
|
|
||||||
) => void;
|
|
||||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = ({
|
|
||||||
library,
|
|
||||||
onRemoveFromLibrary,
|
|
||||||
onAddToLibrary,
|
|
||||||
onInsertShape,
|
|
||||||
pendingElements,
|
|
||||||
setAppState,
|
|
||||||
}: {
|
|
||||||
library: LibraryItems;
|
|
||||||
pendingElements: LibraryItem;
|
|
||||||
onRemoveFromLibrary: (index: number) => void;
|
|
||||||
onInsertShape: (elements: LibraryItem) => void;
|
|
||||||
onAddToLibrary: (elements: LibraryItem) => void;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
}) => {
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const numCells = library.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;
|
|
||||||
|
|
||||||
rows.push(
|
|
||||||
<div className="layer-ui__library-header">
|
|
||||||
<ToolButton
|
|
||||||
key="import"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.load")}
|
|
||||||
aria-label={t("buttons.load")}
|
|
||||||
icon={load}
|
|
||||||
onClick={() => {
|
|
||||||
importLibraryFromJSON()
|
|
||||||
.then(() => {
|
|
||||||
// Maybe we should close and open the menu so that the items get updated.
|
|
||||||
// But for now we just close the menu.
|
|
||||||
setAppState({ isLibraryOpen: false });
|
|
||||||
})
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ToolButton
|
|
||||||
key="export"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.export")}
|
|
||||||
aria-label={t("buttons.export")}
|
|
||||||
icon={exportFile}
|
|
||||||
onClick={() => {
|
|
||||||
saveLibraryAsJSON()
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://libraries.excalidraw.com"
|
|
||||||
target="_excalidraw_libraries"
|
|
||||||
onClick={() => {
|
|
||||||
trackEvent(EVENT_EXIT, "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 >= library.length;
|
|
||||||
addedPendingElements = addedPendingElements || shouldAddPendingElements;
|
|
||||||
|
|
||||||
children.push(
|
|
||||||
<Stack.Col key={x}>
|
|
||||||
<LibraryUnit
|
|
||||||
elements={library[y + x]}
|
|
||||||
pendingElements={
|
|
||||||
shouldAddPendingElements ? pendingElements : undefined
|
|
||||||
}
|
|
||||||
onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
|
|
||||||
onClick={
|
|
||||||
shouldAddPendingElements
|
|
||||||
? onAddToLibrary.bind(null, pendingElements)
|
|
||||||
: onInsertShape.bind(null, library[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,
|
|
||||||
setAppState,
|
|
||||||
}: {
|
|
||||||
pendingElements: LibraryItem;
|
|
||||||
onClickOutside: (event: MouseEvent) => void;
|
|
||||||
onInsertShape: (elements: LibraryItem) => void;
|
|
||||||
onAddToLibrary: () => void;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
}) => {
|
|
||||||
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!);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeFromLibrary = useCallback(async (indexToRemove) => {
|
|
||||||
const items = await Library.loadLibrary();
|
|
||||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
|
||||||
Library.saveLibrary(nextItems);
|
|
||||||
trackEvent(EVENT_LIBRARY, "remove");
|
|
||||||
setLibraryItems(nextItems);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addToLibrary = useCallback(
|
|
||||||
async (elements: LibraryItem) => {
|
|
||||||
const items = await Library.loadLibrary();
|
|
||||||
const nextItems = [...items, elements];
|
|
||||||
onAddToLibrary();
|
|
||||||
trackEvent(EVENT_LIBRARY, "add");
|
|
||||||
Library.saveLibrary(nextItems);
|
|
||||||
setLibraryItems(nextItems);
|
|
||||||
},
|
|
||||||
[onAddToLibrary],
|
|
||||||
);
|
|
||||||
|
|
||||||
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
|
|
||||||
library={libraryItems}
|
|
||||||
onRemoveFromLibrary={removeFromLibrary}
|
|
||||||
onAddToLibrary={addToLibrary}
|
|
||||||
onInsertShape={onInsertShape}
|
|
||||||
pendingElements={pendingElements}
|
|
||||||
setAppState={setAppState}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Island>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LayerUI = ({
|
const LayerUI = ({
|
||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
setAppState,
|
setAppState,
|
||||||
canvas,
|
canvas,
|
||||||
elements,
|
elements,
|
||||||
onCollabButtonClick,
|
onRoomCreate,
|
||||||
|
onUsernameChange,
|
||||||
|
onRoomDestroy,
|
||||||
onLockToggle,
|
onLockToggle,
|
||||||
onInsertElements,
|
|
||||||
zenModeEnabled,
|
zenModeEnabled,
|
||||||
toggleZenMode,
|
toggleZenMode,
|
||||||
isCollaborating,
|
|
||||||
onExportToBackend,
|
|
||||||
renderCustomFooter,
|
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const renderEncryptedIcon = () => (
|
const renderEncryptedIcon = () => (
|
||||||
<a
|
<a
|
||||||
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
|
className={`encrypted-icon tooltip zen-mode-visibility ${
|
||||||
"zen-mode-visibility--hidden": zenModeEnabled,
|
zenModeEnabled ? "zen-mode-visibility--hidden" : ""
|
||||||
})}
|
}`}
|
||||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={() => {
|
|
||||||
trackEvent(EVENT_EXIT, "e2ee shield");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
|
<span className="tooltip-text" dir="auto">
|
||||||
{shield}
|
{t("encrypted.tooltip")}
|
||||||
</Tooltip>
|
</span>
|
||||||
|
{shield}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderExportDialog = () => {
|
const renderExportDialog = () => {
|
||||||
const createExporter = (type: ExportType): ExportCB => async (
|
const createExporter = (type: ExportType): ExportCB => (
|
||||||
exportedElements,
|
exportedElements,
|
||||||
scale,
|
scale,
|
||||||
) => {
|
) => {
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
await exportCanvas(type, exportedElements, appState, canvas, {
|
exportCanvas(type, exportedElements, appState, canvas, {
|
||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
name: appState.name,
|
name: appState.name,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
scale,
|
scale,
|
||||||
shouldAddWatermark: appState.shouldAddWatermark,
|
shouldAddWatermark: appState.shouldAddWatermark,
|
||||||
})
|
});
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExportDialog
|
<ExportDialog
|
||||||
elements={elements}
|
elements={elements}
|
||||||
@@ -367,14 +99,20 @@ const LayerUI = ({
|
|||||||
onExportToPng={createExporter("png")}
|
onExportToPng={createExporter("png")}
|
||||||
onExportToSvg={createExporter("svg")}
|
onExportToSvg={createExporter("svg")}
|
||||||
onExportToClipboard={createExporter("clipboard")}
|
onExportToClipboard={createExporter("clipboard")}
|
||||||
onExportToBackend={
|
onExportToBackend={(exportedElements) => {
|
||||||
onExportToBackend
|
if (canvas) {
|
||||||
? (elements) => {
|
exportCanvas(
|
||||||
onExportToBackend &&
|
"backend",
|
||||||
onExportToBackend(elements, appState, canvas);
|
exportedElements,
|
||||||
}
|
{
|
||||||
: undefined
|
...appState,
|
||||||
}
|
selectedElementIds: {},
|
||||||
|
},
|
||||||
|
canvas,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -382,33 +120,27 @@ const LayerUI = ({
|
|||||||
const renderCanvasActions = () => (
|
const renderCanvasActions = () => (
|
||||||
<Section
|
<Section
|
||||||
heading="canvasActions"
|
heading="canvasActions"
|
||||||
className={clsx("zen-mode-transition", {
|
className={`zen-mode-transition ${zenModeEnabled && "transition-left"}`}
|
||||||
"transition-left": zenModeEnabled,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{/* the zIndex ensures this menu has higher stacking order,
|
{/* the zIndex ensures this menu has higher stacking order,
|
||||||
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||||
<Island padding={2} style={{ zIndex: 1 }}>
|
<Island padding={4} style={{ zIndex: 1 }}>
|
||||||
<Stack.Col gap={4}>
|
<Stack.Col gap={4}>
|
||||||
<Stack.Row gap={1} justifyContent="space-between">
|
<Stack.Row gap={1} justifyContent="space-between">
|
||||||
{actionManager.renderAction("loadScene")}
|
{actionManager.renderAction("loadScene")}
|
||||||
{actionManager.renderAction("saveScene")}
|
{actionManager.renderAction("saveScene")}
|
||||||
{actionManager.renderAction("saveAsScene")}
|
|
||||||
{renderExportDialog()}
|
{renderExportDialog()}
|
||||||
{actionManager.renderAction("clearCanvas")}
|
{actionManager.renderAction("clearCanvas")}
|
||||||
{onCollabButtonClick && (
|
<RoomDialog
|
||||||
<CollabButton
|
isCollaborating={appState.isCollaborating}
|
||||||
isCollaborating={isCollaborating}
|
collaboratorCount={appState.collaborators.size}
|
||||||
collaboratorCount={appState.collaborators.size}
|
username={appState.username}
|
||||||
onClick={onCollabButtonClick}
|
onUsernameChange={onUsernameChange}
|
||||||
/>
|
onRoomCreate={onRoomCreate}
|
||||||
)}
|
onRoomDestroy={onRoomDestroy}
|
||||||
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
<BackgroundPickerAndDarkModeToggle
|
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||||
actionManager={actionManager}
|
|
||||||
appState={appState}
|
|
||||||
setAppState={setAppState}
|
|
||||||
/>
|
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</Island>
|
</Island>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -417,11 +149,9 @@ const LayerUI = ({
|
|||||||
const renderSelectedShapeActions = () => (
|
const renderSelectedShapeActions = () => (
|
||||||
<Section
|
<Section
|
||||||
heading="selectedShapeActions"
|
heading="selectedShapeActions"
|
||||||
className={clsx("zen-mode-transition", {
|
className={`zen-mode-transition ${zenModeEnabled && "transition-left"}`}
|
||||||
"transition-left": zenModeEnabled,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Island className={CLASSES.SHAPE_ACTIONS_MENU} padding={2}>
|
<Island className={CLASSES.SHAPE_ACTIONS_MENU} padding={4}>
|
||||||
<SelectedShapeActions
|
<SelectedShapeActions
|
||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
@@ -432,42 +162,18 @@ const LayerUI = ({
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeLibrary = useCallback(
|
|
||||||
(event) => {
|
|
||||||
setAppState({ isLibraryOpen: false });
|
|
||||||
},
|
|
||||||
[setAppState],
|
|
||||||
);
|
|
||||||
|
|
||||||
const deselectItems = useCallback(() => {
|
|
||||||
setAppState({
|
|
||||||
selectedElementIds: {},
|
|
||||||
selectedGroupIds: {},
|
|
||||||
});
|
|
||||||
}, [setAppState]);
|
|
||||||
|
|
||||||
const libraryMenu = appState.isLibraryOpen ? (
|
|
||||||
<LibraryMenu
|
|
||||||
pendingElements={getSelectedElements(elements, appState)}
|
|
||||||
onClickOutside={closeLibrary}
|
|
||||||
onInsertShape={onInsertElements}
|
|
||||||
onAddToLibrary={deselectItems}
|
|
||||||
setAppState={setAppState}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const renderFixedSideContainer = () => {
|
const renderFixedSideContainer = () => {
|
||||||
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FixedSideContainer side="top">
|
<FixedSideContainer side="top">
|
||||||
|
<HintViewer appState={appState} elements={elements} />
|
||||||
<div className="App-menu App-menu_top">
|
<div className="App-menu App-menu_top">
|
||||||
<Stack.Col
|
<Stack.Col
|
||||||
gap={4}
|
gap={4}
|
||||||
className={clsx({ "disable-pointerEvents": zenModeEnabled })}
|
className={zenModeEnabled && "disable-pointerEvents"}
|
||||||
>
|
>
|
||||||
{renderCanvasActions()}
|
{renderCanvasActions()}
|
||||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||||
@@ -476,17 +182,12 @@ const LayerUI = ({
|
|||||||
{(heading) => (
|
{(heading) => (
|
||||||
<Stack.Col gap={4} align="start">
|
<Stack.Col gap={4} align="start">
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<Island
|
<Island padding={1} className={zenModeEnabled && "zen-mode"}>
|
||||||
padding={1}
|
|
||||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
|
||||||
>
|
|
||||||
<HintViewer appState={appState} elements={elements} />
|
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<ShapesSwitcher
|
<ShapesSwitcher
|
||||||
elementType={appState.elementType}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
isLibraryOpen={appState.isLibraryOpen}
|
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
@@ -497,68 +198,55 @@ const LayerUI = ({
|
|||||||
title={t("toolBar.lock")}
|
title={t("toolBar.lock")}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{libraryMenu}
|
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
<UserList
|
<div />
|
||||||
className={clsx("zen-mode-transition", {
|
|
||||||
"transition-right": zenModeEnabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{Array.from(appState.collaborators)
|
|
||||||
// Collaborator is either not initialized or is actually the current user.
|
|
||||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
|
||||||
.map(([clientId, client]) => (
|
|
||||||
<Tooltip
|
|
||||||
label={client.username || "Unknown user"}
|
|
||||||
key={clientId}
|
|
||||||
>
|
|
||||||
{actionManager.renderAction("goToCollaborator", clientId)}
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</UserList>
|
|
||||||
</div>
|
</div>
|
||||||
|
{
|
||||||
|
<div
|
||||||
|
className={`App-menu App-menu_bottom zen-mode-transition ${
|
||||||
|
zenModeEnabled && "App-menu_bottom--transition-left"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Stack.Col gap={2}>
|
||||||
|
<Section heading="canvasActions">
|
||||||
|
<Island padding={1}>
|
||||||
|
<ZoomActions
|
||||||
|
renderAction={actionManager.renderAction}
|
||||||
|
zoom={appState.zoom}
|
||||||
|
/>
|
||||||
|
</Island>
|
||||||
|
{renderEncryptedIcon()}
|
||||||
|
</Section>
|
||||||
|
</Stack.Col>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderBottomAppMenu = () => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx("App-menu App-menu_bottom zen-mode-transition", {
|
|
||||||
"App-menu_bottom--transition-left": zenModeEnabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Stack.Col gap={2}>
|
|
||||||
<Section heading="canvasActions">
|
|
||||||
<Island padding={1}>
|
|
||||||
<ZoomActions
|
|
||||||
renderAction={actionManager.renderAction}
|
|
||||||
zoom={appState.zoom}
|
|
||||||
/>
|
|
||||||
</Island>
|
|
||||||
{renderEncryptedIcon()}
|
|
||||||
</Section>
|
|
||||||
</Stack.Col>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderFooter = () => (
|
const renderFooter = () => (
|
||||||
<footer role="contentinfo" className="layer-ui__wrapper__footer">
|
<footer role="contentinfo" className="layer-ui__wrapper__footer">
|
||||||
<div
|
<div
|
||||||
className={clsx("zen-mode-transition", {
|
className={`zen-mode-transition ${
|
||||||
"transition-right disable-pointerEvents": zenModeEnabled,
|
zenModeEnabled && "transition-right disable-pointerEvents"
|
||||||
})}
|
}`}
|
||||||
>
|
>
|
||||||
{renderCustomFooter?.(false)}
|
<LanguageList
|
||||||
|
onChange={(lng) => {
|
||||||
|
setLanguage(lng);
|
||||||
|
setAppState({});
|
||||||
|
}}
|
||||||
|
languages={languages}
|
||||||
|
floating
|
||||||
|
/>
|
||||||
{actionManager.renderAction("toggleShortcuts")}
|
{actionManager.renderAction("toggleShortcuts")}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={clsx("disable-zen-mode", {
|
className={`disable-zen-mode ${
|
||||||
"disable-zen-mode--visible": zenModeEnabled,
|
zenModeEnabled && "disable-zen-mode--visible"
|
||||||
})}
|
}`}
|
||||||
onClick={toggleZenMode}
|
onClick={toggleZenMode}
|
||||||
>
|
>
|
||||||
{t("buttons.exitZenMode")}
|
{t("buttons.exitZenMode")}
|
||||||
@@ -567,10 +255,7 @@ const LayerUI = ({
|
|||||||
<button
|
<button
|
||||||
className="scroll-back-to-content"
|
className="scroll-back-to-content"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent(EVENT_ACTION, "scroll to content");
|
setAppState({ ...calculateScrollCenter(elements) });
|
||||||
setAppState({
|
|
||||||
...calculateScrollCenter(elements, appState, canvas),
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("buttons.scrollBackToContent")}
|
{t("buttons.scrollBackToContent")}
|
||||||
@@ -579,8 +264,20 @@ const LayerUI = ({
|
|||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|
||||||
const dialogs = (
|
return isMobile ? (
|
||||||
<>
|
<MobileMenu
|
||||||
|
appState={appState}
|
||||||
|
elements={elements}
|
||||||
|
actionManager={actionManager}
|
||||||
|
exportButton={renderExportDialog()}
|
||||||
|
setAppState={setAppState}
|
||||||
|
onUsernameChange={onUsernameChange}
|
||||||
|
onRoomCreate={onRoomCreate}
|
||||||
|
onRoomDestroy={onRoomDestroy}
|
||||||
|
onLockToggle={onLockToggle}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="layer-ui__wrapper">
|
||||||
{appState.isLoading && <LoadingMessage />}
|
{appState.isLoading && <LoadingMessage />}
|
||||||
{appState.errorMessage && (
|
{appState.errorMessage && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
@@ -590,56 +287,17 @@ const LayerUI = ({
|
|||||||
)}
|
)}
|
||||||
{appState.showShortcutsDialog && (
|
{appState.showShortcutsDialog && (
|
||||||
<ShortcutsDialog
|
<ShortcutsDialog
|
||||||
onClose={() => setAppState({ showShortcutsDialog: false })}
|
onClose={() => setAppState({ showShortcutsDialog: null })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{appState.pasteDialog.shown && (
|
|
||||||
<PasteChartDialog
|
|
||||||
setAppState={setAppState}
|
|
||||||
appState={appState}
|
|
||||||
onInsertChart={onInsertElements}
|
|
||||||
onClose={() =>
|
|
||||||
setAppState({
|
|
||||||
pasteDialog: { shown: false, data: null },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return isMobile ? (
|
|
||||||
<>
|
|
||||||
{dialogs}
|
|
||||||
<MobileMenu
|
|
||||||
appState={appState}
|
|
||||||
elements={elements}
|
|
||||||
actionManager={actionManager}
|
|
||||||
libraryMenu={libraryMenu}
|
|
||||||
exportButton={renderExportDialog()}
|
|
||||||
setAppState={setAppState}
|
|
||||||
onCollabButtonClick={onCollabButtonClick}
|
|
||||||
onLockToggle={onLockToggle}
|
|
||||||
canvas={canvas}
|
|
||||||
isCollaborating={isCollaborating}
|
|
||||||
renderCustomFooter={renderCustomFooter}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="layer-ui__wrapper">
|
|
||||||
{dialogs}
|
|
||||||
{renderFixedSideContainer()}
|
{renderFixedSideContainer()}
|
||||||
{renderBottomAppMenu()}
|
|
||||||
{
|
{
|
||||||
<aside
|
<aside
|
||||||
className={clsx(
|
className={`layer-ui__wrapper__github-corner zen-mode-transition ${
|
||||||
"layer-ui__wrapper__github-corner zen-mode-transition",
|
zenModeEnabled && "transition-right"
|
||||||
{
|
}`}
|
||||||
"transition-right": zenModeEnabled,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<GitHubCorner appearance={appState.appearance} />
|
<GitHubCorner />
|
||||||
</aside>
|
</aside>
|
||||||
}
|
}
|
||||||
{renderFooter()}
|
{renderFooter()}
|
||||||
@@ -650,8 +308,13 @@ const LayerUI = ({
|
|||||||
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||||
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
|
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
|
||||||
const {
|
const {
|
||||||
suggestedBindings,
|
draggingElement,
|
||||||
startBoundElement: boundElement,
|
resizingElement,
|
||||||
|
multiElement,
|
||||||
|
editingElement,
|
||||||
|
isResizing,
|
||||||
|
cursorX,
|
||||||
|
cursorY,
|
||||||
...ret
|
...ret
|
||||||
} = appState;
|
} = appState;
|
||||||
return ret;
|
return ret;
|
||||||
@@ -660,8 +323,8 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
|||||||
const nextAppState = getNecessaryObj(next.appState);
|
const nextAppState = getNecessaryObj(next.appState);
|
||||||
|
|
||||||
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
prev.langCode === next.langCode &&
|
|
||||||
prev.elements === next.elements &&
|
prev.elements === next.elements &&
|
||||||
keys.every((key) => prevAppState[key] === nextAppState[key])
|
keys.every((key) => prevAppState[key] === nextAppState[key])
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
.excalidraw {
|
|
||||||
.library-unit {
|
|
||||||
align-items: center;
|
|
||||||
border: 1px solid var(--button-gray-2);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
width: 63px;
|
|
||||||
height: 63px; // match width
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-unit__dragger {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-unit__dragger > svg {
|
|
||||||
filter: var(--appearance-filter);
|
|
||||||
flex-grow: 1;
|
|
||||||
max-height: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-unit__removeFromLibrary,
|
|
||||||
.library-unit__removeFromLibrary:hover,
|
|
||||||
.library-unit__removeFromLibrary:active {
|
|
||||||
align-items: center;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--icon-fill-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
position: absolute;
|
|
||||||
right: 5px;
|
|
||||||
top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-unit__removeFromLibrary > svg {
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-unit__pulse {
|
|
||||||
transform: scale(1);
|
|
||||||
animation: library-unit__pulse-animation 1s ease-in infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-unit__adder {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
margin-left: -10px;
|
|
||||||
margin-top: -10px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-unit__active {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes library-unit__pulse-animation {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import clsx from "clsx";
|
|
||||||
import oc from "open-color";
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { close } from "../components/icons";
|
|
||||||
import { MIME_TYPES } from "../constants";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import useIsMobile from "../is-mobile";
|
|
||||||
import { exportToSvg } from "../scene/export";
|
|
||||||
import { LibraryItem } from "../types";
|
|
||||||
import "./LibraryUnit.scss";
|
|
||||||
|
|
||||||
// fa-plus
|
|
||||||
const PLUS_ICON = (
|
|
||||||
<svg viewBox="0 0 1792 1792">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const LibraryUnit = ({
|
|
||||||
elements,
|
|
||||||
pendingElements,
|
|
||||||
onRemoveFromLibrary,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
elements?: LibraryItem;
|
|
||||||
pendingElements?: LibraryItem;
|
|
||||||
onRemoveFromLibrary: () => void;
|
|
||||||
onClick: () => void;
|
|
||||||
}) => {
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const elementsToRender = elements || pendingElements;
|
|
||||||
if (!elementsToRender) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const svg = exportToSvg(elementsToRender, {
|
|
||||||
exportBackground: false,
|
|
||||||
viewBackgroundColor: oc.white,
|
|
||||||
shouldAddWatermark: false,
|
|
||||||
});
|
|
||||||
for (const child of ref.current!.children) {
|
|
||||||
if (child.tagName !== "svg") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ref.current!.removeChild(child);
|
|
||||||
}
|
|
||||||
ref.current!.appendChild(svg);
|
|
||||||
|
|
||||||
const current = ref.current!;
|
|
||||||
return () => {
|
|
||||||
current.removeChild(svg);
|
|
||||||
};
|
|
||||||
}, [elements, pendingElements]);
|
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const adder = (isHovered || isMobile) && pendingElements && (
|
|
||||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx("library-unit", {
|
|
||||||
"library-unit__active": elements || pendingElements,
|
|
||||||
})}
|
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={clsx("library-unit__dragger", {
|
|
||||||
"library-unit__pulse": !!pendingElements,
|
|
||||||
})}
|
|
||||||
ref={ref}
|
|
||||||
draggable={!!elements}
|
|
||||||
onClick={!!elements || !!pendingElements ? onClick : undefined}
|
|
||||||
onDragStart={(event) => {
|
|
||||||
setIsHovered(false);
|
|
||||||
event.dataTransfer.setData(
|
|
||||||
MIME_TYPES.excalidrawlib,
|
|
||||||
JSON.stringify(elements),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{adder}
|
|
||||||
{elements && (isHovered || isMobile) && (
|
|
||||||
<button
|
|
||||||
className="library-unit__removeFromLibrary"
|
|
||||||
aria-label={t("labels.removeFromLibrary")}
|
|
||||||
onClick={onRemoveFromLibrary}
|
|
||||||
>
|
|
||||||
{close}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
export const LoadingMessage = () => {
|
export const LoadingMessage = () => {
|
||||||
// !! KEEP THIS IN SYNC WITH index.html !!
|
// !! KEEP THIS IN SYNC WITH index.html !!
|
||||||
return (
|
return (
|
||||||
<div className="LoadingMessage">
|
<div className="LoadingMessage">
|
||||||
<span>{t("labels.loadingScene")}</span>
|
<span>{"Loading scene..."}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user