mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-08-29 05:07:08 +02:00
Compare commits
14 Commits
random_use
...
updatescen
Author | SHA1 | Date | |
---|---|---|---|
![]() |
eb206cc932 | ||
![]() |
16c287c848 | ||
![]() |
78024873e5 | ||
![]() |
4e41bd9dbb | ||
![]() |
edc23b854f | ||
![]() |
4843c49556 | ||
![]() |
d565413082 | ||
![]() |
dcda7184d0 | ||
![]() |
8d413670c8 | ||
![]() |
f774452124 | ||
![]() |
db4ed1ecb1 | ||
![]() |
489f45b910 | ||
![]() |
a17be085b0 | ||
![]() |
4e07a608d3 |
26
.github/workflows/autorelease-excalidraw.yml
vendored
26
.github/workflows/autorelease-excalidraw.yml
vendored
@@ -1,26 +0,0 @@
|
|||||||
name: Auto release @excalidraw/excalidraw-next
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
Auto-release-excalidraw-next:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
- name: Setup Node.js 14.x
|
|
||||||
uses: actions/setup-node@v2
|
|
||||||
with:
|
|
||||||
node-version: 14.x
|
|
||||||
- name: Set up publish access
|
|
||||||
run: |
|
|
||||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
|
||||||
env:
|
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
- name: Auto release
|
|
||||||
run: |
|
|
||||||
yarn autorelease
|
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,11 +5,9 @@
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.envrc
|
.envrc
|
||||||
.eslintcache
|
.eslintcache
|
||||||
.history
|
|
||||||
.idea
|
.idea
|
||||||
.vercel
|
.vercel
|
||||||
.vscode
|
.vscode
|
||||||
.yarn
|
|
||||||
*.log
|
*.log
|
||||||
*.tgz
|
*.tgz
|
||||||
build
|
build
|
||||||
@@ -22,4 +20,3 @@ package-lock.json
|
|||||||
static
|
static
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
src/packages/excalidraw/types
|
|
||||||
|
@@ -10,7 +10,7 @@ ARG NODE_ENV=production
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN yarn build:app:docker
|
RUN yarn build:app:docker
|
||||||
|
|
||||||
FROM nginx:1.21-alpine
|
FROM nginx:1.17-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
18
README.md
18
README.md
@@ -70,8 +70,6 @@ The first set of digits is the room. This is visible from the server that’s go
|
|||||||
|
|
||||||
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
||||||
|
|
||||||
> Note: Please ensure that the encryption key is 22 characters long.
|
|
||||||
|
|
||||||
## Shape libraries
|
## Shape libraries
|
||||||
|
|
||||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||||
@@ -95,7 +93,7 @@ These instructions will get you a copy of the project up and running on your loc
|
|||||||
#### Requirements
|
#### Requirements
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/en/)
|
- [Node.js](https://nodejs.org/en/)
|
||||||
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
|
- [Yarn](https://yarnpkg.com/getting-started/install)
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
|
||||||
#### Clone the repo
|
#### Clone the repo
|
||||||
@@ -104,20 +102,6 @@ These instructions will get you a copy of the project up and running on your loc
|
|||||||
git clone https://github.com/excalidraw/excalidraw.git
|
git clone https://github.com/excalidraw/excalidraw.git
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Install the dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Start the server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn start
|
|
||||||
```
|
|
||||||
|
|
||||||
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
|
|
||||||
|
|
||||||
#### Commands
|
#### Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
@@ -2,8 +2,5 @@
|
|||||||
"firestore": {
|
"firestore": {
|
||||||
"rules": "firestore.rules",
|
"rules": "firestore.rules",
|
||||||
"indexes": "firestore.indexes.json"
|
"indexes": "firestore.indexes.json"
|
||||||
},
|
|
||||||
"storage": {
|
|
||||||
"rules": "storage.rules"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,12 +0,0 @@
|
|||||||
rules_version = '2';
|
|
||||||
service firebase.storage {
|
|
||||||
match /b/{bucket}/o {
|
|
||||||
match /{migrations} {
|
|
||||||
match /{scenes}/{scene} {
|
|
||||||
allow get, write: if true;
|
|
||||||
// redundant, but let's be explicit'
|
|
||||||
allow list: if false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
44
package.json
44
package.json
@@ -19,37 +19,34 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dwelle/browser-fs-access": "0.21.1",
|
"@sentry/browser": "6.2.2",
|
||||||
"@excalidraw/random-username": "1.0.0",
|
"@sentry/integrations": "6.2.1",
|
||||||
"@sentry/browser": "6.2.5",
|
"@testing-library/jest-dom": "5.11.9",
|
||||||
"@sentry/integrations": "6.2.5",
|
"@testing-library/react": "11.2.5",
|
||||||
"@testing-library/jest-dom": "5.11.10",
|
"@types/jest": "26.0.20",
|
||||||
"@testing-library/react": "11.2.6",
|
"@types/react": "17.0.2",
|
||||||
"@tldraw/vec": "0.0.106",
|
"@types/react-dom": "17.0.1",
|
||||||
"@types/jest": "26.0.22",
|
|
||||||
"@types/react": "17.0.3",
|
|
||||||
"@types/react-dom": "17.0.3",
|
|
||||||
"@types/socket.io-client": "1.4.36",
|
"@types/socket.io-client": "1.4.36",
|
||||||
|
"browser-fs-access": "0.14.2",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"firebase": "8.3.3",
|
"firebase": "8.2.10",
|
||||||
"i18next-browser-languagedetector": "6.1.0",
|
"i18next-browser-languagedetector": "6.0.1",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "3.1.22",
|
"nanoid": "3.1.21",
|
||||||
"open-color": "1.8.0",
|
"open-color": "1.8.0",
|
||||||
"pako": "1.0.11",
|
"pako": "1.0.11",
|
||||||
"perfect-freehand": "1.0.15",
|
|
||||||
"png-chunk-text": "1.0.0",
|
"png-chunk-text": "1.0.0",
|
||||||
"png-chunks-encode": "1.0.0",
|
"png-chunks-encode": "1.0.0",
|
||||||
"png-chunks-extract": "1.0.0",
|
"png-chunks-extract": "1.0.0",
|
||||||
"points-on-curve": "0.2.0",
|
"points-on-curve": "0.2.0",
|
||||||
"pwacompat": "2.0.17",
|
"pwacompat": "2.0.17",
|
||||||
"react": "17.0.2",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.1",
|
||||||
"react-scripts": "4.0.3",
|
"react-scripts": "4.0.3",
|
||||||
"roughjs": "4.4.1",
|
"roughjs": "4.3.1",
|
||||||
"sass": "1.32.10",
|
"sass": "1.32.8",
|
||||||
"socket.io-client": "2.3.1",
|
"socket.io-client": "2.3.1",
|
||||||
"typescript": "4.2.4"
|
"typescript": "4.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@excalidraw/eslint-config": "1.0.0",
|
"@excalidraw/eslint-config": "1.0.0",
|
||||||
@@ -57,9 +54,9 @@
|
|||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.6",
|
||||||
"@types/pako": "1.0.1",
|
"@types/pako": "1.0.1",
|
||||||
"@types/resize-observer-browser": "0.1.5",
|
"@types/resize-observer-browser": "0.1.5",
|
||||||
"eslint-config-prettier": "8.3.0",
|
"eslint-config-prettier": "8.1.0",
|
||||||
"eslint-plugin-prettier": "3.3.1",
|
"eslint-plugin-prettier": "3.3.1",
|
||||||
"firebase-tools": "9.9.0",
|
"firebase-tools": "9.6.1",
|
||||||
"husky": "4.3.8",
|
"husky": "4.3.8",
|
||||||
"jest-canvas-mock": "2.3.1",
|
"jest-canvas-mock": "2.3.1",
|
||||||
"lint-staged": "10.5.4",
|
"lint-staged": "10.5.4",
|
||||||
@@ -78,7 +75,7 @@
|
|||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)"
|
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
||||||
],
|
],
|
||||||
"resetMocks": false
|
"resetMocks": false
|
||||||
},
|
},
|
||||||
@@ -106,7 +103,6 @@
|
|||||||
"test:other": "yarn prettier --list-different",
|
"test:other": "yarn prettier --list-different",
|
||||||
"test:typecheck": "tsc",
|
"test:typecheck": "tsc",
|
||||||
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
||||||
"test": "yarn test:app",
|
"test": "yarn test:app"
|
||||||
"autorelease": "node scripts/autorelease.js"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
@@ -13,18 +13,6 @@
|
|||||||
|
|
||||||
<meta name="theme-color" content="#000" />
|
<meta name="theme-color" content="#000" />
|
||||||
|
|
||||||
<!-- Declarative Link Capturing (https://web.dev/declarative-link-capturing/) -->
|
|
||||||
<meta
|
|
||||||
http-equiv="origin-trial"
|
|
||||||
content="Ak3VyzTheARtX2CnxBZ3ZKxImB0mNyvDakmMxeAChgxrWFMZ3IGN64VP+uj36VxM5OegsbLmrP258b1xvqp7+Q8AAABbeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJBcHBMaW5rQ2FwdHVyaW5nIiwiZXhwaXJ5IjoxNjM0MDgzMTk5fQ=="
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- File Handling (https://web.dev/file-handling/) -->
|
|
||||||
<meta
|
|
||||||
http-equiv="origin-trial"
|
|
||||||
content="AkMQsAnFmKfRfPKQHNCv2WmZREqgwkqhyt2M7aOwQiCStB+hPYnGnv+mNbkPDAsGXrwsj/waFi76wPzTDUaEeQ0AAABUeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJGaWxlSGFuZGxpbmciLCJleHBpcnkiOjE2MzQwODMxOTl9"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- General tags -->
|
<!-- General tags -->
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
@@ -63,7 +51,8 @@
|
|||||||
name="twitter:description"
|
name="twitter:description"
|
||||||
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||||
/>
|
/>
|
||||||
|
<!-- OG tags require absolute url for images -->
|
||||||
|
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
|
||||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||||
|
|
||||||
<!-- Excalidraw version -->
|
<!-- Excalidraw version -->
|
||||||
@@ -99,8 +88,6 @@
|
|||||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||||
<script>
|
<script>
|
||||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||||
// setting this so that libraries installation reuses this window tab.
|
|
||||||
window.name = "_excalidraw";
|
|
||||||
</script>
|
</script>
|
||||||
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
||||||
<script
|
<script
|
||||||
@@ -119,17 +106,15 @@
|
|||||||
|
|
||||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||||
<style>
|
<style>
|
||||||
body,
|
body {
|
||||||
html {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||||
Roboto, Helvetica, Arial, sans-serif;
|
Roboto, Helvetica, Arial, sans-serif;
|
||||||
font-family: var(--ui-font);
|
font-family: var(--ui-font);
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
|
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
height: 100%;
|
height: 100vh;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
@@ -139,7 +124,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
clip: rect(1px, 1px, 1px, 1px);
|
clip: rect(1px, 1px, 1px, 1px);
|
||||||
white-space: nowrap; /* added line */
|
white-space: nowrap; /* added line */
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.LoadingMessage {
|
.LoadingMessage {
|
||||||
@@ -162,24 +146,6 @@
|
|||||||
color: var(--popup-text-color);
|
color: var(--popup-text-color);
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
}
|
}
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
-webkit-touch-callout: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-khtml-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
@media screen and (min-width: 1200px) {
|
|
||||||
-webkit-touch-callout: default;
|
|
||||||
-webkit-user-select: auto;
|
|
||||||
-khtml-user-select: auto;
|
|
||||||
-moz-user-select: auto;
|
|
||||||
-ms-user-select: auto;
|
|
||||||
user-select: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
@@ -26,7 +26,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"capture_links": "new-client",
|
"capture_links": "new_client",
|
||||||
"share_target": {
|
"share_target": {
|
||||||
"action": "/web-share-target",
|
"action": "/web-share-target",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
@@ -39,37 +39,5 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"screenshots": [
|
|
||||||
{
|
|
||||||
"src": "/screenshots/virtual-whiteboard.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "462x945"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/screenshots/wireframe.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "462x945"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/screenshots/illustration.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "462x945"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/screenshots/shapes.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "462x945"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/screenshots/collaboration.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "462x945"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/screenshots/export.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "462x945"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 28 KiB |
Binary file not shown.
Before Width: | Height: | Size: 25 KiB |
Binary file not shown.
Before Width: | Height: | Size: 47 KiB |
Binary file not shown.
Before Width: | Height: | Size: 25 KiB |
Binary file not shown.
Before Width: | Height: | Size: 27 KiB |
Binary file not shown.
Before Width: | Height: | Size: 27 KiB |
@@ -1,51 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const { exec, execSync } = require("child_process");
|
|
||||||
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
|
||||||
const pkg = require(excalidrawPackage);
|
|
||||||
|
|
||||||
const getShortCommitHash = () => {
|
|
||||||
return execSync("git rev-parse --short HEAD").toString().trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
const publish = () => {
|
|
||||||
try {
|
|
||||||
execSync(`yarn --frozen-lockfile`);
|
|
||||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
|
||||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
|
||||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// get files changed between prev and head commit
|
|
||||||
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
|
||||||
if (error || stderr) {
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const changedFiles = stdout.trim().split("\n");
|
|
||||||
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
|
||||||
|
|
||||||
const excalidrawPackageFiles = changedFiles.filter((file) => {
|
|
||||||
return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!excalidrawPackageFiles.length) {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// update package.json
|
|
||||||
pkg.version = `${pkg.version}-${getShortCommitHash()}`;
|
|
||||||
pkg.name = "@excalidraw/excalidraw-next";
|
|
||||||
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
|
||||||
|
|
||||||
// update readme
|
|
||||||
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
|
||||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
|
||||||
|
|
||||||
publish();
|
|
||||||
});
|
|
@@ -37,9 +37,6 @@ const crowdinMap = {
|
|||||||
"uk-UA": "en-uk",
|
"uk-UA": "en-uk",
|
||||||
"zh-CN": "en-zhcn",
|
"zh-CN": "en-zhcn",
|
||||||
"zh-TW": "en-zhtw",
|
"zh-TW": "en-zhtw",
|
||||||
"lv-LV": "en-lv",
|
|
||||||
"cs-CZ": "en-cs",
|
|
||||||
"kk-KZ": "en-kk",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const flags = {
|
const flags = {
|
||||||
@@ -77,9 +74,6 @@ const flags = {
|
|||||||
"uk-UA": "🇺🇦",
|
"uk-UA": "🇺🇦",
|
||||||
"zh-CN": "🇨🇳",
|
"zh-CN": "🇨🇳",
|
||||||
"zh-TW": "🇹🇼",
|
"zh-TW": "🇹🇼",
|
||||||
"lv-LV": "🇱🇻",
|
|
||||||
"cs-CZ": "🇨🇿",
|
|
||||||
"kk-KZ": "🇰🇿",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const languages = {
|
const languages = {
|
||||||
@@ -117,9 +111,6 @@ const languages = {
|
|||||||
"uk-UA": "Українська",
|
"uk-UA": "Українська",
|
||||||
"zh-CN": "简体中文",
|
"zh-CN": "简体中文",
|
||||||
"zh-TW": "繁體中文",
|
"zh-TW": "繁體中文",
|
||||||
"lv-LV": "Latviešu",
|
|
||||||
"cs-CZ": "Česky",
|
|
||||||
"kk-KZ": "Қазақ тілі",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const percentages = fs.readFileSync(
|
const percentages = fs.readFileSync(
|
||||||
|
@@ -1,39 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const util = require("util");
|
|
||||||
const exec = util.promisify(require("child_process").exec);
|
|
||||||
const updateReadme = require("./updateReadme");
|
|
||||||
const updateChangelog = require("./updateChangelog");
|
|
||||||
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
|
||||||
|
|
||||||
const updatePackageVersion = (nextVersion) => {
|
|
||||||
const pkg = require(excalidrawPackage);
|
|
||||||
pkg.version = nextVersion;
|
|
||||||
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
|
||||||
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
|
||||||
};
|
|
||||||
|
|
||||||
const release = async (nextVersion) => {
|
|
||||||
try {
|
|
||||||
updateReadme();
|
|
||||||
await updateChangelog(nextVersion);
|
|
||||||
updatePackageVersion(nextVersion);
|
|
||||||
await exec(`git add -u`);
|
|
||||||
await exec(
|
|
||||||
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
|
||||||
);
|
|
||||||
/* eslint-disable no-console */
|
|
||||||
console.log("Done!");
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextVersion = process.argv.slice(2)[0];
|
|
||||||
if (!nextVersion) {
|
|
||||||
console.error("Pass the next version to release!");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
release(nextVersion);
|
|
@@ -1,97 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const util = require("util");
|
|
||||||
const exec = util.promisify(require("child_process").exec);
|
|
||||||
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
|
||||||
const pkg = require(excalidrawPackage);
|
|
||||||
const lastVersion = pkg.version;
|
|
||||||
const existingChangeLog = fs.readFileSync(
|
|
||||||
`${excalidrawDir}/CHANGELOG.md`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
|
|
||||||
const headerForType = {
|
|
||||||
feat: "Features",
|
|
||||||
fix: "Fixes",
|
|
||||||
style: "Styles",
|
|
||||||
refactor: " Refactor",
|
|
||||||
perf: "Performance",
|
|
||||||
build: "Build",
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCommitHashForLastVersion = async () => {
|
|
||||||
try {
|
|
||||||
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
|
|
||||||
const { stdout } = await exec(
|
|
||||||
`git log --format=format:"%H" --grep=${commitMessage}`,
|
|
||||||
);
|
|
||||||
return stdout;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLibraryCommitsSinceLastRelease = async () => {
|
|
||||||
const commitHash = await getCommitHashForLastVersion();
|
|
||||||
const { stdout } = await exec(
|
|
||||||
`git log --pretty=format:%s ${commitHash}...master`,
|
|
||||||
);
|
|
||||||
const commitsSinceLastRelease = stdout.split("\n");
|
|
||||||
const commitList = {};
|
|
||||||
supportedTypes.forEach((type) => {
|
|
||||||
commitList[type] = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
commitsSinceLastRelease.forEach((commit) => {
|
|
||||||
const indexOfColon = commit.indexOf(":");
|
|
||||||
const type = commit.slice(0, indexOfColon);
|
|
||||||
if (!supportedTypes.includes(type)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const messageWithoutType = commit.slice(indexOfColon + 1).trim();
|
|
||||||
const messageWithCapitalizeFirst =
|
|
||||||
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
|
|
||||||
const prNumber = commit.match(/\(#([0-9]*)\)/)[1];
|
|
||||||
|
|
||||||
// return if the changelog already contains the pr number which would happen for package updates
|
|
||||||
if (existingChangeLog.includes(prNumber)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
|
|
||||||
const messageWithPRLink = messageWithCapitalizeFirst.replace(
|
|
||||||
/\(#[0-9]*\)/,
|
|
||||||
prMarkdown,
|
|
||||||
);
|
|
||||||
commitList[type].push(messageWithPRLink);
|
|
||||||
});
|
|
||||||
return commitList;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateChangelog = async (nextVersion) => {
|
|
||||||
const commitList = await getLibraryCommitsSinceLastRelease();
|
|
||||||
let changelogForLibrary =
|
|
||||||
"## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
|
|
||||||
supportedTypes.forEach((type) => {
|
|
||||||
if (commitList[type].length) {
|
|
||||||
changelogForLibrary += `### ${headerForType[type]}\n\n`;
|
|
||||||
const commits = commitList[type];
|
|
||||||
commits.forEach((commit) => {
|
|
||||||
changelogForLibrary += `- ${commit}\n\n`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
changelogForLibrary += "---\n";
|
|
||||||
const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
|
|
||||||
let updatedContent =
|
|
||||||
existingChangeLog.slice(0, lastVersionIndex) +
|
|
||||||
changelogForLibrary +
|
|
||||||
existingChangeLog.slice(lastVersionIndex);
|
|
||||||
const currentDate = new Date().toISOString().slice(0, 10);
|
|
||||||
const newVersion = `## ${nextVersion} (${currentDate})`;
|
|
||||||
updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
|
|
||||||
fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = updateChangelog;
|
|
@@ -1,27 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
const updateReadme = () => {
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
|
||||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
|
||||||
|
|
||||||
// remove note for unstable release
|
|
||||||
data = data.replace(
|
|
||||||
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
// replace "excalidraw-next" with "excalidraw"
|
|
||||||
data = data.replace(/excalidraw-next/g, "excalidraw");
|
|
||||||
data = data.trim();
|
|
||||||
|
|
||||||
const demoIndex = data.indexOf("### Demo");
|
|
||||||
const excalidrawNextNote =
|
|
||||||
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
|
|
||||||
// Add excalidraw next note to try out for unreleased changes
|
|
||||||
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
|
|
||||||
|
|
||||||
// update readme
|
|
||||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = updateReadme;
|
|
@@ -2,20 +2,18 @@ import { register } from "./register";
|
|||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { deepCopyElement } from "../element/newElement";
|
import { deepCopyElement } from "../element/newElement";
|
||||||
|
import { Library } from "../data/library";
|
||||||
|
|
||||||
export const actionAddToLibrary = register({
|
export const actionAddToLibrary = register({
|
||||||
name: "addToLibrary",
|
name: "addToLibrary",
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState) => {
|
||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
|
|
||||||
app.library.loadLibrary().then((items) => {
|
Library.loadLibrary().then((items) => {
|
||||||
app.library.saveLibrary([
|
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
||||||
...items,
|
|
||||||
selectedElements.map(deepCopyElement),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { alignElements, Alignment } from "../align";
|
import { alignElements, Alignment } from "../align";
|
||||||
import {
|
import {
|
||||||
AlignBottomIcon,
|
AlignBottomIcon,
|
||||||
|
@@ -1,14 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { trash, zoomIn, zoomOut } from "../components/icons";
|
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
import { ZOOM_STEP } from "../constants";
|
||||||
import { THEME, ZOOM_STEP } from "../constants";
|
|
||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import useIsMobile from "../is-mobile";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
@@ -16,14 +16,13 @@ import { getNewZoom } from "../scene/zoom";
|
|||||||
import { AppState, NormalizedZoomValue } from "../types";
|
import { AppState, NormalizedZoomValue } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, ...value },
|
appState: { ...appState, viewBackgroundColor: value },
|
||||||
commitToHistory: !!value.viewBackgroundColor,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => {
|
PanelComponent: ({ appState, updateData }) => {
|
||||||
@@ -33,12 +32,7 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
label={t("labels.canvasBackground")}
|
label={t("labels.canvasBackground")}
|
||||||
type="canvasBackground"
|
type="canvasBackground"
|
||||||
color={appState.viewBackgroundColor}
|
color={appState.viewBackgroundColor}
|
||||||
onChange={(color) => updateData({ viewBackgroundColor: color })}
|
onChange={(color) => updateData(color)}
|
||||||
isActive={appState.openPopup === "canvasColorPicker"}
|
|
||||||
setActive={(active) =>
|
|
||||||
updateData({ openPopup: active ? "canvasColorPicker" : null })
|
|
||||||
}
|
|
||||||
data-testid="canvas-background-picker"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -59,6 +53,7 @@ export const actionClearCanvas = register({
|
|||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
exportEmbedScene: appState.exportEmbedScene,
|
exportEmbedScene: appState.exportEmbedScene,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
|
shouldAddWatermark: appState.shouldAddWatermark,
|
||||||
showStats: appState.showStats,
|
showStats: appState.showStats,
|
||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
},
|
},
|
||||||
@@ -77,7 +72,6 @@ export const actionClearCanvas = register({
|
|||||||
updateData(null);
|
updateData(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
data-testid="clear-canvas-button"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -108,7 +102,6 @@ export const actionZoomIn = register({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
size="small"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -143,7 +136,6 @@ export const actionZoomOut = register({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
size="small"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -170,21 +162,16 @@ export const actionResetZoom = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<Tooltip label={t("buttons.resetZoom")}>
|
<ToolButton
|
||||||
<ToolButton
|
type="button"
|
||||||
type="button"
|
icon={resetZoom}
|
||||||
className="reset-zoom-button"
|
title={t("buttons.resetZoom")}
|
||||||
title={t("buttons.resetZoom")}
|
aria-label={t("buttons.resetZoom")}
|
||||||
aria-label={t("buttons.resetZoom")}
|
onClick={() => {
|
||||||
onClick={() => {
|
updateData(null);
|
||||||
updateData(null);
|
}}
|
||||||
}}
|
/>
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{(appState.zoom.value * 100).toFixed(0)}%
|
|
||||||
</ToolButton>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||||
@@ -271,28 +258,3 @@ export const actionZoomToFit = register({
|
|||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
!event[KEYS.CTRL_OR_CMD],
|
!event[KEYS.CTRL_OR_CMD],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionToggleTheme = register({
|
|
||||||
name: "toggleTheme",
|
|
||||||
perform: (_, appState, value) => {
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
theme:
|
|
||||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
|
||||||
},
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
|
||||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
|
||||||
<DarkModeToggle
|
|
||||||
value={appState.theme}
|
|
||||||
onChange={(theme) => {
|
|
||||||
updateData(theme);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
|
||||||
});
|
|
||||||
|
@@ -50,6 +50,7 @@ export const actionCopyAsSvg = register({
|
|||||||
? selectedElements
|
? selectedElements
|
||||||
: getNonDeletedElements(elements),
|
: getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
app.canvas,
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -88,6 +89,7 @@ export const actionCopyAsPng = register({
|
|||||||
? selectedElements
|
? selectedElements
|
||||||
: getNonDeletedElements(elements),
|
: getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
app.canvas,
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import React from "react";
|
||||||
import { trash } from "../components/icons";
|
import { trash } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
DistributeHorizontallyIcon,
|
DistributeHorizontallyIcon,
|
||||||
DistributeVerticallyIcon,
|
DistributeVerticallyIcon,
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
@@ -1,25 +1,16 @@
|
|||||||
|
import React from "react";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { load, questionCircle, saveAs } from "../components/icons";
|
import { load, questionCircle, save, saveAs } from "../components/icons";
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import "../components/ToolIcon.scss";
|
import "../components/ToolIcon.scss";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
|
||||||
import { loadFromJSON, saveAsJSON } from "../data";
|
import { loadFromJSON, saveAsJSON } from "../data";
|
||||||
import { resaveAsImageWithScene } from "../data/resave";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import useIsMobile from "../is-mobile";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { CheckboxItem } from "../components/CheckboxItem";
|
|
||||||
import { getExportSize } from "../scene/export";
|
|
||||||
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
|
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|
||||||
import { getNonDeletedElements } from "../element";
|
|
||||||
import { ActiveFile } from "../components/ActiveFile";
|
|
||||||
import { isImageFileHandle } from "../data/blob";
|
|
||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
|
||||||
import { Theme } from "../element/types";
|
|
||||||
|
|
||||||
export const actionChangeProjectName = register({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
@@ -32,61 +23,11 @@ export const actionChangeProjectName = register({
|
|||||||
label={t("labels.fileTitle")}
|
label={t("labels.fileTitle")}
|
||||||
value={appState.name || "Unnamed"}
|
value={appState.name || "Unnamed"}
|
||||||
onChange={(name: string) => updateData(name)}
|
onChange={(name: string) => updateData(name)}
|
||||||
isNameEditable={
|
isNameEditable={typeof appProps.name === "undefined"}
|
||||||
typeof appProps.name === "undefined" && !appState.viewModeEnabled
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeExportScale = register({
|
|
||||||
name: "changeExportScale",
|
|
||||||
perform: (_elements, appState, value) => {
|
|
||||||
return {
|
|
||||||
appState: { ...appState, exportScale: value },
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
|
||||||
const elements = getNonDeletedElements(allElements);
|
|
||||||
const exportSelected = isSomeElementSelected(elements, appState);
|
|
||||||
const exportedElements = exportSelected
|
|
||||||
? getSelectedElements(elements, appState)
|
|
||||||
: elements;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{EXPORT_SCALES.map((s) => {
|
|
||||||
const [width, height] = getExportSize(
|
|
||||||
exportedElements,
|
|
||||||
DEFAULT_EXPORT_PADDING,
|
|
||||||
s,
|
|
||||||
);
|
|
||||||
|
|
||||||
const scaleButtonTitle = `${t(
|
|
||||||
"buttons.scale",
|
|
||||||
)} ${s}x (${width}x${height})`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToolButton
|
|
||||||
key={s}
|
|
||||||
size="small"
|
|
||||||
type="radio"
|
|
||||||
icon={`${s}x`}
|
|
||||||
name="export-canvas-scale"
|
|
||||||
title={scaleButtonTitle}
|
|
||||||
aria-label={scaleButtonTitle}
|
|
||||||
id="export-canvas-scale"
|
|
||||||
checked={s === appState.exportScale}
|
|
||||||
onChange={() => updateData(s)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionChangeExportBackground = register({
|
export const actionChangeExportBackground = register({
|
||||||
name: "changeExportBackground",
|
name: "changeExportBackground",
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
@@ -96,12 +37,14 @@ export const actionChangeExportBackground = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
<CheckboxItem
|
<label>
|
||||||
checked={appState.exportBackground}
|
<input
|
||||||
onChange={(checked) => updateData(checked)}
|
type="checkbox"
|
||||||
>
|
checked={appState.exportBackground}
|
||||||
|
onChange={(event) => updateData(event.target.checked)}
|
||||||
|
/>{" "}
|
||||||
{t("labels.withBackground")}
|
{t("labels.withBackground")}
|
||||||
</CheckboxItem>
|
</label>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,35 +57,57 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
<CheckboxItem
|
<label style={{ display: "flex" }}>
|
||||||
checked={appState.exportEmbedScene}
|
<input
|
||||||
onChange={(checked) => updateData(checked)}
|
type="checkbox"
|
||||||
>
|
checked={appState.exportEmbedScene}
|
||||||
|
onChange={(event) => updateData(event.target.checked)}
|
||||||
|
/>{" "}
|
||||||
{t("labels.exportEmbedScene")}
|
{t("labels.exportEmbedScene")}
|
||||||
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
<Tooltip
|
||||||
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
|
label={t("labels.exportEmbedScene_details")}
|
||||||
|
position="above"
|
||||||
|
long={true}
|
||||||
|
>
|
||||||
|
<div className="TooltipIcon">{questionCircle}</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</CheckboxItem>
|
</label>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionSaveToActiveFile = register({
|
export const actionChangeShouldAddWatermark = register({
|
||||||
name: "saveToActiveFile",
|
name: "changeShouldAddWatermark",
|
||||||
|
perform: (_elements, appState, value) => {
|
||||||
|
return {
|
||||||
|
appState: { ...appState, shouldAddWatermark: value },
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={appState.shouldAddWatermark}
|
||||||
|
onChange={(event) => updateData(event.target.checked)}
|
||||||
|
/>{" "}
|
||||||
|
{t("labels.addWatermark")}
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionSaveScene = register({
|
||||||
|
name: "saveScene",
|
||||||
perform: async (elements, appState, value) => {
|
perform: async (elements, appState, value) => {
|
||||||
const fileHandleExists = !!appState.fileHandle;
|
const fileHandleExists = !!appState.fileHandle;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||||
? await resaveAsImageWithScene(elements, appState)
|
|
||||||
: await saveAsJSON(elements, appState);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
fileHandle,
|
fileHandle,
|
||||||
toastMessage: fileHandleExists
|
toastMessage: fileHandleExists
|
||||||
? fileHandle?.name
|
? fileHandle.name
|
||||||
? t("toast.fileSavedToFilename").replace(
|
? t("toast.fileSavedToFilename").replace(
|
||||||
"{filename}",
|
"{filename}",
|
||||||
`"${fileHandle.name}"`,
|
`"${fileHandle.name}"`,
|
||||||
@@ -160,16 +125,20 @@ export const actionSaveToActiveFile = register({
|
|||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ActiveFile
|
<ToolButton
|
||||||
onSave={() => updateData(null)}
|
type="button"
|
||||||
fileName={appState.fileHandle?.name}
|
icon={save}
|
||||||
|
title={t("buttons.save")}
|
||||||
|
aria-label={t("buttons.save")}
|
||||||
|
showAriaLabel={useIsMobile()}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionSaveFileToDisk = register({
|
export const actionSaveAsScene = register({
|
||||||
name: "saveFileToDisk",
|
name: "saveAsScene",
|
||||||
perform: async (elements, appState, value) => {
|
perform: async (elements, appState, value) => {
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(elements, {
|
const { fileHandle } = await saveAsJSON(elements, {
|
||||||
@@ -193,9 +162,10 @@ export const actionSaveFileToDisk = register({
|
|||||||
title={t("buttons.saveAs")}
|
title={t("buttons.saveAs")}
|
||||||
aria-label={t("buttons.saveAs")}
|
aria-label={t("buttons.saveAs")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
hidden={!nativeFileSystemSupported}
|
hidden={
|
||||||
|
!("chooseFileSystemEntries" in window || "showOpenFilePicker" in window)
|
||||||
|
}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
data-testid="save-as-button"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -207,7 +177,7 @@ export const actionLoadScene = register({
|
|||||||
const {
|
const {
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
} = await loadFromJSON(appState, elements);
|
} = await loadFromJSON(appState);
|
||||||
return {
|
return {
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
@@ -233,7 +203,6 @@ export const actionLoadScene = register({
|
|||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
data-testid="load-button"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -256,9 +225,9 @@ export const actionExportWithDarkMode = register({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DarkModeToggle
|
<DarkModeToggle
|
||||||
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
|
value={appState.exportWithDarkMode ? "dark" : "light"}
|
||||||
onChange={(theme: Theme) => {
|
onChange={(theme: Appearence) => {
|
||||||
updateData(theme === THEME.DARK);
|
updateData(theme === "dark");
|
||||||
}}
|
}}
|
||||||
title={t("labels.toggleExportColorScheme")}
|
title={t("labels.toggleExportColorScheme")}
|
||||||
/>
|
/>
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isInvisiblySmallElement } from "../element";
|
import { isInvisiblySmallElement } from "../element";
|
||||||
import { resetCursor } from "../utils";
|
import { resetCursor } from "../utils";
|
||||||
|
import React from "react";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -17,7 +18,7 @@ import { isBindingElement } from "../element/typeChecks";
|
|||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
perform: (elements, appState, _, { canvas }) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const {
|
||||||
elementId,
|
elementId,
|
||||||
@@ -50,19 +51,19 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
focusContainer();
|
window.document.activeElement.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
const multiPointElement = appState.multiElement
|
const multiPointElement = appState.multiElement
|
||||||
? appState.multiElement
|
? appState.multiElement
|
||||||
: appState.editingElement?.type === "freedraw"
|
: appState.editingElement?.type === "draw"
|
||||||
? appState.editingElement
|
? appState.editingElement
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (multiPointElement) {
|
if (multiPointElement) {
|
||||||
// pen and mouse have hover
|
// pen and mouse have hover
|
||||||
if (
|
if (
|
||||||
multiPointElement.type !== "freedraw" &&
|
multiPointElement.type !== "draw" &&
|
||||||
appState.lastPointerDownWith !== "touch"
|
appState.lastPointerDownWith !== "touch"
|
||||||
) {
|
) {
|
||||||
const { points, lastCommittedPoint } = multiPointElement;
|
const { points, lastCommittedPoint } = multiPointElement;
|
||||||
@@ -85,7 +86,7 @@ export const actionFinalize = register({
|
|||||||
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
||||||
if (
|
if (
|
||||||
multiPointElement.type === "line" ||
|
multiPointElement.type === "line" ||
|
||||||
multiPointElement.type === "freedraw"
|
multiPointElement.type === "draw"
|
||||||
) {
|
) {
|
||||||
if (isLoop) {
|
if (isLoop) {
|
||||||
const linePoints = multiPointElement.points;
|
const linePoints = multiPointElement.points;
|
||||||
@@ -117,24 +118,22 @@ export const actionFinalize = register({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!appState.elementLocked && appState.elementType !== "freedraw") {
|
if (!appState.elementLocked && appState.elementType !== "draw") {
|
||||||
appState.selectedElementIds[multiPointElement.id] = true;
|
appState.selectedElementIds[multiPointElement.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(!appState.elementLocked && appState.elementType !== "freedraw") ||
|
(!appState.elementLocked && appState.elementType !== "draw") ||
|
||||||
!multiPointElement
|
!multiPointElement
|
||||||
) {
|
) {
|
||||||
resetCursor(canvas);
|
resetCursor(canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
elementType:
|
elementType:
|
||||||
(appState.elementLocked || appState.elementType === "freedraw") &&
|
(appState.elementLocked || appState.elementType === "draw") &&
|
||||||
multiPointElement
|
multiPointElement
|
||||||
? appState.elementType
|
? appState.elementType
|
||||||
: "selection",
|
: "selection",
|
||||||
@@ -146,14 +145,14 @@ export const actionFinalize = register({
|
|||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
multiPointElement &&
|
multiPointElement &&
|
||||||
!appState.elementLocked &&
|
!appState.elementLocked &&
|
||||||
appState.elementType !== "freedraw"
|
appState.elementType !== "draw"
|
||||||
? {
|
? {
|
||||||
...appState.selectedElementIds,
|
...appState.selectedElementIds,
|
||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.elementType === "freedraw",
|
commitToHistory: appState.elementType === "draw",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
|
@@ -1,207 +0,0 @@
|
|||||||
import { register } from "./register";
|
|
||||||
import { getSelectedElements } from "../scene";
|
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
|
||||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { getTransformHandles } from "../element/transformHandles";
|
|
||||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
|
||||||
import { updateBoundElements } from "../element/binding";
|
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
|
||||||
|
|
||||||
const enableActionFlipHorizontal = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
) => {
|
|
||||||
const eligibleElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
return eligibleElements.length === 1 && eligibleElements[0].type !== "text";
|
|
||||||
};
|
|
||||||
|
|
||||||
const enableActionFlipVertical = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
) => {
|
|
||||||
const eligibleElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
return eligibleElements.length === 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
|
||||||
name: "flipHorizontal",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
return {
|
|
||||||
elements: flipSelectedElements(elements, appState, "horizontal"),
|
|
||||||
appState,
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
keyTest: (event) => event.shiftKey && event.code === "KeyH",
|
|
||||||
contextItemLabel: "labels.flipHorizontal",
|
|
||||||
contextItemPredicate: (elements, appState) =>
|
|
||||||
enableActionFlipHorizontal(elements, appState),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionFlipVertical = register({
|
|
||||||
name: "flipVertical",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
return {
|
|
||||||
elements: flipSelectedElements(elements, appState, "vertical"),
|
|
||||||
appState,
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
keyTest: (event) => event.shiftKey && event.code === "KeyV",
|
|
||||||
contextItemLabel: "labels.flipVertical",
|
|
||||||
contextItemPredicate: (elements, appState) =>
|
|
||||||
enableActionFlipVertical(elements, appState),
|
|
||||||
});
|
|
||||||
|
|
||||||
const flipSelectedElements = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: Readonly<AppState>,
|
|
||||||
flipDirection: "horizontal" | "vertical",
|
|
||||||
) => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
|
|
||||||
// remove once we allow for groups of elements to be flipped
|
|
||||||
if (selectedElements.length > 1) {
|
|
||||||
return elements;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedElements = flipElements(
|
|
||||||
selectedElements,
|
|
||||||
appState,
|
|
||||||
flipDirection,
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedElementsMap = getElementMap(updatedElements);
|
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
|
||||||
};
|
|
||||||
|
|
||||||
const flipElements = (
|
|
||||||
elements: NonDeleted<ExcalidrawElement>[],
|
|
||||||
appState: AppState,
|
|
||||||
flipDirection: "horizontal" | "vertical",
|
|
||||||
): ExcalidrawElement[] => {
|
|
||||||
for (let i = 0; i < elements.length; i++) {
|
|
||||||
flipElement(elements[i], appState);
|
|
||||||
// If vertical flip, rotate an extra 180
|
|
||||||
if (flipDirection === "vertical") {
|
|
||||||
rotateElement(elements[i], Math.PI);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return elements;
|
|
||||||
};
|
|
||||||
|
|
||||||
const flipElement = (
|
|
||||||
element: NonDeleted<ExcalidrawElement>,
|
|
||||||
appState: AppState,
|
|
||||||
) => {
|
|
||||||
const originalX = element.x;
|
|
||||||
const originalY = element.y;
|
|
||||||
const width = element.width;
|
|
||||||
const height = element.height;
|
|
||||||
const originalAngle = normalizeAngle(element.angle);
|
|
||||||
|
|
||||||
let finalOffsetX = 0;
|
|
||||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
|
||||||
finalOffsetX =
|
|
||||||
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
|
|
||||||
element.width;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotate back to zero, if necessary
|
|
||||||
mutateElement(element, {
|
|
||||||
angle: normalizeAngle(0),
|
|
||||||
});
|
|
||||||
// Flip unrotated by pulling TransformHandle to opposite side
|
|
||||||
const transformHandles = getTransformHandles(element, appState.zoom);
|
|
||||||
let usingNWHandle = true;
|
|
||||||
let newNCoordsX = 0;
|
|
||||||
let nHandle = transformHandles.nw;
|
|
||||||
if (!nHandle) {
|
|
||||||
// Use ne handle instead
|
|
||||||
usingNWHandle = false;
|
|
||||||
nHandle = transformHandles.ne;
|
|
||||||
if (!nHandle) {
|
|
||||||
mutateElement(element, {
|
|
||||||
angle: originalAngle,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLinearElement(element)) {
|
|
||||||
for (let i = 1; i < element.points.length; i++) {
|
|
||||||
LinearElementEditor.movePoint(element, i, [
|
|
||||||
-element.points[i][0],
|
|
||||||
element.points[i][1],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
LinearElementEditor.normalizePoints(element);
|
|
||||||
} else {
|
|
||||||
// calculate new x-coord for transformation
|
|
||||||
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
|
|
||||||
resizeSingleElement(
|
|
||||||
element,
|
|
||||||
true,
|
|
||||||
element,
|
|
||||||
usingNWHandle ? "nw" : "ne",
|
|
||||||
false,
|
|
||||||
newNCoordsX,
|
|
||||||
nHandle[1],
|
|
||||||
);
|
|
||||||
// fix the size to account for handle sizes
|
|
||||||
mutateElement(element, {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotate by (360 degrees - original angle)
|
|
||||||
let angle = normalizeAngle(2 * Math.PI - originalAngle);
|
|
||||||
if (angle < 0) {
|
|
||||||
// check, probably unnecessary
|
|
||||||
angle = normalizeAngle(angle + 2 * Math.PI);
|
|
||||||
}
|
|
||||||
mutateElement(element, {
|
|
||||||
angle,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Move back to original spot to appear "flipped in place"
|
|
||||||
mutateElement(element, {
|
|
||||||
x: originalX + finalOffsetX,
|
|
||||||
y: originalY,
|
|
||||||
});
|
|
||||||
|
|
||||||
updateBoundElements(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
|
|
||||||
const originalX = element.x;
|
|
||||||
const originalY = element.y;
|
|
||||||
let angle = normalizeAngle(element.angle + rotationAngle);
|
|
||||||
if (angle < 0) {
|
|
||||||
// check, probably unnecessary
|
|
||||||
angle = normalizeAngle(2 * Math.PI + angle);
|
|
||||||
}
|
|
||||||
mutateElement(element, {
|
|
||||||
angle,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Move back to original spot
|
|
||||||
mutateElement(element, {
|
|
||||||
x: originalX,
|
|
||||||
y: originalY,
|
|
||||||
});
|
|
||||||
};
|
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
import { Action, ActionResult } from "./types";
|
import { Action, ActionResult } from "./types";
|
||||||
|
import React from "react";
|
||||||
import { undo, redo } from "../components/icons";
|
import { undo, redo } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import History, { HistoryEntry } from "../history";
|
import { SceneHistory, HistoryEntry } from "../history";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isWindows, KEYS } from "../keys";
|
import { isWindows, KEYS } from "../keys";
|
||||||
@@ -58,7 +59,7 @@ const writeData = (
|
|||||||
return { commitToHistory };
|
return { commitToHistory };
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActionCreator = (history: History) => Action;
|
type ActionCreator = (history: SceneHistory) => Action;
|
||||||
|
|
||||||
export const createUndoAction: ActionCreator = (history) => ({
|
export const createUndoAction: ActionCreator = (history) => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
@@ -68,13 +69,12 @@ export const createUndoAction: ActionCreator = (history) => ({
|
|||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.key.toLowerCase() === KEYS.Z &&
|
event.key.toLowerCase() === KEYS.Z &&
|
||||||
!event.shiftKey,
|
!event.shiftKey,
|
||||||
PanelComponent: ({ updateData, data }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={undo}
|
icon={undo}
|
||||||
aria-label={t("buttons.undo")}
|
aria-label={t("buttons.undo")}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
size={data?.size || "medium"}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
commitToHistory: () => false,
|
commitToHistory: () => false,
|
||||||
@@ -89,13 +89,12 @@ export const createRedoAction: ActionCreator = (history) => ({
|
|||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event.key.toLowerCase() === KEYS.Z) ||
|
event.key.toLowerCase() === KEYS.Z) ||
|
||||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||||
PanelComponent: ({ updateData, data }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={redo}
|
icon={redo}
|
||||||
aria-label={t("buttons.redo")}
|
aria-label={t("buttons.redo")}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
size={data?.size || "medium"}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
commitToHistory: () => false,
|
commitToHistory: () => false,
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { menu, palette } from "../components/icons";
|
import { menu, palette } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -69,10 +70,7 @@ export const actionFullScreen = register({
|
|||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
perform: (_elements, appState, _, { focusContainer }) => {
|
perform: (_elements, appState) => {
|
||||||
if (appState.showHelpDialog) {
|
|
||||||
focusContainer();
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { getClientColors, getClientInitials } from "../clients";
|
import { getClientColors, getClientInitials } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
@@ -29,8 +30,8 @@ export const actionGoToCollaborator = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, data }) => {
|
PanelComponent: ({ appState, updateData, id }) => {
|
||||||
const clientId: string | undefined = data?.id;
|
const clientId = id;
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
import { AppState } from "../../src/types";
|
import { AppState } from "../../src/types";
|
||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
|
import { ButtonSelect } from "../components/ButtonSelect";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { IconPicker } from "../components/IconPicker";
|
import { IconPicker } from "../components/IconPicker";
|
||||||
import {
|
import {
|
||||||
@@ -12,13 +14,6 @@ import {
|
|||||||
FillCrossHatchIcon,
|
FillCrossHatchIcon,
|
||||||
FillHachureIcon,
|
FillHachureIcon,
|
||||||
FillSolidIcon,
|
FillSolidIcon,
|
||||||
FontFamilyCodeIcon,
|
|
||||||
FontFamilyHandDrawnIcon,
|
|
||||||
FontFamilyNormalIcon,
|
|
||||||
FontSizeExtraLargeIcon,
|
|
||||||
FontSizeLargeIcon,
|
|
||||||
FontSizeMediumIcon,
|
|
||||||
FontSizeSmallIcon,
|
|
||||||
SloppinessArchitectIcon,
|
SloppinessArchitectIcon,
|
||||||
SloppinessArtistIcon,
|
SloppinessArtistIcon,
|
||||||
SloppinessCartoonistIcon,
|
SloppinessCartoonistIcon,
|
||||||
@@ -26,15 +21,8 @@ import {
|
|||||||
StrokeStyleDottedIcon,
|
StrokeStyleDottedIcon,
|
||||||
StrokeStyleSolidIcon,
|
StrokeStyleSolidIcon,
|
||||||
StrokeWidthIcon,
|
StrokeWidthIcon,
|
||||||
TextAlignCenterIcon,
|
|
||||||
TextAlignLeftIcon,
|
|
||||||
TextAlignRightIcon,
|
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import {
|
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
|
||||||
DEFAULT_FONT_FAMILY,
|
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
FONT_FAMILY,
|
|
||||||
} from "../constants";
|
|
||||||
import {
|
import {
|
||||||
getNonDeletedElements,
|
getNonDeletedElements,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
@@ -47,7 +35,7 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
FontFamilyValues,
|
FontFamily,
|
||||||
TextAlign,
|
TextAlign,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getLanguage, t } from "../i18n";
|
import { getLanguage, t } from "../i18n";
|
||||||
@@ -102,18 +90,13 @@ export const actionChangeStrokeColor = register({
|
|||||||
name: "changeStrokeColor",
|
name: "changeStrokeColor",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
...(value.currentItemStrokeColor && {
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
newElementWith(el, {
|
||||||
newElementWith(el, {
|
strokeColor: value,
|
||||||
strokeColor: value.currentItemStrokeColor,
|
}),
|
||||||
}),
|
),
|
||||||
),
|
appState: { ...appState, currentItemStrokeColor: value },
|
||||||
}),
|
commitToHistory: true,
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
...value,
|
|
||||||
},
|
|
||||||
commitToHistory: !!value.currentItemStrokeColor,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@@ -128,11 +111,7 @@ export const actionChangeStrokeColor = register({
|
|||||||
(element) => element.strokeColor,
|
(element) => element.strokeColor,
|
||||||
appState.currentItemStrokeColor,
|
appState.currentItemStrokeColor,
|
||||||
)}
|
)}
|
||||||
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
onChange={updateData}
|
||||||
isActive={appState.openPopup === "strokeColorPicker"}
|
|
||||||
setActive={(active) =>
|
|
||||||
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -142,18 +121,13 @@ export const actionChangeBackgroundColor = register({
|
|||||||
name: "changeBackgroundColor",
|
name: "changeBackgroundColor",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
...(value.currentItemBackgroundColor && {
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
newElementWith(el, {
|
||||||
newElementWith(el, {
|
backgroundColor: value,
|
||||||
backgroundColor: value.currentItemBackgroundColor,
|
}),
|
||||||
}),
|
),
|
||||||
),
|
appState: { ...appState, currentItemBackgroundColor: value },
|
||||||
}),
|
commitToHistory: true,
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
...value,
|
|
||||||
},
|
|
||||||
commitToHistory: !!value.currentItemBackgroundColor,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@@ -168,11 +142,7 @@ export const actionChangeBackgroundColor = register({
|
|||||||
(element) => element.backgroundColor,
|
(element) => element.backgroundColor,
|
||||||
appState.currentItemBackgroundColor,
|
appState.currentItemBackgroundColor,
|
||||||
)}
|
)}
|
||||||
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
onChange={updateData}
|
||||||
isActive={appState.openPopup === "backgroundColorPicker"}
|
|
||||||
setActive={(active) =>
|
|
||||||
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -443,29 +413,13 @@ export const actionChangeFontSize = register({
|
|||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fontSize")}</legend>
|
<legend>{t("labels.fontSize")}</legend>
|
||||||
<ButtonIconSelect
|
<ButtonSelect
|
||||||
group="font-size"
|
group="font-size"
|
||||||
options={[
|
options={[
|
||||||
{
|
{ value: 16, text: t("labels.small") },
|
||||||
value: 16,
|
{ value: 20, text: t("labels.medium") },
|
||||||
text: t("labels.small"),
|
{ value: 28, text: t("labels.large") },
|
||||||
icon: <FontSizeSmallIcon theme={appState.theme} />,
|
{ value: 36, text: t("labels.veryLarge") },
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 20,
|
|
||||||
text: t("labels.medium"),
|
|
||||||
icon: <FontSizeMediumIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 28,
|
|
||||||
text: t("labels.large"),
|
|
||||||
icon: <FontSizeLargeIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 36,
|
|
||||||
text: t("labels.veryLarge"),
|
|
||||||
icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
@@ -502,32 +456,16 @@ export const actionChangeFontFamily = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
const options: {
|
const options: { value: FontFamily; text: string }[] = [
|
||||||
value: FontFamilyValues;
|
{ value: 1, text: t("labels.handDrawn") },
|
||||||
text: string;
|
{ value: 2, text: t("labels.normal") },
|
||||||
icon: JSX.Element;
|
{ value: 3, text: t("labels.code") },
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
value: FONT_FAMILY.Virgil,
|
|
||||||
text: t("labels.handDrawn"),
|
|
||||||
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: FONT_FAMILY.Helvetica,
|
|
||||||
text: t("labels.normal"),
|
|
||||||
icon: <FontFamilyNormalIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: FONT_FAMILY.Cascadia,
|
|
||||||
text: t("labels.code"),
|
|
||||||
icon: <FontFamilyCodeIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fontFamily")}</legend>
|
<legend>{t("labels.fontFamily")}</legend>
|
||||||
<ButtonIconSelect<FontFamilyValues | false>
|
<ButtonSelect<FontFamily | false>
|
||||||
group="font-family"
|
group="font-family"
|
||||||
options={options}
|
options={options}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
@@ -568,24 +506,12 @@ export const actionChangeTextAlign = register({
|
|||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.textAlign")}</legend>
|
<legend>{t("labels.textAlign")}</legend>
|
||||||
<ButtonIconSelect<TextAlign | false>
|
<ButtonSelect<TextAlign | false>
|
||||||
group="text-align"
|
group="text-align"
|
||||||
options={[
|
options={[
|
||||||
{
|
{ value: "left", text: t("labels.left") },
|
||||||
value: "left",
|
{ value: "center", text: t("labels.center") },
|
||||||
text: t("labels.left"),
|
{ value: "right", text: t("labels.right") },
|
||||||
icon: <TextAlignLeftIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "center",
|
|
||||||
text: t("labels.center"),
|
|
||||||
icon: <TextAlignCenterIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "right",
|
|
||||||
text: t("labels.right"),
|
|
||||||
icon: <TextAlignRightIcon theme={appState.theme} />,
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { CODES, KEYS } from "../keys";
|
|
||||||
|
|
||||||
export const actionToggleStats = register({
|
export const actionToggleStats = register({
|
||||||
name: "stats",
|
name: "stats",
|
||||||
@@ -14,6 +13,4 @@ export const actionToggleStats = register({
|
|||||||
},
|
},
|
||||||
checked: (appState) => appState.showStats,
|
checked: (appState) => appState.showStats,
|
||||||
contextItemLabel: "stats.title",
|
contextItemLabel: "stats.title",
|
||||||
keyTest: (event) =>
|
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
|
||||||
});
|
});
|
||||||
|
@@ -10,6 +10,7 @@ export const actionToggleViewMode = register({
|
|||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
viewModeEnabled: !this.checked!(appState),
|
viewModeEnabled: !this.checked!(appState),
|
||||||
|
selectedElementIds: {},
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
|
@@ -26,7 +26,6 @@ export {
|
|||||||
actionZoomOut,
|
actionZoomOut,
|
||||||
actionResetZoom,
|
actionResetZoom,
|
||||||
actionZoomToFit,
|
actionZoomToFit,
|
||||||
actionToggleTheme,
|
|
||||||
} from "./actionCanvas";
|
} from "./actionCanvas";
|
||||||
|
|
||||||
export { actionFinalize } from "./actionFinalize";
|
export { actionFinalize } from "./actionFinalize";
|
||||||
@@ -34,8 +33,8 @@ export { actionFinalize } from "./actionFinalize";
|
|||||||
export {
|
export {
|
||||||
actionChangeProjectName,
|
actionChangeProjectName,
|
||||||
actionChangeExportBackground,
|
actionChangeExportBackground,
|
||||||
actionSaveToActiveFile,
|
actionSaveScene,
|
||||||
actionSaveFileToDisk,
|
actionSaveAsScene,
|
||||||
actionLoadScene,
|
actionLoadScene,
|
||||||
} from "./actionExport";
|
} from "./actionExport";
|
||||||
|
|
||||||
@@ -67,8 +66,6 @@ export {
|
|||||||
distributeVertically,
|
distributeVertically,
|
||||||
} from "./actionDistribute";
|
} from "./actionDistribute";
|
||||||
|
|
||||||
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
actionCopy,
|
actionCopy,
|
||||||
actionCut,
|
actionCut,
|
||||||
|
@@ -5,21 +5,14 @@ import {
|
|||||||
UpdaterFn,
|
UpdaterFn,
|
||||||
ActionName,
|
ActionName,
|
||||||
ActionResult,
|
ActionResult,
|
||||||
PanelComponentProps,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppProps, AppState } from "../types";
|
import { AppState, ExcalidrawProps } from "../types";
|
||||||
import { MODES } from "../constants";
|
import { MODES } from "../constants";
|
||||||
import Library from "../data/library";
|
|
||||||
|
|
||||||
// This is the <App> component, but for now we don't care about anything but its
|
// This is the <App> component, but for now we don't care about anything but its
|
||||||
// `canvas` state.
|
// `canvas` state.
|
||||||
type App = {
|
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
|
||||||
canvas: HTMLCanvasElement | null;
|
|
||||||
focusContainer: () => void;
|
|
||||||
props: AppProps;
|
|
||||||
library: Library;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ActionManager implements ActionsManagerInterface {
|
export class ActionManager implements ActionsManagerInterface {
|
||||||
actions = {} as ActionsManagerInterface["actions"];
|
actions = {} as ActionsManagerInterface["actions"];
|
||||||
@@ -58,15 +51,11 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
actions.forEach((action) => this.registerAction(action));
|
actions.forEach((action) => this.registerAction(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) {
|
handleKeyDown(event: KeyboardEvent) {
|
||||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
|
||||||
const data = Object.values(this.actions)
|
const data = Object.values(this.actions)
|
||||||
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
|
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
|
||||||
.filter(
|
.filter(
|
||||||
(action) =>
|
(action) =>
|
||||||
(action.name in canvasActions
|
|
||||||
? canvasActions[action.name as keyof typeof canvasActions]
|
|
||||||
: true) &&
|
|
||||||
action.keyTest &&
|
action.keyTest &&
|
||||||
action.keyTest(
|
action.keyTest(
|
||||||
event,
|
event,
|
||||||
@@ -108,19 +97,12 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Id is an attribute that we can use to pass in data like keys.
|
||||||
* @param data additional data sent to the PanelComponent
|
// This is needed for dynamically generated action components
|
||||||
*/
|
// like the user list. We can use this key to extract more
|
||||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
// data from app state. This is an alternative to generic prop hell!
|
||||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
renderAction = (name: ActionName, id?: string) => {
|
||||||
|
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
|
||||||
if (
|
|
||||||
this.actions[name] &&
|
|
||||||
"PanelComponent" in this.actions[name] &&
|
|
||||||
(name in canvasActions
|
|
||||||
? canvasActions[name as keyof typeof canvasActions]
|
|
||||||
: true)
|
|
||||||
) {
|
|
||||||
const action = this.actions[name];
|
const action = this.actions[name];
|
||||||
const PanelComponent = action.PanelComponent!;
|
const PanelComponent = action.PanelComponent!;
|
||||||
const updateData = (formState?: any) => {
|
const updateData = (formState?: any) => {
|
||||||
@@ -139,8 +121,8 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
elements={this.getElementsIncludingDeleted()}
|
elements={this.getElementsIncludingDeleted()}
|
||||||
appState={this.getAppState()}
|
appState={this.getAppState()}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
id={id}
|
||||||
appProps={this.app.props}
|
appProps={this.app.props}
|
||||||
data={data}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -23,9 +23,7 @@ export type ShortcutName =
|
|||||||
| "zenMode"
|
| "zenMode"
|
||||||
| "stats"
|
| "stats"
|
||||||
| "addToLibrary"
|
| "addToLibrary"
|
||||||
| "viewMode"
|
| "viewMode";
|
||||||
| "flipHorizontal"
|
|
||||||
| "flipVertical";
|
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||||
@@ -57,10 +55,8 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||||
zenMode: [getShortcutKey("Alt+Z")],
|
zenMode: [getShortcutKey("Alt+Z")],
|
||||||
stats: [getShortcutKey("Alt+/")],
|
stats: [],
|
||||||
addToLibrary: [],
|
addToLibrary: [],
|
||||||
flipHorizontal: [getShortcutKey("Shift+H")],
|
|
||||||
flipVertical: [getShortcutKey("Shift+V")],
|
|
||||||
viewMode: [getShortcutKey("Alt+R")],
|
viewMode: [getShortcutKey("Alt+R")],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -1,33 +1,22 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, ExcalidrawProps } from "../types";
|
import { AppState, ExcalidrawProps } from "../types";
|
||||||
import Library from "../data/library";
|
|
||||||
import { ToolButtonSize } from "../components/ToolButton";
|
|
||||||
|
|
||||||
/** if false, the action should be prevented */
|
/** if false, the action should be prevented */
|
||||||
export type ActionResult =
|
export type ActionResult =
|
||||||
| {
|
| {
|
||||||
elements?: readonly ExcalidrawElement[] | null;
|
elements?: readonly ExcalidrawElement[] | null;
|
||||||
appState?: MarkOptional<
|
appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
|
||||||
AppState,
|
|
||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
|
||||||
> | null;
|
|
||||||
commitToHistory: boolean;
|
commitToHistory: boolean;
|
||||||
syncHistory?: boolean;
|
syncHistory?: boolean;
|
||||||
}
|
}
|
||||||
| false;
|
| false;
|
||||||
|
|
||||||
type AppAPI = {
|
|
||||||
canvas: HTMLCanvasElement | null;
|
|
||||||
focusContainer(): void;
|
|
||||||
library: Library;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ActionFn = (
|
type ActionFn = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
formData: any,
|
formData: any,
|
||||||
app: AppAPI,
|
app: { canvas: HTMLCanvasElement | null },
|
||||||
) => ActionResult | Promise<ActionResult>;
|
) => ActionResult | Promise<ActionResult>;
|
||||||
|
|
||||||
export type UpdaterFn = (res: ActionResult) => void;
|
export type UpdaterFn = (res: ActionResult) => void;
|
||||||
@@ -53,7 +42,6 @@ export type ActionName =
|
|||||||
| "changeBackgroundColor"
|
| "changeBackgroundColor"
|
||||||
| "changeFillStyle"
|
| "changeFillStyle"
|
||||||
| "changeStrokeWidth"
|
| "changeStrokeWidth"
|
||||||
| "changeStrokeShape"
|
|
||||||
| "changeSloppiness"
|
| "changeSloppiness"
|
||||||
| "changeStrokeStyle"
|
| "changeStrokeStyle"
|
||||||
| "changeArrowhead"
|
| "changeArrowhead"
|
||||||
@@ -67,9 +55,9 @@ export type ActionName =
|
|||||||
| "changeProjectName"
|
| "changeProjectName"
|
||||||
| "changeExportBackground"
|
| "changeExportBackground"
|
||||||
| "changeExportEmbedScene"
|
| "changeExportEmbedScene"
|
||||||
| "changeExportScale"
|
| "changeShouldAddWatermark"
|
||||||
| "saveToActiveFile"
|
| "saveScene"
|
||||||
| "saveFileToDisk"
|
| "saveAsScene"
|
||||||
| "loadScene"
|
| "loadScene"
|
||||||
| "duplicateSelection"
|
| "duplicateSelection"
|
||||||
| "deleteSelectedElements"
|
| "deleteSelectedElements"
|
||||||
@@ -97,27 +85,22 @@ export type ActionName =
|
|||||||
| "alignHorizontallyCentered"
|
| "alignHorizontallyCentered"
|
||||||
| "distributeHorizontally"
|
| "distributeHorizontally"
|
||||||
| "distributeVertically"
|
| "distributeVertically"
|
||||||
| "flipHorizontal"
|
|
||||||
| "flipVertical"
|
|
||||||
| "viewMode"
|
| "viewMode"
|
||||||
| "exportWithDarkMode"
|
| "exportWithDarkMode";
|
||||||
| "toggleTheme";
|
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
|
||||||
elements: readonly ExcalidrawElement[];
|
|
||||||
appState: AppState;
|
|
||||||
updateData: (formData?: any) => void;
|
|
||||||
appProps: ExcalidrawProps;
|
|
||||||
data?: Partial<{ id: string; size: ToolButtonSize }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: ActionName;
|
name: ActionName;
|
||||||
PanelComponent?: React.FC<PanelComponentProps>;
|
PanelComponent?: React.FC<{
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
appState: AppState;
|
||||||
|
updateData: (formData?: any) => void;
|
||||||
|
appProps: ExcalidrawProps;
|
||||||
|
id?: string;
|
||||||
|
}>;
|
||||||
perform: ActionFn;
|
perform: ActionFn;
|
||||||
keyPriority?: number;
|
keyPriority?: number;
|
||||||
keyTest?: (
|
keyTest?: (
|
||||||
event: React.KeyboardEvent | KeyboardEvent,
|
event: KeyboardEvent,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => boolean;
|
) => boolean;
|
||||||
@@ -132,7 +115,6 @@ export interface Action {
|
|||||||
export interface ActionsManagerInterface {
|
export interface ActionsManagerInterface {
|
||||||
actions: Record<ActionName, Action>;
|
actions: Record<ActionName, Action>;
|
||||||
registerAction: (action: Action) => void;
|
registerAction: (action: Action) => void;
|
||||||
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
|
handleKeyDown: (event: KeyboardEvent) => boolean;
|
||||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||||
executeAction: (action: Action) => void;
|
|
||||||
}
|
}
|
||||||
|
@@ -3,23 +3,17 @@ import {
|
|||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
EXPORT_SCALES,
|
|
||||||
THEME,
|
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
import { AppState, NormalizedZoomValue } from "./types";
|
import { AppState, NormalizedZoomValue } from "./types";
|
||||||
import { getDateTime } from "./utils";
|
import { getDateTime } from "./utils";
|
||||||
|
|
||||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
|
||||||
? devicePixelRatio
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
export const getDefaultAppState = (): Omit<
|
export const getDefaultAppState = (): Omit<
|
||||||
AppState,
|
AppState,
|
||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"offsetTop" | "offsetLeft"
|
||||||
> => {
|
> => {
|
||||||
return {
|
return {
|
||||||
theme: THEME.LIGHT,
|
theme: "light",
|
||||||
collaborators: new Map(),
|
collaborators: new Map(),
|
||||||
currentChartType: "bar",
|
currentChartType: "bar",
|
||||||
currentItemBackgroundColor: "transparent",
|
currentItemBackgroundColor: "transparent",
|
||||||
@@ -45,11 +39,11 @@ export const getDefaultAppState = (): Omit<
|
|||||||
elementType: "selection",
|
elementType: "selection",
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
exportBackground: true,
|
exportBackground: true,
|
||||||
exportScale: defaultExportScale,
|
|
||||||
exportEmbedScene: false,
|
exportEmbedScene: false,
|
||||||
exportWithDarkMode: false,
|
exportWithDarkMode: false,
|
||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
gridSize: null,
|
gridSize: null,
|
||||||
|
height: window.innerHeight,
|
||||||
isBindingEnabled: true,
|
isBindingEnabled: true,
|
||||||
isLibraryOpen: false,
|
isLibraryOpen: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -59,7 +53,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
multiElement: null,
|
multiElement: null,
|
||||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||||
openMenu: null,
|
openMenu: null,
|
||||||
openPopup: null,
|
|
||||||
pasteDialog: { shown: false, data: null },
|
pasteDialog: { shown: false, data: null },
|
||||||
previousSelectedElementIds: {},
|
previousSelectedElementIds: {},
|
||||||
resizingElement: null,
|
resizingElement: null,
|
||||||
@@ -69,6 +62,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
|
shouldAddWatermark: false,
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
showHelpDialog: false,
|
showHelpDialog: false,
|
||||||
showStats: false,
|
showStats: false,
|
||||||
@@ -76,6 +70,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
toastMessage: null,
|
toastMessage: null,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
|
width: window.innerWidth,
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
@@ -124,7 +119,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
errorMessage: { browser: false, export: false },
|
errorMessage: { browser: false, export: false },
|
||||||
exportBackground: { browser: true, export: false },
|
exportBackground: { browser: true, export: false },
|
||||||
exportEmbedScene: { browser: true, export: false },
|
exportEmbedScene: { browser: true, export: false },
|
||||||
exportScale: { browser: true, export: false },
|
|
||||||
exportWithDarkMode: { browser: true, export: false },
|
exportWithDarkMode: { browser: true, export: false },
|
||||||
fileHandle: { browser: false, export: false },
|
fileHandle: { browser: false, export: false },
|
||||||
gridSize: { browser: true, export: true },
|
gridSize: { browser: true, export: true },
|
||||||
@@ -140,7 +134,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
offsetLeft: { browser: false, export: false },
|
offsetLeft: { browser: false, export: false },
|
||||||
offsetTop: { browser: false, export: false },
|
offsetTop: { browser: false, export: false },
|
||||||
openMenu: { browser: true, export: false },
|
openMenu: { browser: true, export: false },
|
||||||
openPopup: { browser: false, export: false },
|
|
||||||
pasteDialog: { browser: false, export: false },
|
pasteDialog: { browser: false, export: false },
|
||||||
previousSelectedElementIds: { browser: true, export: false },
|
previousSelectedElementIds: { browser: true, export: false },
|
||||||
resizingElement: { browser: false, export: false },
|
resizingElement: { browser: false, export: false },
|
||||||
@@ -150,6 +143,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
selectedElementIds: { browser: true, export: false },
|
selectedElementIds: { browser: true, export: false },
|
||||||
selectedGroupIds: { browser: true, export: false },
|
selectedGroupIds: { browser: true, export: false },
|
||||||
selectionElement: { browser: false, export: false },
|
selectionElement: { browser: false, export: false },
|
||||||
|
shouldAddWatermark: { browser: true, export: false },
|
||||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
shouldCacheIgnoreZoom: { browser: true, export: false },
|
||||||
showHelpDialog: { browser: false, export: false },
|
showHelpDialog: { browser: false, export: false },
|
||||||
showStats: { browser: true, export: false },
|
showStats: { browser: true, export: false },
|
||||||
|
@@ -6,20 +6,16 @@ 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 { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
import { EXPORT_DATA_TYPES } from "./constants";
|
import { canvasToBlob } from "./data/blob";
|
||||||
|
|
||||||
|
const TYPE_ELEMENTS = "excalidraw/elements";
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof TYPE_ELEMENTS;
|
||||||
|
created: number;
|
||||||
elements: ExcalidrawElement[];
|
elements: ExcalidrawElement[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ClipboardData {
|
|
||||||
spreadsheet?: Spreadsheet;
|
|
||||||
elements?: readonly ExcalidrawElement[];
|
|
||||||
text?: string;
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let CLIPBOARD = "";
|
let CLIPBOARD = "";
|
||||||
let PREFER_APP_CLIPBOARD = false;
|
let PREFER_APP_CLIPBOARD = false;
|
||||||
|
|
||||||
@@ -35,16 +31,8 @@ export const probablySupportsClipboardBlob =
|
|||||||
"ClipboardItem" in window &&
|
"ClipboardItem" in window &&
|
||||||
"toBlob" in HTMLCanvasElement.prototype;
|
"toBlob" in HTMLCanvasElement.prototype;
|
||||||
|
|
||||||
const clipboardContainsElements = (
|
const isElementsClipboard = (contents: any): contents is ElementsClipboard => {
|
||||||
contents: any,
|
if (contents?.type === TYPE_ELEMENTS) {
|
||||||
): contents is { elements: ExcalidrawElement[] } => {
|
|
||||||
if (
|
|
||||||
[
|
|
||||||
EXPORT_DATA_TYPES.excalidraw,
|
|
||||||
EXPORT_DATA_TYPES.excalidrawClipboard,
|
|
||||||
].includes(contents?.type) &&
|
|
||||||
Array.isArray(contents.elements)
|
|
||||||
) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -55,7 +43,8 @@ export const copyToClipboard = async (
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
const contents: ElementsClipboard = {
|
const contents: ElementsClipboard = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
type: TYPE_ELEMENTS,
|
||||||
|
created: Date.now(),
|
||||||
elements: getSelectedElements(elements, appState),
|
elements: getSelectedElements(elements, appState),
|
||||||
};
|
};
|
||||||
const json = JSON.stringify(contents);
|
const json = JSON.stringify(contents);
|
||||||
@@ -116,7 +105,12 @@ const getSystemClipboard = async (
|
|||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent | null,
|
event: ClipboardEvent | null,
|
||||||
): Promise<ClipboardData> => {
|
): Promise<{
|
||||||
|
spreadsheet?: Spreadsheet;
|
||||||
|
elements?: readonly ExcalidrawElement[];
|
||||||
|
text?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}> => {
|
||||||
const systemClipboard = await getSystemClipboard(event);
|
const systemClipboard = await getSystemClipboard(event);
|
||||||
|
|
||||||
// if system clipboard empty, couldn't be resolved, or contains previously
|
// if system clipboard empty, couldn't be resolved, or contains previously
|
||||||
@@ -137,9 +131,15 @@ export const parseClipboard = async (
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const systemClipboardData = JSON.parse(systemClipboard);
|
const systemClipboardData = JSON.parse(systemClipboard);
|
||||||
if (clipboardContainsElements(systemClipboardData)) {
|
// system clipboard elements are newer than in-app clipboard
|
||||||
|
if (
|
||||||
|
isElementsClipboard(systemClipboardData) &&
|
||||||
|
(!appClipboardData?.created ||
|
||||||
|
appClipboardData.created < systemClipboardData.created)
|
||||||
|
) {
|
||||||
return { elements: systemClipboardData.elements };
|
return { elements: systemClipboardData.elements };
|
||||||
}
|
}
|
||||||
|
// in-app clipboard is newer than system clipboard
|
||||||
return appClipboardData;
|
return appClipboardData;
|
||||||
} catch {
|
} catch {
|
||||||
// system clipboard doesn't contain excalidraw elements → return plaintext
|
// system clipboard doesn't contain excalidraw elements → return plaintext
|
||||||
@@ -151,7 +151,8 @@ export const parseClipboard = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
|
||||||
|
const blob = await canvasToBlob(canvas);
|
||||||
await navigator.clipboard.write([
|
await navigator.clipboard.write([
|
||||||
new window.ClipboardItem({ "image/png": blob }),
|
new window.ClipboardItem({ "image/png": blob }),
|
||||||
]);
|
]);
|
||||||
|
@@ -3,14 +3,13 @@ import { ActionManager } from "../actions/manager";
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import useIsMobile from "../is-mobile";
|
||||||
import {
|
import {
|
||||||
canChangeSharpness,
|
canChangeSharpness,
|
||||||
canHaveArrowheads,
|
canHaveArrowheads,
|
||||||
getTargetElements,
|
getTargetElements,
|
||||||
hasBackground,
|
hasBackground,
|
||||||
hasStrokeStyle,
|
hasStroke,
|
||||||
hasStrokeWidth,
|
|
||||||
hasText,
|
hasText,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { SHAPES } from "../shapes";
|
import { SHAPES } from "../shapes";
|
||||||
@@ -54,17 +53,10 @@ export const SelectedShapeActions = ({
|
|||||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||||
{showFillIcons && renderAction("changeFillStyle")}
|
{showFillIcons && renderAction("changeFillStyle")}
|
||||||
|
|
||||||
{(hasStrokeWidth(elementType) ||
|
{(hasStroke(elementType) ||
|
||||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
targetElements.some((element) => hasStroke(element.type))) && (
|
||||||
renderAction("changeStrokeWidth")}
|
|
||||||
|
|
||||||
{(elementType === "freedraw" ||
|
|
||||||
targetElements.some((element) => element.type === "freedraw")) &&
|
|
||||||
renderAction("changeStrokeShape")}
|
|
||||||
|
|
||||||
{(hasStrokeStyle(elementType) ||
|
|
||||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
|
||||||
<>
|
<>
|
||||||
|
{renderAction("changeStrokeWidth")}
|
||||||
{renderAction("changeStrokeStyle")}
|
{renderAction("changeStrokeStyle")}
|
||||||
{renderAction("changeSloppiness")}
|
{renderAction("changeSloppiness")}
|
||||||
</>
|
</>
|
||||||
@@ -151,14 +143,23 @@ export const SelectedShapeActions = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LIBRARY_ICON = (
|
||||||
|
// 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 = ({
|
export const ShapesSwitcher = ({
|
||||||
canvas,
|
canvas,
|
||||||
elementType,
|
elementType,
|
||||||
setAppState,
|
setAppState,
|
||||||
|
isLibraryOpen,
|
||||||
}: {
|
}: {
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
elementType: ExcalidrawElement["type"];
|
elementType: ExcalidrawElement["type"];
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
isLibraryOpen: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<>
|
<>
|
||||||
{SHAPES.map(({ value, icon, key }, index) => {
|
{SHAPES.map(({ value, icon, key }, index) => {
|
||||||
@@ -192,6 +193,19 @@ export const ShapesSwitcher = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<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={() => {
|
||||||
|
setAppState({ isLibraryOpen: !isLibraryOpen });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -204,9 +218,12 @@ export const ZoomActions = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<Stack.Col gap={1}>
|
<Stack.Col gap={1}>
|
||||||
<Stack.Row gap={1} align="center">
|
<Stack.Row gap={1} align="center">
|
||||||
{renderAction("zoomOut")}
|
|
||||||
{renderAction("zoomIn")}
|
{renderAction("zoomIn")}
|
||||||
|
{renderAction("zoomOut")}
|
||||||
{renderAction("resetZoom")}
|
{renderAction("resetZoom")}
|
||||||
|
<div style={{ marginInlineStart: 4 }}>
|
||||||
|
{(zoom.value * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
);
|
);
|
||||||
|
@@ -1,21 +0,0 @@
|
|||||||
.excalidraw {
|
|
||||||
.ActiveFile {
|
|
||||||
.ActiveFile__fileName {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
span {
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
width: 9.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 1.15em;
|
|
||||||
margin-inline-end: 0.3em;
|
|
||||||
transform: scaleY(0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,28 +0,0 @@
|
|||||||
import Stack from "../components/Stack";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
|
||||||
import { save, file } from "../components/icons";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
import "./ActiveFile.scss";
|
|
||||||
|
|
||||||
type ActiveFileProps = {
|
|
||||||
fileName?: string;
|
|
||||||
onSave: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
|
|
||||||
<Stack.Row className="ActiveFile" gap={1} align="center">
|
|
||||||
<span className="ActiveFile__fileName">
|
|
||||||
{file}
|
|
||||||
<span>{fileName}</span>
|
|
||||||
</span>
|
|
||||||
<ToolButton
|
|
||||||
type="icon"
|
|
||||||
icon={save}
|
|
||||||
title={t("buttons.save")}
|
|
||||||
aria-label={t("buttons.save")}
|
|
||||||
onClick={onSave}
|
|
||||||
data-testid="save-button"
|
|
||||||
/>
|
|
||||||
</Stack.Row>
|
|
||||||
);
|
|
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import { DarkModeToggle } from "./DarkModeToggle";
|
||||||
|
|
||||||
export const BackgroundPickerAndDarkModeToggle = ({
|
export const BackgroundPickerAndDarkModeToggle = ({
|
||||||
appState,
|
appState,
|
||||||
@@ -15,6 +16,15 @@ export const BackgroundPickerAndDarkModeToggle = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<div style={{ display: "flex" }}>
|
<div style={{ display: "flex" }}>
|
||||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||||
{showThemeBtn && actionManager.renderAction("toggleTheme")}
|
{showThemeBtn && (
|
||||||
|
<div style={{ marginInlineStart: "0.25rem" }}>
|
||||||
|
<DarkModeToggle
|
||||||
|
value={appState.theme}
|
||||||
|
onChange={(theme) => {
|
||||||
|
setAppState({ theme });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const ButtonIconCycle = <T extends any>({
|
export const ButtonIconCycle = <T extends any>({
|
||||||
@@ -13,11 +14,11 @@ export const ButtonIconCycle = <T extends any>({
|
|||||||
}) => {
|
}) => {
|
||||||
const current = options.find((op) => op.value === value);
|
const current = options.find((op) => op.value === value);
|
||||||
|
|
||||||
const cycle = () => {
|
function cycle() {
|
||||||
const index = options.indexOf(current!);
|
const index = options.indexOf(current!);
|
||||||
const next = (index + 1) % options.length;
|
const next = (index + 1) % options.length;
|
||||||
onChange(options[next].value);
|
onChange(options[next].value);
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label key={group} className={clsx({ active: current!.value !== null })}>
|
<label key={group} className={clsx({ active: current!.value !== null })}>
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const ButtonSelect = <T extends Object>({
|
export const ButtonSelect = <T extends Object>({
|
||||||
|
@@ -1,53 +0,0 @@
|
|||||||
@import "../css/variables.module";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.Card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
max-width: 290px;
|
|
||||||
|
|
||||||
margin: 1em;
|
|
||||||
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.Card-icon {
|
|
||||||
font-size: 2.6em;
|
|
||||||
display: flex;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 1.4rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--card-color);
|
|
||||||
color: $oc-white;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 2.8rem;
|
|
||||||
height: 2.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Card-details {
|
|
||||||
font-size: 0.96em;
|
|
||||||
min-height: 90px;
|
|
||||||
padding: 0 1em;
|
|
||||||
margin-bottom: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .Card-button.ToolIcon_type_button {
|
|
||||||
height: 2.5rem;
|
|
||||||
margin-top: 1em;
|
|
||||||
margin-bottom: 0.3em;
|
|
||||||
background-color: var(--card-color);
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--card-color-darker);
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: var(--card-color-darkest);
|
|
||||||
}
|
|
||||||
.ToolIcon__label {
|
|
||||||
color: $oc-white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,20 +0,0 @@
|
|||||||
import OpenColor from "open-color";
|
|
||||||
|
|
||||||
import "./Card.scss";
|
|
||||||
|
|
||||||
export const Card: React.FC<{
|
|
||||||
color: keyof OpenColor;
|
|
||||||
}> = ({ children, color }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="Card"
|
|
||||||
style={{
|
|
||||||
["--card-color" as any]: OpenColor[color][7],
|
|
||||||
["--card-color-darker" as any]: OpenColor[color][8],
|
|
||||||
["--card-color-darkest" as any]: OpenColor[color][9],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -1,89 +0,0 @@
|
|||||||
@import "../css/variables.module";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.Checkbox {
|
|
||||||
margin: 4px 0.3em;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
|
|
||||||
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
|
|
||||||
box-shadow: 0 0 0 2px #{$oc-blue-4};
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
|
|
||||||
svg {
|
|
||||||
display: block;
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
.Checkbox-box {
|
|
||||||
box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.Checkbox-box {
|
|
||||||
background-color: fade-out($oc-blue-1, 0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-checked {
|
|
||||||
.Checkbox-box {
|
|
||||||
background-color: #{$oc-blue-1};
|
|
||||||
svg {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&:hover .Checkbox-box {
|
|
||||||
background-color: #{$oc-blue-2};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Checkbox-box {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
padding: 0;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
|
|
||||||
margin: 0 1em;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
box-shadow: 0 0 0 2px #{$oc-blue-7};
|
|
||||||
background-color: transparent;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
color: #{$oc-blue-7};
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
box-shadow: 0 0 0 3px #{$oc-blue-7};
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
display: none;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
stroke-width: 3px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Checkbox-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.excalidraw-tooltip-icon {
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,27 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { checkIcon } from "./icons";
|
|
||||||
|
|
||||||
import "./CheckboxItem.scss";
|
|
||||||
|
|
||||||
export const CheckboxItem: React.FC<{
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
}> = ({ children, checked, onChange }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx("Checkbox", { "is-checked": checked })}
|
|
||||||
onClick={(event) => {
|
|
||||||
onChange(!checked);
|
|
||||||
((event.currentTarget as HTMLDivElement).querySelector(
|
|
||||||
".Checkbox-box",
|
|
||||||
) as HTMLButtonElement).focus();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
|
||||||
{checkIcon}
|
|
||||||
</button>
|
|
||||||
<div className="Checkbox-label">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -1,7 +1,8 @@
|
|||||||
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import useIsMobile from "../is-mobile";
|
||||||
import { users } from "./icons";
|
import { users } from "./icons";
|
||||||
|
|
||||||
import "./CollabButton.scss";
|
import "./CollabButton.scss";
|
||||||
|
@@ -160,7 +160,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-input {
|
.color-picker-input {
|
||||||
width: 11ch; /* length of `transparent` */
|
width: 12ch; /* length of `transparent` + 1 */
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
background-color: var(--input-bg-color);
|
background-color: var(--input-bg-color);
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
left: 2px;
|
left: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include isMobile {
|
@media #{$is-mobile-query} {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
import { isTransparent } from "../utils";
|
|
||||||
|
|
||||||
import "./ColorPicker.scss";
|
import "./ColorPicker.scss";
|
||||||
import { isArrowKey, KEYS } from "../keys";
|
import { isArrowKey, KEYS } from "../keys";
|
||||||
@@ -15,7 +14,7 @@ const isValidColor = (color: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getColor = (color: string): string | null => {
|
const getColor = (color: string): string | null => {
|
||||||
if (isTransparent(color)) {
|
if (color === "transparent") {
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +115,6 @@ const Picker = ({
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
event.nativeEvent.stopImmediatePropagation();
|
event.nativeEvent.stopImmediatePropagation();
|
||||||
event.stopPropagation();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -138,41 +136,36 @@ const Picker = ({
|
|||||||
}}
|
}}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{colors.map((_color, i) => {
|
{colors.map((_color, i) => (
|
||||||
const _colorWithoutHash = _color.replace("#", "");
|
<button
|
||||||
return (
|
className="color-picker-swatch"
|
||||||
<button
|
onClick={(event) => {
|
||||||
className="color-picker-swatch"
|
(event.currentTarget as HTMLButtonElement).focus();
|
||||||
onClick={(event) => {
|
onChange(_color);
|
||||||
(event.currentTarget as HTMLButtonElement).focus();
|
}}
|
||||||
onChange(_color);
|
title={`${_color} — ${keyBindings[i].toUpperCase()}`}
|
||||||
}}
|
aria-label={_color}
|
||||||
title={`${t(`colors.${_colorWithoutHash}`)}${
|
aria-keyshortcuts={keyBindings[i]}
|
||||||
!isTransparent(_color) ? ` (${_color})` : ""
|
style={{ color: _color }}
|
||||||
} — ${keyBindings[i].toUpperCase()}`}
|
key={_color}
|
||||||
aria-label={t(`colors.${_colorWithoutHash}`)}
|
ref={(el) => {
|
||||||
aria-keyshortcuts={keyBindings[i]}
|
if (el && i === 0) {
|
||||||
style={{ color: _color }}
|
firstItem.current = el;
|
||||||
key={_color}
|
}
|
||||||
ref={(el) => {
|
if (el && _color === color) {
|
||||||
if (el && i === 0) {
|
activeItem.current = el;
|
||||||
firstItem.current = el;
|
}
|
||||||
}
|
}}
|
||||||
if (el && _color === color) {
|
onFocus={() => {
|
||||||
activeItem.current = el;
|
onChange(_color);
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
onFocus={() => {
|
{_color === "transparent" ? (
|
||||||
onChange(_color);
|
<div className="color-picker-transparent"></div>
|
||||||
}}
|
) : undefined}
|
||||||
>
|
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
||||||
{isTransparent(_color) ? (
|
</button>
|
||||||
<div className="color-picker-transparent"></div>
|
))}
|
||||||
) : undefined}
|
|
||||||
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{showInput && (
|
{showInput && (
|
||||||
<ColorInput
|
<ColorInput
|
||||||
color={color}
|
color={color}
|
||||||
@@ -244,16 +237,13 @@ export const ColorPicker = ({
|
|||||||
color,
|
color,
|
||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
isActive,
|
|
||||||
setActive,
|
|
||||||
}: {
|
}: {
|
||||||
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
||||||
color: string | null;
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
isActive: boolean;
|
|
||||||
setActive: (active: boolean) => void;
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isActive, setActive] = React.useState(false);
|
||||||
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -76,7 +76,7 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include isMobile {
|
@media #{$is-mobile-query} {
|
||||||
.context-menu-option {
|
.context-menu-option {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { render, unmountComponentAtNode } from "react-dom";
|
import { render, unmountComponentAtNode } from "react-dom";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
@@ -31,63 +32,67 @@ const ContextMenu = ({
|
|||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
}: ContextMenuProps) => {
|
}: ContextMenuProps) => {
|
||||||
|
const isDarkTheme = !!document
|
||||||
|
.querySelector(".excalidraw")
|
||||||
|
?.classList.contains("theme--dark");
|
||||||
return (
|
return (
|
||||||
<Popover
|
<div
|
||||||
onCloseRequest={onCloseRequest}
|
className={clsx("excalidraw", {
|
||||||
top={top}
|
"theme--dark theme--dark-background-none": isDarkTheme,
|
||||||
left={left}
|
})}
|
||||||
fitInViewport={true}
|
|
||||||
>
|
>
|
||||||
<ul
|
<Popover
|
||||||
className="context-menu"
|
onCloseRequest={onCloseRequest}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
top={top}
|
||||||
|
left={left}
|
||||||
|
fitInViewport={true}
|
||||||
>
|
>
|
||||||
{options.map((option, idx) => {
|
<ul
|
||||||
if (option === "separator") {
|
className="context-menu"
|
||||||
return <hr key={idx} className="context-menu-option-separator" />;
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
}
|
>
|
||||||
|
{options.map((option, idx) => {
|
||||||
|
if (option === "separator") {
|
||||||
|
return <hr key={idx} className="context-menu-option-separator" />;
|
||||||
|
}
|
||||||
|
|
||||||
const actionName = option.name;
|
const actionName = option.name;
|
||||||
const label = option.contextItemLabel
|
const label = option.contextItemLabel
|
||||||
? t(option.contextItemLabel)
|
? t(option.contextItemLabel)
|
||||||
: "";
|
: "";
|
||||||
return (
|
return (
|
||||||
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
|
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
|
||||||
<button
|
<button
|
||||||
className={clsx("context-menu-option", {
|
className={clsx("context-menu-option", {
|
||||||
dangerous: actionName === "deleteSelectedElements",
|
dangerous: actionName === "deleteSelectedElements",
|
||||||
checkmark: option.checked?.(appState),
|
checkmark: option.checked?.(appState),
|
||||||
})}
|
})}
|
||||||
onClick={() => actionManager.executeAction(option)}
|
onClick={() => actionManager.executeAction(option)}
|
||||||
>
|
>
|
||||||
<div className="context-menu-option__label">{label}</div>
|
<div className="context-menu-option__label">{label}</div>
|
||||||
<kbd className="context-menu-option__shortcut">
|
<kbd className="context-menu-option__shortcut">
|
||||||
{actionName
|
{actionName
|
||||||
? getShortcutFromShortcutName(actionName as ShortcutName)
|
? getShortcutFromShortcutName(actionName as ShortcutName)
|
||||||
: ""}
|
: ""}
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>();
|
let contextMenuNode: HTMLDivElement;
|
||||||
|
const getContextMenuNode = (): HTMLDivElement => {
|
||||||
const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
|
|
||||||
let contextMenuNode = contextMenuNodeByContainer.get(container);
|
|
||||||
if (contextMenuNode) {
|
if (contextMenuNode) {
|
||||||
return contextMenuNode;
|
return contextMenuNode;
|
||||||
}
|
}
|
||||||
contextMenuNode = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
container
|
document.body.appendChild(div);
|
||||||
.querySelector(".excalidraw-contextMenuContainer")!
|
return (contextMenuNode = div);
|
||||||
.appendChild(contextMenuNode);
|
|
||||||
contextMenuNodeByContainer.set(container, contextMenuNode);
|
|
||||||
return contextMenuNode;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ContextMenuParams = {
|
type ContextMenuParams = {
|
||||||
@@ -96,16 +101,10 @@ type ContextMenuParams = {
|
|||||||
left: ContextMenuProps["left"];
|
left: ContextMenuProps["left"];
|
||||||
actionManager: ContextMenuProps["actionManager"];
|
actionManager: ContextMenuProps["actionManager"];
|
||||||
appState: Readonly<AppState>;
|
appState: Readonly<AppState>;
|
||||||
container: HTMLElement;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = (container: HTMLElement) => {
|
const handleClose = () => {
|
||||||
const contextMenuNode = contextMenuNodeByContainer.get(container);
|
unmountComponentAtNode(getContextMenuNode());
|
||||||
if (contextMenuNode) {
|
|
||||||
unmountComponentAtNode(contextMenuNode);
|
|
||||||
contextMenuNode.remove();
|
|
||||||
contextMenuNodeByContainer.delete(container);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -122,11 +121,11 @@ export default {
|
|||||||
top={params.top}
|
top={params.top}
|
||||||
left={params.left}
|
left={params.left}
|
||||||
options={options}
|
options={options}
|
||||||
onCloseRequest={() => handleClose(params.container)}
|
onCloseRequest={handleClose}
|
||||||
actionManager={params.actionManager}
|
actionManager={params.actionManager}
|
||||||
appState={params.appState}
|
appState={params.appState}
|
||||||
/>,
|
/>,
|
||||||
getContextMenuNode(params.container),
|
getContextMenuNode(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@@ -1,32 +1,42 @@
|
|||||||
import "./ToolIcon.scss";
|
import "./ToolIcon.scss";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
import { THEME } from "../constants";
|
export type Appearence = "light" | "dark";
|
||||||
import { Theme } from "../element/types";
|
|
||||||
|
|
||||||
// We chose to use only explicit toggle and not a third option for system value,
|
// We chose to use only explicit toggle and not a third option for system value,
|
||||||
// but this could be added in the future.
|
// but this could be added in the future.
|
||||||
export const DarkModeToggle = (props: {
|
export const DarkModeToggle = (props: {
|
||||||
value: Theme;
|
value: Appearence;
|
||||||
onChange: (value: Theme) => void;
|
onChange: (value: Appearence) => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const title =
|
const title = props.title
|
||||||
props.title ||
|
? props.title
|
||||||
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
|
: props.value === "dark"
|
||||||
|
? t("buttons.lightMode")
|
||||||
|
: t("buttons.darkMode");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<label
|
||||||
type="icon"
|
className="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
|
||||||
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
|
|
||||||
title={title}
|
|
||||||
aria-label={title}
|
|
||||||
onClick={() =>
|
|
||||||
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
|
|
||||||
}
|
|
||||||
data-testid="toggle-dark-mode"
|
data-testid="toggle-dark-mode"
|
||||||
/>
|
title={title}
|
||||||
|
>
|
||||||
|
<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={title}
|
||||||
|
/>
|
||||||
|
<div className="ToolIcon__icon">
|
||||||
|
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -31,7 +31,7 @@
|
|||||||
padding: 0 16px 16px;
|
padding: 0 16px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include isMobile {
|
@media #{$is-mobile-query} {
|
||||||
.Dialog {
|
.Dialog {
|
||||||
--metric: calc(var(--space-factor) * 4);
|
--metric: calc(var(--space-factor) * 4);
|
||||||
--inset-left: #{"max(var(--metric), var(--sal))"};
|
--inset-left: #{"max(var(--metric), var(--sal))"};
|
||||||
|
@@ -1,14 +1,13 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useExcalidrawContainer, useIsMobile } from "../components/App";
|
import useIsMobile from "../is-mobile";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import "./Dialog.scss";
|
import "./Dialog.scss";
|
||||||
import { back, close } from "./icons";
|
import { back, close } from "./icons";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import { AppState } from "../types";
|
|
||||||
|
|
||||||
export const Dialog = (props: {
|
export const Dialog = (props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -17,11 +16,8 @@ export const Dialog = (props: {
|
|||||||
onCloseRequest(): void;
|
onCloseRequest(): void;
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
theme?: AppState["theme"];
|
|
||||||
}) => {
|
}) => {
|
||||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||||
const [lastActiveElement] = useState(document.activeElement);
|
|
||||||
const { id } = useExcalidrawContainer();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!islandNode) {
|
if (!islandNode) {
|
||||||
@@ -69,25 +65,19 @@ export const Dialog = (props: {
|
|||||||
return focusableElements ? Array.from(focusableElements) : [];
|
return focusableElements ? Array.from(focusableElements) : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
(lastActiveElement as HTMLElement).focus();
|
|
||||||
props.onCloseRequest();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
className={clsx("Dialog", props.className)}
|
className={clsx("Dialog", props.className)}
|
||||||
labelledBy="dialog-title"
|
labelledBy="dialog-title"
|
||||||
maxWidth={props.small ? 550 : 800}
|
maxWidth={props.small ? 550 : 800}
|
||||||
onCloseRequest={onClose}
|
onCloseRequest={props.onCloseRequest}
|
||||||
theme={props.theme}
|
|
||||||
>
|
>
|
||||||
<Island ref={setIslandNode}>
|
<Island ref={setIslandNode}>
|
||||||
<h2 id={`${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"
|
||||||
onClick={onClose}
|
onClick={props.onCloseRequest}
|
||||||
aria-label={t("buttons.close")}
|
aria-label={t("buttons.close")}
|
||||||
>
|
>
|
||||||
{useIsMobile() ? back : close}
|
{useIsMobile() ? back : close}
|
||||||
|
@@ -2,7 +2,6 @@ import React, { useState } from "react";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { useExcalidrawContainer } from "./App";
|
|
||||||
|
|
||||||
export const ErrorDialog = ({
|
export const ErrorDialog = ({
|
||||||
message,
|
message,
|
||||||
@@ -12,7 +11,6 @@ export const ErrorDialog = ({
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [modalIsShown, setModalIsShown] = useState(!!message);
|
const [modalIsShown, setModalIsShown] = useState(!!message);
|
||||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
|
||||||
|
|
||||||
const handleClose = React.useCallback(() => {
|
const handleClose = React.useCallback(() => {
|
||||||
setModalIsShown(false);
|
setModalIsShown(false);
|
||||||
@@ -20,9 +18,7 @@ export const ErrorDialog = ({
|
|||||||
if (onClose) {
|
if (onClose) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
// TODO: Fix the A11y issues so this is never needed since we should always focus on last active element
|
}, [onClose]);
|
||||||
excalidrawContainer?.focus();
|
|
||||||
}, [onClose, excalidrawContainer]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -32,7 +28,14 @@ export const ErrorDialog = ({
|
|||||||
onCloseRequest={handleClose}
|
onCloseRequest={handleClose}
|
||||||
title={t("errorDialog.title")}
|
title={t("errorDialog.title")}
|
||||||
>
|
>
|
||||||
<div style={{ whiteSpace: "pre-wrap" }}>{message}</div>
|
<div>
|
||||||
|
{message.split("\n").map((line) => (
|
||||||
|
<>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@@ -28,7 +28,24 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include isMobile {
|
.ExportDialog__name {
|
||||||
|
grid-column: project-name;
|
||||||
|
margin: auto;
|
||||||
|
|
||||||
|
.TextInput {
|
||||||
|
height: calc(1rem - 3px);
|
||||||
|
|
||||||
|
&--readonly {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
&:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
.ExportDialog {
|
.ExportDialog {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -57,63 +74,4 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ExportDialog--json {
|
|
||||||
.ExportDialog-cards {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
justify-items: center;
|
|
||||||
row-gap: 2em;
|
|
||||||
|
|
||||||
@media (max-width: 460px) {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
||||||
.Card-details {
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProjectName {
|
|
||||||
width: fit-content;
|
|
||||||
margin: 1em auto;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.TextInput {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProjectName-label {
|
|
||||||
margin: 0.625em 0;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button.ExportDialog-imageExportButton {
|
|
||||||
width: 5rem;
|
|
||||||
height: 5rem;
|
|
||||||
margin: 0 0.2em;
|
|
||||||
|
|
||||||
border-radius: 1rem;
|
|
||||||
background-color: var(--button-color);
|
|
||||||
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.28),
|
|
||||||
0 6px 10px 0 rgba(0, 0, 0, 0.14);
|
|
||||||
|
|
||||||
font-family: Cascadia;
|
|
||||||
font-size: 1.8em;
|
|
||||||
color: $oc-white;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--button-color-darker);
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: var(--button-color-darkest);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 0.9em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -6,19 +6,18 @@ import { canvasToBlob } from "../data/blob";
|
|||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { CanvasError } from "../errors";
|
import { CanvasError } from "../errors";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "./App";
|
import useIsMobile from "../is-mobile";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { exportToCanvas } from "../scene/export";
|
import { exportToCanvas, getExportSize } from "../scene/export";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { clipboard, exportImage } from "./icons";
|
import "./ExportDialog.scss";
|
||||||
|
import { clipboard, exportFile, link } from "./icons";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import "./ExportDialog.scss";
|
|
||||||
import OpenColor from "open-color";
|
const scales = [1, 2, 3];
|
||||||
import { CheckboxItem } from "./CheckboxItem";
|
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
|
||||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
|
||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
|
||||||
|
|
||||||
const supportsContextFilters =
|
const supportsContextFilters =
|
||||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||||
@@ -53,37 +52,15 @@ export type ExportCB = (
|
|||||||
scale?: number,
|
scale?: number,
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
const ExportButton: React.FC<{
|
const ExportModal = ({
|
||||||
color: keyof OpenColor;
|
|
||||||
onClick: () => void;
|
|
||||||
title: string;
|
|
||||||
shade?: number;
|
|
||||||
}> = ({ children, title, onClick, color, shade = 6 }) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="ExportDialog-imageExportButton"
|
|
||||||
style={{
|
|
||||||
["--button-color" as any]: OpenColor[color][shade],
|
|
||||||
["--button-color-darker" as any]: OpenColor[color][shade + 1],
|
|
||||||
["--button-color-darkest" as any]: OpenColor[color][shade + 2],
|
|
||||||
}}
|
|
||||||
title={title}
|
|
||||||
aria-label={title}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ImageExportModal = ({
|
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = 10,
|
||||||
actionManager,
|
actionManager,
|
||||||
onExportToPng,
|
onExportToPng,
|
||||||
onExportToSvg,
|
onExportToSvg,
|
||||||
onExportToClipboard,
|
onExportToClipboard,
|
||||||
|
onExportToBackend,
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
@@ -92,12 +69,18 @@ const ImageExportModal = ({
|
|||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
onExportToSvg: ExportCB;
|
onExportToSvg: ExportCB;
|
||||||
onExportToClipboard: ExportCB;
|
onExportToClipboard: ExportCB;
|
||||||
|
onExportToBackend?: ExportCB;
|
||||||
onCloseRequest: () => void;
|
onCloseRequest: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||||
|
const [scale, setScale] = useState(defaultScale);
|
||||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
const { exportBackground, viewBackgroundColor } = appState;
|
const {
|
||||||
|
exportBackground,
|
||||||
|
viewBackgroundColor,
|
||||||
|
shouldAddWatermark,
|
||||||
|
} = appState;
|
||||||
|
|
||||||
const exportedElements = exportSelected
|
const exportedElements = exportSelected
|
||||||
? getSelectedElements(elements, appState)
|
? getSelectedElements(elements, appState)
|
||||||
@@ -117,6 +100,8 @@ const ImageExportModal = ({
|
|||||||
exportBackground,
|
exportBackground,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
exportPadding,
|
exportPadding,
|
||||||
|
scale,
|
||||||
|
shouldAddWatermark,
|
||||||
});
|
});
|
||||||
|
|
||||||
// if converting to blob fails, there's some problem that will
|
// if converting to blob fails, there's some problem that will
|
||||||
@@ -139,6 +124,8 @@ const ImageExportModal = ({
|
|||||||
exportBackground,
|
exportBackground,
|
||||||
exportPadding,
|
exportPadding,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
|
scale,
|
||||||
|
shouldAddWatermark,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -146,85 +133,106 @@ const ImageExportModal = ({
|
|||||||
<div className="ExportDialog__preview" ref={previewRef} />
|
<div className="ExportDialog__preview" ref={previewRef} />
|
||||||
{supportsContextFilters &&
|
{supportsContextFilters &&
|
||||||
actionManager.renderAction("exportWithDarkMode")}
|
actionManager.renderAction("exportWithDarkMode")}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
|
<Stack.Col gap={2} align="center">
|
||||||
<div
|
<div className="ExportDialog__actions">
|
||||||
style={{
|
<Stack.Row gap={2}>
|
||||||
display: "grid",
|
<ToolButton
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))",
|
type="button"
|
||||||
// dunno why this is needed, but when the items wrap it creates
|
label="PNG"
|
||||||
// an overflow
|
title={t("buttons.exportToPng")}
|
||||||
overflow: "hidden",
|
aria-label={t("buttons.exportToPng")}
|
||||||
}}
|
onClick={() => onExportToPng(exportedElements, scale)}
|
||||||
>
|
/>
|
||||||
{actionManager.renderAction("changeExportBackground")}
|
<ToolButton
|
||||||
{someElementIsSelected && (
|
type="button"
|
||||||
<CheckboxItem
|
label="SVG"
|
||||||
checked={exportSelected}
|
title={t("buttons.exportToSvg")}
|
||||||
onChange={(checked) => setExportSelected(checked)}
|
aria-label={t("buttons.exportToSvg")}
|
||||||
>
|
onClick={() => onExportToSvg(exportedElements, scale)}
|
||||||
{t("labels.onlySelected")}
|
/>
|
||||||
</CheckboxItem>
|
{probablySupportsClipboardBlob && (
|
||||||
)}
|
<ToolButton
|
||||||
{actionManager.renderAction("changeExportEmbedScene")}
|
type="button"
|
||||||
|
icon={clipboard}
|
||||||
|
title={t("buttons.copyPngToClipboard")}
|
||||||
|
aria-label={t("buttons.copyPngToClipboard")}
|
||||||
|
onClick={() => onExportToClipboard(exportedElements, scale)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onExportToBackend && (
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={link}
|
||||||
|
title={t("buttons.getShareableLink")}
|
||||||
|
aria-label={t("buttons.getShareableLink")}
|
||||||
|
onClick={() => onExportToBackend(exportedElements)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack.Row>
|
||||||
|
<div className="ExportDialog__name">
|
||||||
|
{actionManager.renderAction("changeProjectName")}
|
||||||
|
</div>
|
||||||
|
<Stack.Row gap={2}>
|
||||||
|
{scales.map((s) => {
|
||||||
|
const [width, height] = getExportSize(
|
||||||
|
exportedElements,
|
||||||
|
exportPadding,
|
||||||
|
shouldAddWatermark,
|
||||||
|
s,
|
||||||
|
);
|
||||||
|
|
||||||
|
const scaleButtonTitle = `${t(
|
||||||
|
"buttons.scale",
|
||||||
|
)} ${s}x (${width}x${height})`;
|
||||||
|
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{actionManager.renderAction("changeExportBackground")}
|
||||||
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
|
{someElementIsSelected && (
|
||||||
<Stack.Row gap={2}>
|
<div>
|
||||||
{actionManager.renderAction("changeExportScale")}
|
<label>
|
||||||
</Stack.Row>
|
<input
|
||||||
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
|
type="checkbox"
|
||||||
</div>
|
checked={exportSelected}
|
||||||
<div
|
onChange={(event) =>
|
||||||
style={{
|
setExportSelected(event.currentTarget.checked)
|
||||||
display: "flex",
|
}
|
||||||
alignItems: "center",
|
/>{" "}
|
||||||
justifyContent: "center",
|
{t("labels.onlySelected")}
|
||||||
margin: ".6em 0",
|
</label>
|
||||||
}}
|
</div>
|
||||||
>
|
|
||||||
{!nativeFileSystemSupported &&
|
|
||||||
actionManager.renderAction("changeProjectName")}
|
|
||||||
</div>
|
|
||||||
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
|
|
||||||
<ExportButton
|
|
||||||
color="indigo"
|
|
||||||
title={t("buttons.exportToPng")}
|
|
||||||
aria-label={t("buttons.exportToPng")}
|
|
||||||
onClick={() => onExportToPng(exportedElements)}
|
|
||||||
>
|
|
||||||
PNG
|
|
||||||
</ExportButton>
|
|
||||||
<ExportButton
|
|
||||||
color="red"
|
|
||||||
title={t("buttons.exportToSvg")}
|
|
||||||
aria-label={t("buttons.exportToSvg")}
|
|
||||||
onClick={() => onExportToSvg(exportedElements)}
|
|
||||||
>
|
|
||||||
SVG
|
|
||||||
</ExportButton>
|
|
||||||
{probablySupportsClipboardBlob && (
|
|
||||||
<ExportButton
|
|
||||||
title={t("buttons.copyPngToClipboard")}
|
|
||||||
onClick={() => onExportToClipboard(exportedElements)}
|
|
||||||
color="gray"
|
|
||||||
shade={7}
|
|
||||||
>
|
|
||||||
{clipboard}
|
|
||||||
</ExportButton>
|
|
||||||
)}
|
)}
|
||||||
</Stack.Row>
|
{actionManager.renderAction("changeExportEmbedScene")}
|
||||||
|
{actionManager.renderAction("changeShouldAddWatermark")}
|
||||||
|
</Stack.Col>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ImageExportDialog = ({
|
export const ExportDialog = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = 10,
|
||||||
actionManager,
|
actionManager,
|
||||||
onExportToPng,
|
onExportToPng,
|
||||||
onExportToSvg,
|
onExportToSvg,
|
||||||
onExportToClipboard,
|
onExportToClipboard,
|
||||||
|
onExportToBackend,
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
@@ -233,11 +241,14 @@ export const ImageExportDialog = ({
|
|||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
onExportToSvg: ExportCB;
|
onExportToSvg: ExportCB;
|
||||||
onExportToClipboard: ExportCB;
|
onExportToClipboard: ExportCB;
|
||||||
|
onExportToBackend?: ExportCB;
|
||||||
}) => {
|
}) => {
|
||||||
const [modalIsShown, setModalIsShown] = useState(false);
|
const [modalIsShown, setModalIsShown] = useState(false);
|
||||||
|
const triggerButton = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const handleClose = React.useCallback(() => {
|
const handleClose = React.useCallback(() => {
|
||||||
setModalIsShown(false);
|
setModalIsShown(false);
|
||||||
|
triggerButton.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -246,16 +257,17 @@ export const ImageExportDialog = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setModalIsShown(true);
|
setModalIsShown(true);
|
||||||
}}
|
}}
|
||||||
data-testid="image-export-button"
|
data-testid="export-button"
|
||||||
icon={exportImage}
|
icon={exportFile}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("buttons.exportImage")}
|
aria-label={t("buttons.export")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
title={t("buttons.exportImage")}
|
title={t("buttons.export")}
|
||||||
|
ref={triggerButton}
|
||||||
/>
|
/>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
<Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
|
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||||
<ImageExportModal
|
<ExportModal
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
exportPadding={exportPadding}
|
exportPadding={exportPadding}
|
||||||
@@ -263,6 +275,7 @@ export const ImageExportDialog = ({
|
|||||||
onExportToPng={onExportToPng}
|
onExportToPng={onExportToPng}
|
||||||
onExportToSvg={onExportToSvg}
|
onExportToSvg={onExportToSvg}
|
||||||
onExportToClipboard={onExportToClipboard}
|
onExportToClipboard={onExportToClipboard}
|
||||||
|
onExportToBackend={onExportToBackend}
|
||||||
onCloseRequest={handleClose}
|
onCloseRequest={handleClose}
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
@@ -1,5 +1,6 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.FixedSideContainer {
|
.FixedSideContainer {
|
||||||
|
--margin: 0.25rem;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -9,9 +10,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.FixedSideContainer_side_top {
|
.FixedSideContainer_side_top {
|
||||||
left: var(--space-factor);
|
left: var(--margin);
|
||||||
top: var(--space-factor);
|
top: var(--margin);
|
||||||
right: var(--space-factor);
|
right: var(--margin);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,16 +23,16 @@
|
|||||||
|
|
||||||
/* TODO: if these are used, make sure to implement RTL support
|
/* TODO: if these are used, make sure to implement RTL support
|
||||||
.FixedSideContainer_side_left {
|
.FixedSideContainer_side_left {
|
||||||
left: var(--space-factor);
|
left: var(--margin);
|
||||||
top: var(--space-factor);
|
top: var(--margin);
|
||||||
bottom: var(--space-factor);
|
bottom: var(--margin);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.FixedSideContainer_side_right {
|
.FixedSideContainer_side_right {
|
||||||
right: var(--space-factor);
|
right: var(--margin);
|
||||||
top: var(--space-factor);
|
top: var(--margin);
|
||||||
bottom: var(--space-factor);
|
bottom: var(--margin);
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
@@ -1,23 +1,15 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { THEME } from "../../constants";
|
|
||||||
import { Theme } from "../../element/types";
|
|
||||||
|
|
||||||
// https://github.com/tholman/github-corners
|
// https://github.com/tholman/github-corners
|
||||||
export const GitHubCorner = React.memo(
|
export const GitHubCorner = React.memo(
|
||||||
({ theme, dir }: { theme: Theme; dir: string }) => (
|
({ theme }: { theme: "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="rtl-mirror"
|
className="github-corner rtl-mirror"
|
||||||
style={{
|
|
||||||
marginTop: "calc(var(--space-factor) * -1)",
|
|
||||||
[dir === "rtl"
|
|
||||||
? "marginLeft"
|
|
||||||
: "marginRight"]: "calc(var(--space-factor) * -1)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/excalidraw/excalidraw"
|
href="https://github.com/excalidraw/excalidraw"
|
||||||
@@ -27,18 +19,18 @@ export const GitHubCorner = React.memo(
|
|||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||||
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
|
fill={theme === "light" ? oc.gray[6] : oc.gray[8]}
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
className="octo-arm"
|
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"
|
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" }}
|
style={{ transformOrigin: "130px 106px" }}
|
||||||
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
fill={theme === "light" ? oc.white : oc.black}
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
className="octo-body"
|
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"
|
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={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
fill={theme === "light" ? oc.white : oc.black}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</svg>
|
</svg>
|
@@ -153,17 +153,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
||||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("toolBar.freedraw")}
|
label={t("toolBar.draw")}
|
||||||
shortcuts={["Shift+P", "7"]}
|
shortcuts={["Shift+P", "7"]}
|
||||||
/>
|
/>
|
||||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||||
<Shortcut
|
|
||||||
label={t("helpDialog.editSelectedShape")}
|
|
||||||
shortcuts={[
|
|
||||||
getShortcutKey("Enter"),
|
|
||||||
t("helpDialog.doubleClick"),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("helpDialog.textNewLine")}
|
label={t("helpDialog.textNewLine")}
|
||||||
shortcuts={[
|
shortcuts={[
|
||||||
@@ -238,14 +231,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("labels.viewMode")}
|
label={t("labels.viewMode")}
|
||||||
shortcuts={[getShortcutKey("Alt+R")]}
|
shortcuts={[getShortcutKey("Alt+R")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
|
||||||
label={t("labels.toggleTheme")}
|
|
||||||
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("stats.title")}
|
|
||||||
shortcuts={[getShortcutKey("Alt+/")]}
|
|
||||||
/>
|
|
||||||
</ShortcutIsland>
|
</ShortcutIsland>
|
||||||
</Column>
|
</Column>
|
||||||
<Column>
|
<Column>
|
||||||
@@ -364,22 +349,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("labels.ungroup")}
|
label={t("labels.ungroup")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
|
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
|
||||||
label={t("labels.flipHorizontal")}
|
|
||||||
shortcuts={[getShortcutKey("Shift+H")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.flipVertical")}
|
|
||||||
shortcuts={[getShortcutKey("Shift+V")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.showStroke")}
|
|
||||||
shortcuts={[getShortcutKey("S")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.showBackground")}
|
|
||||||
shortcuts={[getShortcutKey("G")]}
|
|
||||||
/>
|
|
||||||
</ShortcutIsland>
|
</ShortcutIsland>
|
||||||
</Column>
|
</Column>
|
||||||
</Columns>
|
</Columns>
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { questionCircle } from "../components/icons";
|
import { questionCircle } from "../components/icons";
|
||||||
|
|
||||||
type HelpIconProps = {
|
type HelpIconProps = {
|
||||||
@@ -8,13 +9,7 @@ type HelpIconProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const HelpIcon = (props: HelpIconProps) => (
|
export const HelpIcon = (props: HelpIconProps) => (
|
||||||
<button
|
<label title={`${props.title} — ?`} className="help-icon">
|
||||||
className="help-icon"
|
<div onClick={props.onClick}>{questionCircle}</div>
|
||||||
onClick={props.onClick}
|
</label>
|
||||||
type="button"
|
|
||||||
title={`${props.title} — ?`}
|
|
||||||
aria-label={props.title}
|
|
||||||
>
|
|
||||||
{questionCircle}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
|
@@ -19,7 +19,7 @@ $wide-viewport-width: 1000px;
|
|||||||
color: $oc-gray-6;
|
color: $oc-gray-6;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|
||||||
@include isMobile {
|
@media #{$is-mobile-query} {
|
||||||
position: static;
|
position: static;
|
||||||
padding-right: 2em;
|
padding-right: 2em;
|
||||||
}
|
}
|
||||||
|
@@ -1,10 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
|
||||||
import "./HintViewer.scss";
|
import "./HintViewer.scss";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isLinearElement, isTextElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
|
|
||||||
interface Hint {
|
interface Hint {
|
||||||
@@ -22,7 +23,7 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
return t("hints.linearElementMulti");
|
return t("hints.linearElementMulti");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elementType === "freedraw") {
|
if (elementType === "draw") {
|
||||||
return t("hints.freeDraw");
|
return t("hints.freeDraw");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,14 +57,6 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
return t("hints.lineEditor_info");
|
return t("hints.lineEditor_info");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
|
||||||
return t("hints.text_selected");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appState.editingElement && isTextElement(appState.editingElement)) {
|
|
||||||
return t("hints.text_editing");
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -111,7 +111,7 @@
|
|||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
left: 2px;
|
left: 2px;
|
||||||
}
|
}
|
||||||
@include isMobile {
|
@media #{$is-mobile-query} {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -88,7 +88,6 @@ function Picker<T>({
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
event.nativeEvent.stopImmediatePropagation();
|
event.nativeEvent.stopImmediatePropagation();
|
||||||
event.stopPropagation();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -1,25 +1,30 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
children: React.ReactElement;
|
|
||||||
}
|
}
|
||||||
|
interface State {
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
export class InitializeApp extends React.Component<Props, State> {
|
||||||
|
public state: { isLoading: boolean } = {
|
||||||
|
isLoading: true,
|
||||||
|
};
|
||||||
|
|
||||||
export const InitializeApp = (props: Props) => {
|
async componentDidMount() {
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateLang = async () => {
|
|
||||||
await setLanguage(currentLang);
|
|
||||||
};
|
|
||||||
const currentLang =
|
const currentLang =
|
||||||
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
languages.find((lang) => lang.code === this.props.langCode) ||
|
||||||
updateLang();
|
defaultLang;
|
||||||
setLoading(false);
|
await setLanguage(currentLang);
|
||||||
}, [props.langCode]);
|
this.setState({
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return loading ? <LoadingMessage /> : props.children;
|
public render() {
|
||||||
};
|
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
.Island {
|
.Island {
|
||||||
--padding: 0;
|
--padding: 0;
|
||||||
background-color: var(--island-bg-color);
|
background-color: var(--island-bg-color);
|
||||||
|
backdrop-filter: saturate(100%) blur(10px);
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: calc(var(--padding) * var(--space-factor));
|
padding: calc(var(--padding) * var(--space-factor));
|
||||||
|
@@ -1,128 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { ActionsManagerInterface } from "../actions/types";
|
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { useIsMobile } from "./App";
|
|
||||||
import { AppState, ExportOpts } from "../types";
|
|
||||||
import { Dialog } from "./Dialog";
|
|
||||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
import { actionSaveFileToDisk } from "../actions/actionExport";
|
|
||||||
import { Card } from "./Card";
|
|
||||||
|
|
||||||
import "./ExportDialog.scss";
|
|
||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
|
||||||
|
|
||||||
export type ExportCB = (
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
|
||||||
scale?: number,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
const JSONExportModal = ({
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
actionManager,
|
|
||||||
exportOpts,
|
|
||||||
canvas,
|
|
||||||
}: {
|
|
||||||
appState: AppState;
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
|
||||||
actionManager: ActionsManagerInterface;
|
|
||||||
onCloseRequest: () => void;
|
|
||||||
exportOpts: ExportOpts;
|
|
||||||
canvas: HTMLCanvasElement | null;
|
|
||||||
}) => {
|
|
||||||
const { onExportToBackend } = exportOpts;
|
|
||||||
return (
|
|
||||||
<div className="ExportDialog ExportDialog--json">
|
|
||||||
<div className="ExportDialog-cards">
|
|
||||||
{exportOpts.saveFileToDisk && (
|
|
||||||
<Card color="lime">
|
|
||||||
<div className="Card-icon">{exportToFileIcon}</div>
|
|
||||||
<h2>{t("exportDialog.disk_title")}</h2>
|
|
||||||
<div className="Card-details">
|
|
||||||
{t("exportDialog.disk_details")}
|
|
||||||
{!nativeFileSystemSupported &&
|
|
||||||
actionManager.renderAction("changeProjectName")}
|
|
||||||
</div>
|
|
||||||
<ToolButton
|
|
||||||
className="Card-button"
|
|
||||||
type="button"
|
|
||||||
title={t("exportDialog.disk_button")}
|
|
||||||
aria-label={t("exportDialog.disk_button")}
|
|
||||||
showAriaLabel={true}
|
|
||||||
onClick={() => {
|
|
||||||
actionManager.executeAction(actionSaveFileToDisk);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
{onExportToBackend && (
|
|
||||||
<Card color="pink">
|
|
||||||
<div className="Card-icon">{link}</div>
|
|
||||||
<h2>{t("exportDialog.link_title")}</h2>
|
|
||||||
<div className="Card-details">{t("exportDialog.link_details")}</div>
|
|
||||||
<ToolButton
|
|
||||||
className="Card-button"
|
|
||||||
type="button"
|
|
||||||
title={t("exportDialog.link_button")}
|
|
||||||
aria-label={t("exportDialog.link_button")}
|
|
||||||
showAriaLabel={true}
|
|
||||||
onClick={() => onExportToBackend(elements, appState, canvas)}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
{exportOpts.renderCustomUI &&
|
|
||||||
exportOpts.renderCustomUI(elements, appState, canvas)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const JSONExportDialog = ({
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
actionManager,
|
|
||||||
exportOpts,
|
|
||||||
canvas,
|
|
||||||
}: {
|
|
||||||
appState: AppState;
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
|
||||||
actionManager: ActionsManagerInterface;
|
|
||||||
exportOpts: ExportOpts;
|
|
||||||
canvas: HTMLCanvasElement | null;
|
|
||||||
}) => {
|
|
||||||
const [modalIsShown, setModalIsShown] = useState(false);
|
|
||||||
|
|
||||||
const handleClose = React.useCallback(() => {
|
|
||||||
setModalIsShown(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ToolButton
|
|
||||||
onClick={() => {
|
|
||||||
setModalIsShown(true);
|
|
||||||
}}
|
|
||||||
data-testid="json-export-button"
|
|
||||||
icon={exportFile}
|
|
||||||
type="button"
|
|
||||||
aria-label={t("buttons.export")}
|
|
||||||
showAriaLabel={useIsMobile()}
|
|
||||||
title={t("buttons.export")}
|
|
||||||
/>
|
|
||||||
{modalIsShown && (
|
|
||||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
|
||||||
<JSONExportModal
|
|
||||||
elements={elements}
|
|
||||||
appState={appState}
|
|
||||||
actionManager={actionManager}
|
|
||||||
onCloseRequest={handleClose}
|
|
||||||
exportOpts={exportOpts}
|
|
||||||
canvas={canvas}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -40,17 +40,50 @@
|
|||||||
.layer-ui__wrapper {
|
.layer-ui__wrapper {
|
||||||
z-index: var(--zIndex-layerUI);
|
z-index: var(--zIndex-layerUI);
|
||||||
|
|
||||||
&__top-right {
|
.encrypted-icon {
|
||||||
|
position: relative;
|
||||||
|
margin-inline-start: 15px;
|
||||||
display: flex;
|
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 {
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
width: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__footer {
|
&__footer {
|
||||||
width: 100%;
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
&-right {
|
:root[dir="ltr"] & {
|
||||||
z-index: 100;
|
right: 0;
|
||||||
display: flex;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
width: 190px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zen-mode-transition {
|
.zen-mode-transition {
|
||||||
@@ -72,15 +105,11 @@
|
|||||||
transform: translate(-999px, 0);
|
transform: translate(-999px, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
|
:root[dir="ltr"] &.App-menu_bottom--transition-left {
|
||||||
transform: translate(-76px, 0);
|
transform: translate(-92px, 0);
|
||||||
}
|
}
|
||||||
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
|
:root[dir="rtl"] &.App-menu_bottom--transition-left {
|
||||||
transform: translate(76px, 0);
|
transform: translate(92px, 0);
|
||||||
}
|
|
||||||
|
|
||||||
&.layer-ui__wrapper__footer-left--transition-bottom {
|
|
||||||
transform: translate(0, 92px);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,27 +137,5 @@
|
|||||||
transition-delay: 0.8s;
|
transition-delay: 0.8s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.layer-ui__wrapper__footer-center {
|
|
||||||
pointer-events: none;
|
|
||||||
& > * {
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.layer-ui__wrapper__footer-left,
|
|
||||||
.layer-ui__wrapper__footer-right,
|
|
||||||
.disable-zen-mode--visible {
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__wrapper__footer-left {
|
|
||||||
margin-bottom: 0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__wrapper__footer-right {
|
|
||||||
margin-top: auto;
|
|
||||||
margin-bottom: auto;
|
|
||||||
margin-inline-end: 1em;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -10,33 +10,29 @@ import { ActionManager } from "../actions/manager";
|
|||||||
import { CLASSES } from "../constants";
|
import { CLASSES } from "../constants";
|
||||||
import { exportCanvas } from "../data";
|
import { exportCanvas } from "../data";
|
||||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||||
|
import { Library } from "../data/library";
|
||||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { Language, t } from "../i18n";
|
import { Language, t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import useIsMobile from "../is-mobile";
|
||||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import {
|
import { AppState, ExcalidrawProps, LibraryItem, LibraryItems } from "../types";
|
||||||
AppProps,
|
|
||||||
AppState,
|
|
||||||
ExcalidrawProps,
|
|
||||||
LibraryItem,
|
|
||||||
LibraryItems,
|
|
||||||
} from "../types";
|
|
||||||
import { muteFSAbortError } from "../utils";
|
import { muteFSAbortError } from "../utils";
|
||||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
import CollabButton from "./CollabButton";
|
import CollabButton from "./CollabButton";
|
||||||
import { ErrorDialog } from "./ErrorDialog";
|
import { ErrorDialog } from "./ErrorDialog";
|
||||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
import { ExportCB, ExportDialog } from "./ExportDialog";
|
||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
|
import { GitHubCorner } from "./GitHubCorner";
|
||||||
import { HintViewer } from "./HintViewer";
|
import { HintViewer } from "./HintViewer";
|
||||||
import { exportFile, load, trash } from "./icons";
|
import { exportFile, load, shield, trash } from "./icons";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import "./LayerUI.scss";
|
import "./LayerUI.scss";
|
||||||
import { LibraryUnit } from "./LibraryUnit";
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { LockButton } from "./LockButton";
|
import { LockIcon } from "./LockIcon";
|
||||||
import { MobileMenu } from "./MobileMenu";
|
import { MobileMenu } from "./MobileMenu";
|
||||||
import { PasteChartDialog } from "./PasteChartDialog";
|
import { PasteChartDialog } from "./PasteChartDialog";
|
||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
@@ -45,10 +41,6 @@ import Stack from "./Stack";
|
|||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import Library from "../data/library";
|
|
||||||
import { JSONExportDialog } from "./JSONExportDialog";
|
|
||||||
import { LibraryButton } from "./LibraryButton";
|
|
||||||
import { isImageFileHandle } from "../data/blob";
|
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
@@ -65,14 +57,14 @@ interface LayerUIProps {
|
|||||||
toggleZenMode: () => void;
|
toggleZenMode: () => void;
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
onExportToBackend?: (
|
||||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
canvas: HTMLCanvasElement | null,
|
||||||
|
) => void;
|
||||||
|
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||||
viewModeEnabled: boolean;
|
viewModeEnabled: boolean;
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
UIOptions: AppProps["UIOptions"];
|
|
||||||
focusContainer: () => void;
|
|
||||||
library: Library;
|
|
||||||
id: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const useOnClickOutside = (
|
const useOnClickOutside = (
|
||||||
@@ -104,44 +96,35 @@ const useOnClickOutside = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LibraryMenuItems = ({
|
const LibraryMenuItems = ({
|
||||||
libraryItems,
|
library,
|
||||||
onRemoveFromLibrary,
|
onRemoveFromLibrary,
|
||||||
onAddToLibrary,
|
onAddToLibrary,
|
||||||
onInsertShape,
|
onInsertShape,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
theme,
|
|
||||||
setAppState,
|
setAppState,
|
||||||
setLibraryItems,
|
setLibraryItems,
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
focusContainer,
|
|
||||||
library,
|
|
||||||
id,
|
|
||||||
}: {
|
}: {
|
||||||
libraryItems: LibraryItems;
|
library: LibraryItems;
|
||||||
pendingElements: LibraryItem;
|
pendingElements: LibraryItem;
|
||||||
onRemoveFromLibrary: (index: number) => void;
|
onRemoveFromLibrary: (index: number) => void;
|
||||||
onInsertShape: (elements: LibraryItem) => void;
|
onInsertShape: (elements: LibraryItem) => void;
|
||||||
onAddToLibrary: (elements: LibraryItem) => void;
|
onAddToLibrary: (elements: LibraryItem) => void;
|
||||||
theme: AppState["theme"];
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
setLibraryItems: (library: LibraryItems) => void;
|
setLibraryItems: (library: LibraryItems) => void;
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
focusContainer: () => void;
|
|
||||||
library: Library;
|
|
||||||
id: string;
|
|
||||||
}) => {
|
}) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
|
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
|
||||||
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||||
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
|
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
|
||||||
const rows = [];
|
const rows = [];
|
||||||
let addedPendingElements = false;
|
let addedPendingElements = false;
|
||||||
|
|
||||||
const referrer =
|
const referrer = libraryReturnUrl || window.location.origin;
|
||||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
|
||||||
|
|
||||||
rows.push(
|
rows.push(
|
||||||
<div className="layer-ui__library-header" key="library-header">
|
<div className="layer-ui__library-header">
|
||||||
<ToolButton
|
<ToolButton
|
||||||
key="import"
|
key="import"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -149,11 +132,11 @@ const LibraryMenuItems = ({
|
|||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
icon={load}
|
icon={load}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
importLibraryFromJSON(library)
|
importLibraryFromJSON()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Close and then open to get the libraries updated
|
// 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 });
|
setAppState({ isLibraryOpen: false });
|
||||||
setAppState({ isLibraryOpen: true });
|
|
||||||
})
|
})
|
||||||
.catch(muteFSAbortError)
|
.catch(muteFSAbortError)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -161,42 +144,36 @@ const LibraryMenuItems = ({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!!libraryItems.length && (
|
<ToolButton
|
||||||
<>
|
key="export"
|
||||||
<ToolButton
|
type="button"
|
||||||
key="export"
|
title={t("buttons.export")}
|
||||||
type="button"
|
aria-label={t("buttons.export")}
|
||||||
title={t("buttons.export")}
|
icon={exportFile}
|
||||||
aria-label={t("buttons.export")}
|
onClick={() => {
|
||||||
icon={exportFile}
|
saveLibraryAsJSON()
|
||||||
onClick={() => {
|
.catch(muteFSAbortError)
|
||||||
saveLibraryAsJSON(library)
|
.catch((error) => {
|
||||||
.catch(muteFSAbortError)
|
setAppState({ errorMessage: error.message });
|
||||||
.catch((error) => {
|
});
|
||||||
setAppState({ errorMessage: error.message });
|
}}
|
||||||
});
|
/>
|
||||||
}}
|
<ToolButton
|
||||||
/>
|
key="reset"
|
||||||
<ToolButton
|
type="button"
|
||||||
key="reset"
|
title={t("buttons.resetLibrary")}
|
||||||
type="button"
|
aria-label={t("buttons.resetLibrary")}
|
||||||
title={t("buttons.resetLibrary")}
|
icon={trash}
|
||||||
aria-label={t("buttons.resetLibrary")}
|
onClick={() => {
|
||||||
icon={trash}
|
if (window.confirm(t("alerts.resetLibrary"))) {
|
||||||
onClick={() => {
|
Library.resetLibrary();
|
||||||
if (window.confirm(t("alerts.resetLibrary"))) {
|
setLibraryItems([]);
|
||||||
library.resetLibrary();
|
}
|
||||||
setLibraryItems([]);
|
}}
|
||||||
focusContainer();
|
/>
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<a
|
<a
|
||||||
href={`https://libraries.excalidraw.com?target=${
|
href={`https://libraries.excalidraw.com?referrer=${referrer}`}
|
||||||
window.name || "_blank"
|
|
||||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
|
|
||||||
target="_excalidraw_libraries"
|
target="_excalidraw_libraries"
|
||||||
>
|
>
|
||||||
{t("labels.libraries")}
|
{t("labels.libraries")}
|
||||||
@@ -211,13 +188,13 @@ const LibraryMenuItems = ({
|
|||||||
const shouldAddPendingElements: boolean =
|
const shouldAddPendingElements: boolean =
|
||||||
pendingElements.length > 0 &&
|
pendingElements.length > 0 &&
|
||||||
!addedPendingElements &&
|
!addedPendingElements &&
|
||||||
y + x >= libraryItems.length;
|
y + x >= library.length;
|
||||||
addedPendingElements = addedPendingElements || shouldAddPendingElements;
|
addedPendingElements = addedPendingElements || shouldAddPendingElements;
|
||||||
|
|
||||||
children.push(
|
children.push(
|
||||||
<Stack.Col key={x}>
|
<Stack.Col key={x}>
|
||||||
<LibraryUnit
|
<LibraryUnit
|
||||||
elements={libraryItems[y + x]}
|
elements={library[y + x]}
|
||||||
pendingElements={
|
pendingElements={
|
||||||
shouldAddPendingElements ? pendingElements : undefined
|
shouldAddPendingElements ? pendingElements : undefined
|
||||||
}
|
}
|
||||||
@@ -225,7 +202,7 @@ const LibraryMenuItems = ({
|
|||||||
onClick={
|
onClick={
|
||||||
shouldAddPendingElements
|
shouldAddPendingElements
|
||||||
? onAddToLibrary.bind(null, pendingElements)
|
? onAddToLibrary.bind(null, pendingElements)
|
||||||
: onInsertShape.bind(null, libraryItems[y + x])
|
: onInsertShape.bind(null, library[y + x])
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Stack.Col>,
|
</Stack.Col>,
|
||||||
@@ -250,23 +227,15 @@ const LibraryMenu = ({
|
|||||||
onInsertShape,
|
onInsertShape,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
onAddToLibrary,
|
onAddToLibrary,
|
||||||
theme,
|
|
||||||
setAppState,
|
setAppState,
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
focusContainer,
|
|
||||||
library,
|
|
||||||
id,
|
|
||||||
}: {
|
}: {
|
||||||
pendingElements: LibraryItem;
|
pendingElements: LibraryItem;
|
||||||
onClickOutside: (event: MouseEvent) => void;
|
onClickOutside: (event: MouseEvent) => void;
|
||||||
onInsertShape: (elements: LibraryItem) => void;
|
onInsertShape: (elements: LibraryItem) => void;
|
||||||
onAddToLibrary: () => void;
|
onAddToLibrary: () => void;
|
||||||
theme: AppState["theme"];
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
focusContainer: () => void;
|
|
||||||
library: Library;
|
|
||||||
id: string;
|
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
useOnClickOutside(ref, (event) => {
|
useOnClickOutside(ref, (event) => {
|
||||||
@@ -292,7 +261,7 @@ const LibraryMenu = ({
|
|||||||
resolve("loading");
|
resolve("loading");
|
||||||
}, 100);
|
}, 100);
|
||||||
}),
|
}),
|
||||||
library.loadLibrary().then((items) => {
|
Library.loadLibrary().then((items) => {
|
||||||
setLibraryItems(items);
|
setLibraryItems(items);
|
||||||
setIsLoading("ready");
|
setIsLoading("ready");
|
||||||
}),
|
}),
|
||||||
@@ -304,33 +273,24 @@ const LibraryMenu = ({
|
|||||||
return () => {
|
return () => {
|
||||||
clearTimeout(loadingTimerRef.current!);
|
clearTimeout(loadingTimerRef.current!);
|
||||||
};
|
};
|
||||||
}, [library]);
|
}, []);
|
||||||
|
|
||||||
const removeFromLibrary = useCallback(
|
const removeFromLibrary = useCallback(async (indexToRemove) => {
|
||||||
async (indexToRemove) => {
|
const items = await Library.loadLibrary();
|
||||||
const items = await library.loadLibrary();
|
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
||||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
Library.saveLibrary(nextItems);
|
||||||
library.saveLibrary(nextItems).catch((error) => {
|
setLibraryItems(nextItems);
|
||||||
setLibraryItems(items);
|
}, []);
|
||||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
|
||||||
});
|
|
||||||
setLibraryItems(nextItems);
|
|
||||||
},
|
|
||||||
[library, setAppState],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addToLibrary = useCallback(
|
const addToLibrary = useCallback(
|
||||||
async (elements: LibraryItem) => {
|
async (elements: LibraryItem) => {
|
||||||
const items = await library.loadLibrary();
|
const items = await Library.loadLibrary();
|
||||||
const nextItems = [...items, elements];
|
const nextItems = [...items, elements];
|
||||||
onAddToLibrary();
|
onAddToLibrary();
|
||||||
library.saveLibrary(nextItems).catch((error) => {
|
Library.saveLibrary(nextItems);
|
||||||
setLibraryItems(items);
|
|
||||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
|
||||||
});
|
|
||||||
setLibraryItems(nextItems);
|
setLibraryItems(nextItems);
|
||||||
},
|
},
|
||||||
[onAddToLibrary, library, setAppState],
|
[onAddToLibrary],
|
||||||
);
|
);
|
||||||
|
|
||||||
return loadingState === "preloading" ? null : (
|
return loadingState === "preloading" ? null : (
|
||||||
@@ -341,7 +301,7 @@ const LibraryMenu = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<LibraryMenuItems
|
<LibraryMenuItems
|
||||||
libraryItems={libraryItems}
|
library={libraryItems}
|
||||||
onRemoveFromLibrary={removeFromLibrary}
|
onRemoveFromLibrary={removeFromLibrary}
|
||||||
onAddToLibrary={addToLibrary}
|
onAddToLibrary={addToLibrary}
|
||||||
onInsertShape={onInsertShape}
|
onInsertShape={onInsertShape}
|
||||||
@@ -349,10 +309,6 @@ const LibraryMenu = ({
|
|||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
setLibraryItems={setLibraryItems}
|
setLibraryItems={setLibraryItems}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
focusContainer={focusContainer}
|
|
||||||
library={library}
|
|
||||||
theme={theme}
|
|
||||||
id={id}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Island>
|
</Island>
|
||||||
@@ -373,77 +329,69 @@ const LayerUI = ({
|
|||||||
showThemeBtn,
|
showThemeBtn,
|
||||||
toggleZenMode,
|
toggleZenMode,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
renderTopRightUI,
|
onExportToBackend,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
viewModeEnabled,
|
viewModeEnabled,
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
UIOptions,
|
|
||||||
focusContainer,
|
|
||||||
library,
|
|
||||||
id,
|
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const renderJSONExportDialog = () => {
|
const renderEncryptedIcon = () => (
|
||||||
if (!UIOptions.canvasActions.export) {
|
<a
|
||||||
return null;
|
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
|
||||||
}
|
"zen-mode-visibility--hidden": zenModeEnabled,
|
||||||
|
})}
|
||||||
return (
|
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||||
<JSONExportDialog
|
target="_blank"
|
||||||
elements={elements}
|
rel="noopener noreferrer"
|
||||||
appState={appState}
|
>
|
||||||
actionManager={actionManager}
|
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
|
||||||
exportOpts={UIOptions.canvasActions.export}
|
{shield}
|
||||||
canvas={canvas}
|
</Tooltip>
|
||||||
/>
|
</a>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const renderImageExportDialog = () => {
|
|
||||||
if (!UIOptions.canvasActions.saveAsImage) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const renderExportDialog = () => {
|
||||||
const createExporter = (type: ExportType): ExportCB => async (
|
const createExporter = (type: ExportType): ExportCB => async (
|
||||||
exportedElements,
|
exportedElements,
|
||||||
|
scale,
|
||||||
) => {
|
) => {
|
||||||
const fileHandle = await exportCanvas(type, exportedElements, appState, {
|
if (canvas) {
|
||||||
exportBackground: appState.exportBackground,
|
await exportCanvas(type, exportedElements, appState, canvas, {
|
||||||
name: appState.name,
|
exportBackground: appState.exportBackground,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
name: appState.name,
|
||||||
})
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
.catch(muteFSAbortError)
|
scale,
|
||||||
.catch((error) => {
|
shouldAddWatermark: appState.shouldAddWatermark,
|
||||||
console.error(error);
|
})
|
||||||
setAppState({ errorMessage: error.message });
|
.catch(muteFSAbortError)
|
||||||
});
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
if (
|
setAppState({ errorMessage: error.message });
|
||||||
appState.exportEmbedScene &&
|
});
|
||||||
fileHandle &&
|
|
||||||
isImageFileHandle(fileHandle)
|
|
||||||
) {
|
|
||||||
setAppState({ fileHandle });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImageExportDialog
|
<ExportDialog
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
onExportToPng={createExporter("png")}
|
onExportToPng={createExporter("png")}
|
||||||
onExportToSvg={createExporter("svg")}
|
onExportToSvg={createExporter("svg")}
|
||||||
onExportToClipboard={createExporter("clipboard")}
|
onExportToClipboard={createExporter("clipboard")}
|
||||||
|
onExportToBackend={
|
||||||
|
onExportToBackend
|
||||||
|
? (elements) => {
|
||||||
|
onExportToBackend &&
|
||||||
|
onExportToBackend(elements, appState, canvas);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Separator = () => {
|
|
||||||
return <div style={{ width: ".625em" }} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderViewModeCanvasActions = () => {
|
const renderViewModeCanvasActions = () => {
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
@@ -457,8 +405,9 @@ const LayerUI = ({
|
|||||||
<Island padding={2} style={{ zIndex: 1 }}>
|
<Island padding={2} 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">
|
||||||
{renderJSONExportDialog()}
|
{actionManager.renderAction("saveScene")}
|
||||||
{renderImageExportDialog()}
|
{actionManager.renderAction("saveAsScene")}
|
||||||
|
{renderExportDialog()}
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</Island>
|
</Island>
|
||||||
@@ -477,12 +426,11 @@ const LayerUI = ({
|
|||||||
<Island padding={2} style={{ zIndex: 1 }}>
|
<Island padding={2} 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("clearCanvas")}
|
|
||||||
<Separator />
|
|
||||||
{actionManager.renderAction("loadScene")}
|
{actionManager.renderAction("loadScene")}
|
||||||
{renderJSONExportDialog()}
|
{actionManager.renderAction("saveScene")}
|
||||||
{renderImageExportDialog()}
|
{actionManager.renderAction("saveAsScene")}
|
||||||
<Separator />
|
{renderExportDialog()}
|
||||||
|
{actionManager.renderAction("clearCanvas")}
|
||||||
{onCollabButtonClick && (
|
{onCollabButtonClick && (
|
||||||
<CollabButton
|
<CollabButton
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
@@ -497,9 +445,6 @@ const LayerUI = ({
|
|||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
showThemeBtn={showThemeBtn}
|
showThemeBtn={showThemeBtn}
|
||||||
/>
|
/>
|
||||||
{appState.fileHandle && (
|
|
||||||
<>{actionManager.renderAction("saveToActiveFile")}</>
|
|
||||||
)}
|
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</Island>
|
</Island>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -518,8 +463,7 @@ const LayerUI = ({
|
|||||||
style={{
|
style={{
|
||||||
// we want to make sure this doesn't overflow so substracting 200
|
// we want to make sure this doesn't overflow so substracting 200
|
||||||
// which is approximately height of zoom footer and top left menu items with some buffer
|
// which is approximately height of zoom footer and top left menu items with some buffer
|
||||||
// if active file name is displayed, subtracting 248 to account for its height
|
maxHeight: `${appState.height - 200}px`,
|
||||||
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectedShapeActions
|
<SelectedShapeActions
|
||||||
@@ -554,10 +498,6 @@ const LayerUI = ({
|
|||||||
onAddToLibrary={deselectItems}
|
onAddToLibrary={deselectItems}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
focusContainer={focusContainer}
|
|
||||||
library={library}
|
|
||||||
theme={appState.theme}
|
|
||||||
id={id}
|
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
@@ -584,12 +524,6 @@ const LayerUI = ({
|
|||||||
{(heading) => (
|
{(heading) => (
|
||||||
<Stack.Col gap={4} align="start">
|
<Stack.Col gap={4} align="start">
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<LockButton
|
|
||||||
zenModeEnabled={zenModeEnabled}
|
|
||||||
checked={appState.elementLocked}
|
|
||||||
onChange={onLockToggle}
|
|
||||||
title={t("toolBar.lock")}
|
|
||||||
/>
|
|
||||||
<Island
|
<Island
|
||||||
padding={1}
|
padding={1}
|
||||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
className={clsx({ "zen-mode": zenModeEnabled })}
|
||||||
@@ -601,12 +535,15 @@ const LayerUI = ({
|
|||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
elementType={appState.elementType}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
|
isLibraryOpen={appState.isLibraryOpen}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
<LibraryButton
|
<LockIcon
|
||||||
appState={appState}
|
zenModeEnabled={zenModeEnabled}
|
||||||
setAppState={setAppState}
|
checked={appState.elementLocked}
|
||||||
|
onChange={onLockToggle}
|
||||||
|
title={t("toolBar.lock")}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{libraryMenu}
|
{libraryMenu}
|
||||||
@@ -614,32 +551,24 @@ const LayerUI = ({
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
<div
|
<UserList
|
||||||
className={clsx(
|
className={clsx("zen-mode-transition", {
|
||||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
"transition-right": zenModeEnabled,
|
||||||
{
|
})}
|
||||||
"transition-right": zenModeEnabled,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<UserList>
|
{appState.collaborators.size > 0 &&
|
||||||
{appState.collaborators.size > 0 &&
|
Array.from(appState.collaborators)
|
||||||
Array.from(appState.collaborators)
|
// Collaborator is either not initialized or is actually the current user.
|
||||||
// Collaborator is either not initialized or is actually the current user.
|
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
.map(([clientId, client]) => (
|
||||||
.map(([clientId, client]) => (
|
<Tooltip
|
||||||
<Tooltip
|
label={client.username || "Unknown user"}
|
||||||
label={client.username || "Unknown user"}
|
key={clientId}
|
||||||
key={clientId}
|
>
|
||||||
>
|
{actionManager.renderAction("goToCollaborator", clientId)}
|
||||||
{actionManager.renderAction("goToCollaborator", {
|
</Tooltip>
|
||||||
id: clientId,
|
))}
|
||||||
})}
|
</UserList>
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</UserList>
|
|
||||||
{renderTopRightUI?.(isMobile, appState)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
);
|
);
|
||||||
@@ -647,71 +576,61 @@ const LayerUI = ({
|
|||||||
|
|
||||||
const renderBottomAppMenu = () => {
|
const renderBottomAppMenu = () => {
|
||||||
return (
|
return (
|
||||||
<footer
|
<div
|
||||||
role="contentinfo"
|
className={clsx("App-menu App-menu_bottom zen-mode-transition", {
|
||||||
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
|
"App-menu_bottom--transition-left": zenModeEnabled,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<Stack.Col gap={2}>
|
||||||
className={clsx(
|
<Section heading="canvasActions">
|
||||||
"layer-ui__wrapper__footer-left zen-mode-transition",
|
<Island padding={1}>
|
||||||
{
|
<ZoomActions
|
||||||
"layer-ui__wrapper__footer-left--transition-left": zenModeEnabled,
|
renderAction={actionManager.renderAction}
|
||||||
},
|
zoom={appState.zoom}
|
||||||
)}
|
/>
|
||||||
>
|
</Island>
|
||||||
<Stack.Col gap={2}>
|
{renderEncryptedIcon()}
|
||||||
<Section heading="canvasActions">
|
</Section>
|
||||||
<Island padding={1}>
|
</Stack.Col>
|
||||||
<ZoomActions
|
</div>
|
||||||
renderAction={actionManager.renderAction}
|
|
||||||
zoom={appState.zoom}
|
|
||||||
/>
|
|
||||||
</Island>
|
|
||||||
{!viewModeEnabled && (
|
|
||||||
<div
|
|
||||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
|
||||||
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{actionManager.renderAction("undo", { size: "small" })}
|
|
||||||
{actionManager.renderAction("redo", { size: "small" })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
</Stack.Col>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"layer-ui__wrapper__footer-center zen-mode-transition",
|
|
||||||
{
|
|
||||||
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{renderCustomFooter?.(false, appState)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"layer-ui__wrapper__footer-right zen-mode-transition",
|
|
||||||
{
|
|
||||||
"transition-right disable-pointerEvents": zenModeEnabled,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{actionManager.renderAction("toggleShortcuts")}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={clsx("disable-zen-mode", {
|
|
||||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
|
||||||
})}
|
|
||||||
onClick={toggleZenMode}
|
|
||||||
>
|
|
||||||
{t("buttons.exitZenMode")}
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderGitHubCorner = () => {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={clsx(
|
||||||
|
"layer-ui__wrapper__github-corner zen-mode-transition",
|
||||||
|
{
|
||||||
|
"transition-right": zenModeEnabled,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GitHubCorner theme={appState.theme} />
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const renderFooter = () => (
|
||||||
|
<footer role="contentinfo" className="layer-ui__wrapper__footer">
|
||||||
|
<div
|
||||||
|
className={clsx("zen-mode-transition", {
|
||||||
|
"transition-right disable-pointerEvents": zenModeEnabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{renderCustomFooter?.(false)}
|
||||||
|
{actionManager.renderAction("toggleShortcuts")}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={clsx("disable-zen-mode", {
|
||||||
|
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||||
|
})}
|
||||||
|
onClick={toggleZenMode}
|
||||||
|
>
|
||||||
|
{t("buttons.exitZenMode")}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
|
||||||
const dialogs = (
|
const dialogs = (
|
||||||
<>
|
<>
|
||||||
{appState.isLoading && <LoadingMessage />}
|
{appState.isLoading && <LoadingMessage />}
|
||||||
@@ -722,11 +641,7 @@ const LayerUI = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{appState.showHelpDialog && (
|
{appState.showHelpDialog && (
|
||||||
<HelpDialog
|
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
|
||||||
onClose={() => {
|
|
||||||
setAppState({ showHelpDialog: false });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{appState.pasteDialog.shown && (
|
{appState.pasteDialog.shown && (
|
||||||
<PasteChartDialog
|
<PasteChartDialog
|
||||||
@@ -751,8 +666,7 @@ const LayerUI = ({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
libraryMenu={libraryMenu}
|
libraryMenu={libraryMenu}
|
||||||
renderJSONExportDialog={renderJSONExportDialog}
|
exportButton={renderExportDialog()}
|
||||||
renderImageExportDialog={renderImageExportDialog}
|
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
onCollabButtonClick={onCollabButtonClick}
|
onCollabButtonClick={onCollabButtonClick}
|
||||||
onLockToggle={onLockToggle}
|
onLockToggle={onLockToggle}
|
||||||
@@ -775,6 +689,8 @@ const LayerUI = ({
|
|||||||
{dialogs}
|
{dialogs}
|
||||||
{renderFixedSideContainer()}
|
{renderFixedSideContainer()}
|
||||||
{renderBottomAppMenu()}
|
{renderBottomAppMenu()}
|
||||||
|
{renderGitHubCorner()}
|
||||||
|
{renderFooter()}
|
||||||
{appState.scrolledOutside && (
|
{appState.scrolledOutside && (
|
||||||
<button
|
<button
|
||||||
className="scroll-back-to-content"
|
className="scroll-back-to-content"
|
||||||
|
@@ -1,46 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { capitalizeString } from "../utils";
|
|
||||||
|
|
||||||
const LIBRARY_ICON = (
|
|
||||||
<svg viewBox="0 0 576 512">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M542.22 32.05c-54.8 3.11-163.72 14.43-230.96 55.59-4.64 2.84-7.27 7.89-7.27 13.17v363.87c0 11.55 12.63 18.85 23.28 13.49 69.18-34.82 169.23-44.32 218.7-46.92 16.89-.89 30.02-14.43 30.02-30.66V62.75c.01-17.71-15.35-31.74-33.77-30.7zM264.73 87.64C197.5 46.48 88.58 35.17 33.78 32.05 15.36 31.01 0 45.04 0 62.75V400.6c0 16.24 13.13 29.78 30.02 30.66 49.49 2.6 149.59 12.11 218.77 46.95 10.62 5.35 23.21-1.94 23.21-13.46V100.63c0-5.29-2.62-10.14-7.27-12.99z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const LibraryButton: React.FC<{
|
|
||||||
appState: AppState;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
}> = ({ appState, setAppState }) => {
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
className={clsx(
|
|
||||||
"ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
|
|
||||||
`ToolIcon_size_medium`,
|
|
||||||
{
|
|
||||||
"zen-mode-visibility--hidden": appState.zenModeEnabled,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
|
||||||
style={{ marginInlineStart: "var(--space-factor)" }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="ToolIcon_type_checkbox"
|
|
||||||
type="checkbox"
|
|
||||||
name="editor-library"
|
|
||||||
onChange={(event) => {
|
|
||||||
setAppState({ isLibraryOpen: event.target.checked });
|
|
||||||
}}
|
|
||||||
checked={appState.isLibraryOpen}
|
|
||||||
aria-label={capitalizeString(t("toolBar.library"))}
|
|
||||||
aria-keyshortcuts="9"
|
|
||||||
/>
|
|
||||||
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -1,10 +1,10 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { close } from "../components/icons";
|
import { close } from "../components/icons";
|
||||||
import { MIME_TYPES } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import useIsMobile from "../is-mobile";
|
||||||
import { exportToSvg } from "../scene/export";
|
import { exportToSvg } from "../scene/export";
|
||||||
import { LibraryItem } from "../types";
|
import { LibraryItem } from "../types";
|
||||||
import "./LibraryUnit.scss";
|
import "./LibraryUnit.scss";
|
||||||
@@ -36,27 +36,22 @@ export const LibraryUnit = ({
|
|||||||
if (!elementsToRender) {
|
if (!elementsToRender) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let svg: SVGSVGElement;
|
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!;
|
const current = ref.current!;
|
||||||
|
|
||||||
(async () => {
|
|
||||||
svg = await exportToSvg(elementsToRender, {
|
|
||||||
exportBackground: false,
|
|
||||||
viewBackgroundColor: oc.white,
|
|
||||||
});
|
|
||||||
for (const child of ref.current!.children) {
|
|
||||||
if (child.tagName !== "svg") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
current!.removeChild(child);
|
|
||||||
}
|
|
||||||
current!.appendChild(svg);
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (svg) {
|
current.removeChild(svg);
|
||||||
current.removeChild(svg);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [elements, pendingElements]);
|
}, [elements, pendingElements]);
|
||||||
|
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
export const LoadingMessage = () => {
|
export const LoadingMessage = () => {
|
||||||
|
@@ -2,17 +2,20 @@ import "./ToolIcon.scss";
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ToolButtonSize } from "./ToolButton";
|
|
||||||
|
type LockIconSize = "s" | "m";
|
||||||
|
|
||||||
type LockIconProps = {
|
type LockIconProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
id?: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange?(): void;
|
onChange?(): void;
|
||||||
|
size?: LockIconSize;
|
||||||
zenModeEnabled?: boolean;
|
zenModeEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
const DEFAULT_SIZE: LockIconSize = "m";
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
CHECKED: (
|
CHECKED: (
|
||||||
@@ -38,12 +41,12 @@ const ICONS = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LockButton = (props: LockIconProps) => {
|
export const LockIcon = (props: LockIconProps) => {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
|
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
|
||||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
`ToolIcon_size_${props.size || DEFAULT_SIZE}`,
|
||||||
{
|
{
|
||||||
"zen-mode-visibility--hidden": props.zenModeEnabled,
|
"zen-mode-visibility--hidden": props.zenModeEnabled,
|
||||||
},
|
},
|
||||||
@@ -54,6 +57,7 @@ export const LockButton = (props: LockIconProps) => {
|
|||||||
className="ToolIcon_type_checkbox"
|
className="ToolIcon_type_checkbox"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name={props.name}
|
name={props.name}
|
||||||
|
id={props.id}
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
checked={props.checked}
|
checked={props.checked}
|
||||||
aria-label={props.title}
|
aria-label={props.title}
|
@@ -13,16 +13,14 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
|||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import CollabButton from "./CollabButton";
|
import CollabButton from "./CollabButton";
|
||||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||||
import { LockButton } from "./LockButton";
|
import { LockIcon } from "./LockIcon";
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
import { LibraryButton } from "./LibraryButton";
|
|
||||||
|
|
||||||
type MobileMenuProps = {
|
type MobileMenuProps = {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
renderJSONExportDialog: () => React.ReactNode;
|
exportButton: React.ReactNode;
|
||||||
renderImageExportDialog: () => React.ReactNode;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
libraryMenu: JSX.Element | null;
|
libraryMenu: JSX.Element | null;
|
||||||
@@ -30,7 +28,7 @@ type MobileMenuProps = {
|
|||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||||
viewModeEnabled: boolean;
|
viewModeEnabled: boolean;
|
||||||
showThemeBtn: boolean;
|
showThemeBtn: boolean;
|
||||||
};
|
};
|
||||||
@@ -40,8 +38,7 @@ export const MobileMenu = ({
|
|||||||
elements,
|
elements,
|
||||||
libraryMenu,
|
libraryMenu,
|
||||||
actionManager,
|
actionManager,
|
||||||
renderJSONExportDialog,
|
exportButton,
|
||||||
renderImageExportDialog,
|
|
||||||
setAppState,
|
setAppState,
|
||||||
onCollabButtonClick,
|
onCollabButtonClick,
|
||||||
onLockToggle,
|
onLockToggle,
|
||||||
@@ -65,15 +62,15 @@ export const MobileMenu = ({
|
|||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
elementType={appState.elementType}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
|
isLibraryOpen={appState.isLibraryOpen}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
<LockButton
|
<LockIcon
|
||||||
checked={appState.elementLocked}
|
checked={appState.elementLocked}
|
||||||
onChange={onLockToggle}
|
onChange={onLockToggle}
|
||||||
title={t("toolBar.lock")}
|
title={t("toolBar.lock")}
|
||||||
/>
|
/>
|
||||||
<LibraryButton appState={appState} setAppState={setAppState} />
|
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{libraryMenu}
|
{libraryMenu}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
@@ -110,17 +107,19 @@ export const MobileMenu = ({
|
|||||||
if (viewModeEnabled) {
|
if (viewModeEnabled) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderJSONExportDialog()}
|
{actionManager.renderAction("saveScene")}
|
||||||
{renderImageExportDialog()}
|
{actionManager.renderAction("saveAsScene")}
|
||||||
|
{exportButton}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{actionManager.renderAction("clearCanvas")}
|
|
||||||
{actionManager.renderAction("loadScene")}
|
{actionManager.renderAction("loadScene")}
|
||||||
{renderJSONExportDialog()}
|
{actionManager.renderAction("saveScene")}
|
||||||
{renderImageExportDialog()}
|
{actionManager.renderAction("saveAsScene")}
|
||||||
|
{exportButton}
|
||||||
|
{actionManager.renderAction("clearCanvas")}
|
||||||
{onCollabButtonClick && (
|
{onCollabButtonClick && (
|
||||||
<CollabButton
|
<CollabButton
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
@@ -156,7 +155,7 @@ export const MobileMenu = ({
|
|||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
<Stack.Col gap={4}>
|
<Stack.Col gap={4}>
|
||||||
{renderCanvasActions()}
|
{renderCanvasActions()}
|
||||||
{renderCustomFooter?.(true, appState)}
|
{renderCustomFooter?.(true)}
|
||||||
{appState.collaborators.size > 0 && (
|
{appState.collaborators.size > 0 && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.collaborators")}</legend>
|
<legend>{t("labels.collaborators")}</legend>
|
||||||
@@ -168,9 +167,10 @@ export const MobileMenu = ({
|
|||||||
)
|
)
|
||||||
.map(([clientId, client]) => (
|
.map(([clientId, client]) => (
|
||||||
<React.Fragment key={clientId}>
|
<React.Fragment key={clientId}>
|
||||||
{actionManager.renderAction("goToCollaborator", {
|
{actionManager.renderAction(
|
||||||
id: clientId,
|
"goToCollaborator",
|
||||||
})}
|
clientId,
|
||||||
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</UserList>
|
</UserList>
|
||||||
|
@@ -26,7 +26,8 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background-color: transparentize($oc-black, 0.3);
|
background-color: transparentize($oc-black, 0.7);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.Modal__content {
|
.Modal__content {
|
||||||
@@ -44,17 +45,14 @@
|
|||||||
|
|
||||||
// for modals, reset blurry bg
|
// for modals, reset blurry bg
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
|
backdrop-filter: none;
|
||||||
|
|
||||||
border: 1px solid var(--dialog-border-color);
|
border: 1px solid var(--dialog-border-color);
|
||||||
box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
|
box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
&:focus {
|
@media #{$is-mobile-query} {
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include isMobile {
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -84,7 +82,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include isMobile {
|
@media #{$is-mobile-query} {
|
||||||
.Modal {
|
.Modal {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
@@ -1,12 +1,9 @@
|
|||||||
import "./Modal.scss";
|
import "./Modal.scss";
|
||||||
|
|
||||||
import React, { useState, useLayoutEffect, useRef } from "react";
|
import React, { useState, useLayoutEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { useExcalidrawContainer, useIsMobile } from "./App";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { THEME } from "../constants";
|
|
||||||
|
|
||||||
export const Modal = (props: {
|
export const Modal = (props: {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -14,10 +11,8 @@ export const Modal = (props: {
|
|||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
onCloseRequest(): void;
|
onCloseRequest(): void;
|
||||||
labelledBy: string;
|
labelledBy: string;
|
||||||
theme?: AppState["theme"];
|
|
||||||
}) => {
|
}) => {
|
||||||
const { theme = THEME.LIGHT } = props;
|
const modalRoot = useBodyRoot();
|
||||||
const modalRoot = useBodyRoot(theme);
|
|
||||||
|
|
||||||
if (!modalRoot) {
|
if (!modalRoot) {
|
||||||
return null;
|
return null;
|
||||||
@@ -26,7 +21,6 @@ export const Modal = (props: {
|
|||||||
const handleKeydown = (event: React.KeyboardEvent) => {
|
const handleKeydown = (event: React.KeyboardEvent) => {
|
||||||
if (event.key === KEYS.ESCAPE) {
|
if (event.key === KEYS.ESCAPE) {
|
||||||
event.nativeEvent.stopImmediatePropagation();
|
event.nativeEvent.stopImmediatePropagation();
|
||||||
event.stopPropagation();
|
|
||||||
props.onCloseRequest();
|
props.onCloseRequest();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -43,7 +37,6 @@ export const Modal = (props: {
|
|||||||
<div
|
<div
|
||||||
className="Modal__content"
|
className="Modal__content"
|
||||||
style={{ "--max-width": `${props.maxWidth}px` }}
|
style={{ "--max-width": `${props.maxWidth}px` }}
|
||||||
tabIndex={0}
|
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
@@ -52,29 +45,16 @@ export const Modal = (props: {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useBodyRoot = (theme: AppState["theme"]) => {
|
const useBodyRoot = () => {
|
||||||
const [div, setDiv] = useState<HTMLDivElement | null>(null);
|
const [div, setDiv] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const isMobileRef = useRef(isMobile);
|
|
||||||
isMobileRef.current = isMobile;
|
|
||||||
|
|
||||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (div) {
|
const isDarkTheme = !!document
|
||||||
div.classList.toggle("excalidraw--mobile", isMobile);
|
.querySelector(".excalidraw")
|
||||||
}
|
?.classList.contains("theme--dark");
|
||||||
}, [div, isMobile]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const isDarkTheme =
|
|
||||||
!!excalidrawContainer?.classList.contains("theme--dark") ||
|
|
||||||
theme === "dark";
|
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
|
|
||||||
div.classList.add("excalidraw", "excalidraw-modal-container");
|
div.classList.add("excalidraw", "excalidraw-modal-container");
|
||||||
div.classList.toggle("excalidraw--mobile", isMobileRef.current);
|
|
||||||
|
|
||||||
if (isDarkTheme) {
|
if (isDarkTheme) {
|
||||||
div.classList.add("theme--dark");
|
div.classList.add("theme--dark");
|
||||||
@@ -87,7 +67,7 @@ const useBodyRoot = (theme: AppState["theme"]) => {
|
|||||||
return () => {
|
return () => {
|
||||||
document.body.removeChild(div);
|
document.body.removeChild(div);
|
||||||
};
|
};
|
||||||
}, [excalidrawContainer, theme]);
|
}, []);
|
||||||
|
|
||||||
return div;
|
return div;
|
||||||
};
|
};
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.PasteChartDialog {
|
.PasteChartDialog {
|
||||||
@include isMobile {
|
@media #{$is-mobile-query} {
|
||||||
.Island {
|
.Island {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@include isMobile {
|
@media #{$is-mobile-query} {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
@@ -34,21 +34,20 @@ const ChartPreviewBtn = (props: {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
setChartElements(elements);
|
setChartElements(elements);
|
||||||
let svg: SVGSVGElement;
|
|
||||||
|
const svg = exportToSvg(elements, {
|
||||||
|
exportBackground: false,
|
||||||
|
viewBackgroundColor: oc.white,
|
||||||
|
shouldAddWatermark: false,
|
||||||
|
});
|
||||||
|
|
||||||
const previewNode = previewRef.current!;
|
const previewNode = previewRef.current!;
|
||||||
|
|
||||||
(async () => {
|
previewNode.appendChild(svg);
|
||||||
svg = await exportToSvg(elements, {
|
|
||||||
exportBackground: false,
|
|
||||||
viewBackgroundColor: oc.white,
|
|
||||||
});
|
|
||||||
|
|
||||||
previewNode.appendChild(svg);
|
if (props.selected) {
|
||||||
|
(previewNode.parentNode as HTMLDivElement).focus();
|
||||||
if (props.selected) {
|
}
|
||||||
(previewNode.parentNode as HTMLDivElement).focus();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
previewNode.removeChild(svg);
|
previewNode.removeChild(svg);
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.popover {
|
.popover {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,25 +0,0 @@
|
|||||||
.ProjectName {
|
|
||||||
margin: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.TextInput {
|
|
||||||
height: calc(1rem - 3px);
|
|
||||||
width: 200px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-align: center;
|
|
||||||
margin-left: 8px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
&--readonly {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
&:hover {
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
width: auto;
|
|
||||||
max-width: 200px;
|
|
||||||
padding-left: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,10 +1,7 @@
|
|||||||
import "./TextInput.scss";
|
import "./TextInput.scss";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { Component } from "react";
|
||||||
import { focusNearestParent } from "../utils";
|
import { selectNode, removeSelection } from "../utils";
|
||||||
|
|
||||||
import "./ProjectName.scss";
|
|
||||||
import { useExcalidrawContainer } from "./App";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -13,19 +10,20 @@ type Props = {
|
|||||||
isNameEditable: boolean;
|
isNameEditable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProjectName = (props: Props) => {
|
export class ProjectName extends Component<Props> {
|
||||||
const { id } = useExcalidrawContainer();
|
private handleFocus = (event: React.FocusEvent<HTMLElement>) => {
|
||||||
const [fileName, setFileName] = useState<string>(props.value);
|
selectNode(event.currentTarget);
|
||||||
|
|
||||||
const handleBlur = (event: any) => {
|
|
||||||
focusNearestParent(event.target);
|
|
||||||
const value = event.target.value;
|
|
||||||
if (value !== props.value) {
|
|
||||||
props.onChange(value);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
private handleBlur = (event: React.FocusEvent<HTMLElement>) => {
|
||||||
|
const value = event.currentTarget.innerText.trim();
|
||||||
|
if (value !== this.props.value) {
|
||||||
|
this.props.onChange(value);
|
||||||
|
}
|
||||||
|
removeSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (event.nativeEvent.isComposing || event.keyCode === 229) {
|
if (event.nativeEvent.isComposing || event.keyCode === 229) {
|
||||||
@@ -34,26 +32,39 @@ export const ProjectName = (props: Props) => {
|
|||||||
event.currentTarget.blur();
|
event.currentTarget.blur();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
private makeEditable = (editable: HTMLSpanElement | null) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
editable.contentEditable = "plaintext-only";
|
||||||
|
} catch {
|
||||||
|
editable.contentEditable = "true";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
public render() {
|
||||||
<div className="ProjectName">
|
return this.props.isNameEditable ? (
|
||||||
<label className="ProjectName-label" htmlFor="filename">
|
<span
|
||||||
{`${props.label}${props.isNameEditable ? "" : ":"}`}
|
suppressContentEditableWarning
|
||||||
</label>
|
ref={this.makeEditable}
|
||||||
{props.isNameEditable ? (
|
data-type="wysiwyg"
|
||||||
<input
|
className="TextInput"
|
||||||
className="TextInput"
|
role="textbox"
|
||||||
onBlur={handleBlur}
|
aria-label={this.props.label}
|
||||||
onKeyDown={handleKeyDown}
|
onBlur={this.handleBlur}
|
||||||
id={`${id}-filename`}
|
onKeyDown={this.handleKeyDown}
|
||||||
value={fileName}
|
onFocus={this.handleFocus}
|
||||||
onChange={(event) => setFileName(event.target.value)}
|
>
|
||||||
/>
|
{this.props.value}
|
||||||
) : (
|
</span>
|
||||||
<span className="TextInput TextInput--readonly" id={`${id}-filename`}>
|
) : (
|
||||||
{props.value}
|
<span
|
||||||
</span>
|
className="TextInput TextInput--readonly"
|
||||||
)}
|
aria-label={this.props.label}
|
||||||
</div>
|
>
|
||||||
);
|
{this.props.value}
|
||||||
};
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useExcalidrawContainer } from "./App";
|
|
||||||
|
|
||||||
interface SectionProps extends React.HTMLProps<HTMLElement> {
|
interface SectionProps extends React.HTMLProps<HTMLElement> {
|
||||||
heading: string;
|
heading: string;
|
||||||
@@ -8,14 +7,13 @@ interface SectionProps extends React.HTMLProps<HTMLElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Section = ({ heading, children, ...props }: SectionProps) => {
|
export const Section = ({ heading, children, ...props }: SectionProps) => {
|
||||||
const { id } = useExcalidrawContainer();
|
|
||||||
const header = (
|
const header = (
|
||||||
<h2 className="visually-hidden" id={`${id}-${heading}-title`}>
|
<h2 className="visually-hidden" id={`${heading}-title`}>
|
||||||
{t(`headings.${heading}`)}
|
{t(`headings.${heading}`)}
|
||||||
</h2>
|
</h2>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<section {...props} aria-labelledby={`${id}-${heading}-title`}>
|
<section {...props} aria-labelledby={`${heading}-title`}>
|
||||||
{typeof children === "function" ? (
|
{typeof children === "function" ? (
|
||||||
children(header)
|
children(header)
|
||||||
) : (
|
) : (
|
||||||
|
@@ -6,7 +6,7 @@
|
|||||||
top: 64px;
|
top: 64px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
z-index: 10;
|
z-index: 999;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 24px 8px 0;
|
margin: 0 24px 8px 0;
|
||||||
|
@@ -1,22 +1,49 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { copyTextToSystemClipboard } from "../clipboard";
|
||||||
|
import { DEFAULT_VERSION } from "../constants";
|
||||||
import { getCommonBounds } from "../element/bounds";
|
import { getCommonBounds } from "../element/bounds";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
|
import {
|
||||||
|
getElementsStorageSize,
|
||||||
|
getTotalStorageSize,
|
||||||
|
} from "../excalidraw-app/data/localStorage";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import useIsMobile from "../is-mobile";
|
||||||
import { getTargetElements } from "../scene";
|
import { getTargetElements } from "../scene";
|
||||||
import { AppState, ExcalidrawProps } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import { debounce, getVersion, nFormatter } from "../utils";
|
||||||
import { close } from "./icons";
|
import { close } from "./icons";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import "./Stats.scss";
|
import "./Stats.scss";
|
||||||
|
|
||||||
|
type StorageSizes = { scene: number; total: number };
|
||||||
|
|
||||||
|
const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
||||||
|
cb({
|
||||||
|
scene: getElementsStorageSize(),
|
||||||
|
total: getTotalStorageSize(),
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
|
||||||
export const Stats = (props: {
|
export const Stats = (props: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
|
||||||
}) => {
|
}) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
|
||||||
|
scene: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getStorageSizes((sizes) => {
|
||||||
|
setStorageSizes(sizes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => () => getStorageSizes.cancel(), []);
|
||||||
|
|
||||||
const boundingBox = getCommonBounds(props.elements);
|
const boundingBox = getCommonBounds(props.elements);
|
||||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||||
@@ -26,6 +53,17 @@ export const Stats = (props: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const version = getVersion();
|
||||||
|
let hash;
|
||||||
|
let timestamp;
|
||||||
|
|
||||||
|
if (version !== DEFAULT_VERSION) {
|
||||||
|
timestamp = version.slice(0, 16).replace("T", " ");
|
||||||
|
hash = version.slice(21);
|
||||||
|
} else {
|
||||||
|
timestamp = t("stats.versionNotAvailable");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="Stats">
|
<div className="Stats">
|
||||||
<Island padding={2}>
|
<Island padding={2}>
|
||||||
@@ -50,7 +88,17 @@ export const Stats = (props: {
|
|||||||
<td>{t("stats.height")}</td>
|
<td>{t("stats.height")}</td>
|
||||||
<td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
|
<td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th colSpan={2}>{t("stats.storage")}</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{t("stats.scene")}</td>
|
||||||
|
<td>{nFormatter(storageSizes.scene, 1)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{t("stats.total")}</td>
|
||||||
|
<td>{nFormatter(storageSizes.total, 1)}</td>
|
||||||
|
</tr>
|
||||||
{selectedElements.length === 1 && (
|
{selectedElements.length === 1 && (
|
||||||
<tr>
|
<tr>
|
||||||
<th colSpan={2}>{t("stats.element")}</th>
|
<th colSpan={2}>{t("stats.element")}</th>
|
||||||
@@ -72,17 +120,31 @@ export const Stats = (props: {
|
|||||||
<>
|
<>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{"x"}</td>
|
<td>{"x"}</td>
|
||||||
<td>{Math.round(selectedBoundingBox[0])}</td>
|
<td>
|
||||||
|
{Math.round(
|
||||||
|
selectedElements.length === 1
|
||||||
|
? selectedElements[0].x
|
||||||
|
: selectedBoundingBox[0],
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{"y"}</td>
|
<td>{"y"}</td>
|
||||||
<td>{Math.round(selectedBoundingBox[1])}</td>
|
<td>
|
||||||
|
{Math.round(
|
||||||
|
selectedElements.length === 1
|
||||||
|
? selectedElements[0].y
|
||||||
|
: selectedBoundingBox[1],
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{t("stats.width")}</td>
|
<td>{t("stats.width")}</td>
|
||||||
<td>
|
<td>
|
||||||
{Math.round(
|
{Math.round(
|
||||||
selectedBoundingBox[2] - selectedBoundingBox[0],
|
selectedElements.length === 1
|
||||||
|
? selectedElements[0].width
|
||||||
|
: selectedBoundingBox[2] - selectedBoundingBox[0],
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -90,7 +152,9 @@ export const Stats = (props: {
|
|||||||
<td>{t("stats.height")}</td>
|
<td>{t("stats.height")}</td>
|
||||||
<td>
|
<td>
|
||||||
{Math.round(
|
{Math.round(
|
||||||
selectedBoundingBox[3] - selectedBoundingBox[1],
|
selectedElements.length === 1
|
||||||
|
? selectedElements[0].height
|
||||||
|
: selectedBoundingBox[3] - selectedBoundingBox[1],
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -106,7 +170,28 @@ export const Stats = (props: {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{props.renderCustomStats?.(props.elements, props.appState)}
|
<tr>
|
||||||
|
<th colSpan={2}>{t("stats.version")}</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={2}
|
||||||
|
style={{ textAlign: "center", cursor: "pointer" }}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await copyTextToSystemClipboard(getVersion());
|
||||||
|
props.setAppState({
|
||||||
|
toastMessage: t("toast.copyToClipboard"),
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
title={t("stats.versionCopy")}
|
||||||
|
>
|
||||||
|
{timestamp}
|
||||||
|
<br />
|
||||||
|
{hash}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</Island>
|
</Island>
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import React, { useCallback, useEffect, useRef } from "react";
|
||||||
import { TOAST_TIMEOUT } from "../constants";
|
import { TOAST_TIMEOUT } from "../constants";
|
||||||
import "./Toast.scss";
|
import "./Toast.scss";
|
||||||
|
|
||||||
|
@@ -2,9 +2,8 @@ import "./ToolIcon.scss";
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useExcalidrawContainer } from "./App";
|
|
||||||
|
|
||||||
export type ToolButtonSize = "small" | "medium";
|
type ToolIconSize = "s" | "m";
|
||||||
|
|
||||||
type ToolButtonBaseProps = {
|
type ToolButtonBaseProps = {
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
@@ -15,7 +14,7 @@ type ToolButtonBaseProps = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
size?: ToolButtonSize;
|
size?: ToolIconSize;
|
||||||
keyBindingLabel?: string;
|
keyBindingLabel?: string;
|
||||||
showAriaLabel?: boolean;
|
showAriaLabel?: boolean;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
@@ -30,24 +29,21 @@ type ToolButtonProps =
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onClick?(): void;
|
onClick?(): void;
|
||||||
})
|
})
|
||||||
| (ToolButtonBaseProps & {
|
|
||||||
type: "icon";
|
|
||||||
children?: React.ReactNode;
|
|
||||||
onClick?(): void;
|
|
||||||
})
|
|
||||||
| (ToolButtonBaseProps & {
|
| (ToolButtonBaseProps & {
|
||||||
type: "radio";
|
type: "radio";
|
||||||
|
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange?(): void;
|
onChange?(): void;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DEFAULT_SIZE: ToolIconSize = "m";
|
||||||
|
|
||||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||||
const { id: excalId } = useExcalidrawContainer();
|
|
||||||
const innerRef = React.useRef(null);
|
const innerRef = React.useRef(null);
|
||||||
React.useImperativeHandle(ref, () => innerRef.current);
|
React.useImperativeHandle(ref, () => innerRef.current);
|
||||||
const sizeCn = `ToolIcon_size_${props.size}`;
|
const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
|
||||||
|
|
||||||
if (props.type === "button" || props.type === "icon") {
|
if (props.type === "button") {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -60,7 +56,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
{
|
{
|
||||||
ToolIcon: !props.hidden,
|
ToolIcon: !props.hidden,
|
||||||
"ToolIcon--selected": props.selected,
|
"ToolIcon--selected": props.selected,
|
||||||
"ToolIcon--plain": props.type === "icon",
|
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
data-testid={props["data-testid"]}
|
data-testid={props["data-testid"]}
|
||||||
@@ -71,16 +66,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
>
|
>
|
||||||
{(props.icon || props.label) && (
|
<div className="ToolIcon__icon" aria-hidden="true">
|
||||||
<div className="ToolIcon__icon" aria-hidden="true">
|
{props.icon || props.label}
|
||||||
{props.icon || props.label}
|
{props.keyBindingLabel && (
|
||||||
{props.keyBindingLabel && (
|
<span className="ToolIcon__keybinding">
|
||||||
<span className="ToolIcon__keybinding">
|
{props.keyBindingLabel}
|
||||||
{props.keyBindingLabel}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{props.showAriaLabel && (
|
{props.showAriaLabel && (
|
||||||
<div className="ToolIcon__label">{props["aria-label"]}</div>
|
<div className="ToolIcon__label">{props["aria-label"]}</div>
|
||||||
)}
|
)}
|
||||||
@@ -98,7 +91,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
aria-label={props["aria-label"]}
|
aria-label={props["aria-label"]}
|
||||||
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
||||||
data-testid={props["data-testid"]}
|
data-testid={props["data-testid"]}
|
||||||
id={`${excalId}-${props.id}`}
|
id={props.id}
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
checked={props.checked}
|
checked={props.checked}
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
@@ -116,5 +109,4 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
ToolButton.defaultProps = {
|
ToolButton.defaultProps = {
|
||||||
visible: true,
|
visible: true,
|
||||||
className: "",
|
className: "",
|
||||||
size: "medium",
|
|
||||||
};
|
};
|
||||||
|
@@ -8,26 +8,9 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
font-family: Cascadia;
|
font-family: Cascadia;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
background-color: var(--button-gray-1);
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
border-radius: var(--space-factor);
|
border-radius: var(--space-factor);
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
background-color: var(--button-gray-1);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--button-gray-2);
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: var(--button-gray-3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon--plain {
|
|
||||||
background-color: transparent;
|
|
||||||
.ToolIcon__icon {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
@@ -60,9 +43,9 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon_size_small .ToolIcon__icon {
|
.ToolIcon_size_s .ToolIcon__icon {
|
||||||
width: 2rem;
|
width: 1.4rem;
|
||||||
height: 2rem;
|
height: 1.4rem;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +57,14 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--button-gray-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: var(--button-gray-2);
|
||||||
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
}
|
}
|
||||||
@@ -86,14 +77,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--button-gray-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background-color: var(--button-gray-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--show {
|
&--show {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
@@ -111,9 +94,6 @@
|
|||||||
|
|
||||||
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
|
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
|
||||||
background-color: var(--button-gray-2);
|
background-color: var(--button-gray-2);
|
||||||
&:active {
|
|
||||||
background-color: var(--button-gray-3);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus + .ToolIcon__icon {
|
&:focus + .ToolIcon__icon {
|
||||||
@@ -141,21 +121,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
background-color: var(--button-gray-1);
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--button-gray-2);
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: var(--button-gray-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2em;
|
height: 2em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon.ToolIcon__lock {
|
.ToolIcon.ToolIcon__lock {
|
||||||
margin-inline-end: var(--space-factor);
|
|
||||||
&.ToolIcon_type_floating {
|
&.ToolIcon_type_floating {
|
||||||
margin-left: 0.1rem;
|
margin-left: 0.1rem;
|
||||||
}
|
}
|
||||||
@@ -186,9 +157,10 @@
|
|||||||
// move the lock button out of the way on small viewports
|
// move the lock button out of the way on small viewports
|
||||||
// it begins to collide with the GitHub icon before we switch to mobile mode
|
// it begins to collide with the GitHub icon before we switch to mobile mode
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.ToolIcon.ToolIcon_type_floating {
|
.ToolIcon.ToolIcon__lock {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
top: 60px;
|
||||||
right: -8px;
|
right: -8px;
|
||||||
|
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
@@ -213,13 +185,16 @@
|
|||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.ToolIcon.ToolIcon__library {
|
}
|
||||||
top: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon.ToolIcon__lock {
|
.TooltipIcon {
|
||||||
margin-inline-end: 0;
|
width: 0.9em;
|
||||||
top: 60px;
|
height: 0.9em;
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-top: 1px;
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,45 +1,58 @@
|
|||||||
@import "../css/variables.module";
|
@import "../css/variables.module";
|
||||||
|
.excalidraw {
|
||||||
|
.Tooltip {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
// container in body where the actual tooltip is appended to
|
.Tooltip__label {
|
||||||
.excalidraw-tooltip {
|
--arrow-size: 4px;
|
||||||
position: absolute;
|
visibility: hidden;
|
||||||
z-index: 1000;
|
background: $oc-black;
|
||||||
|
color: $oc-white;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 500;
|
||||||
|
// extra pixel offset for unknown reasons
|
||||||
|
left: calc(50% + var(--arrow-size) / 2 - 1px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
word-wrap: break-word;
|
||||||
|
|
||||||
padding: 8px;
|
&::after {
|
||||||
border-radius: 6px;
|
content: "";
|
||||||
box-sizing: border-box;
|
border: var(--arrow-size) solid transparent;
|
||||||
pointer-events: none;
|
position: absolute;
|
||||||
word-wrap: break-word;
|
left: calc(50% - var(--arrow-size));
|
||||||
|
}
|
||||||
|
|
||||||
background: $oc-black;
|
&--above {
|
||||||
|
bottom: calc(100% + var(--arrow-size) + 3px);
|
||||||
|
|
||||||
line-height: 1.5;
|
&::after {
|
||||||
text-align: center;
|
border-top-color: $oc-black;
|
||||||
font-size: 13px;
|
top: 100%;
|
||||||
font-weight: 500;
|
}
|
||||||
color: $oc-white;
|
}
|
||||||
|
|
||||||
display: none;
|
&--below {
|
||||||
|
top: calc(100% + var(--arrow-size) + 3px);
|
||||||
|
|
||||||
&.excalidraw-tooltip--visible {
|
&::after {
|
||||||
display: block;
|
border-bottom-color: $oc-black;
|
||||||
}
|
bottom: 100%;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// wraps the element we want to apply the tooltip to
|
}
|
||||||
.excalidraw-tooltip-wrapper {
|
|
||||||
display: flex;
|
.Tooltip:hover .Tooltip__label {
|
||||||
height: 100%;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.excalidraw-tooltip-icon {
|
.Tooltip__label:hover {
|
||||||
width: 0.9em;
|
visibility: visible;
|
||||||
height: 0.9em;
|
|
||||||
margin-left: 5px;
|
|
||||||
margin-top: 1px;
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
@include isMobile {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,93 +1,31 @@
|
|||||||
import "./Tooltip.scss";
|
import "./Tooltip.scss";
|
||||||
|
|
||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
|
|
||||||
const getTooltipDiv = () => {
|
|
||||||
const existingDiv = document.querySelector<HTMLDivElement>(
|
|
||||||
".excalidraw-tooltip",
|
|
||||||
);
|
|
||||||
if (existingDiv) {
|
|
||||||
return existingDiv;
|
|
||||||
}
|
|
||||||
const div = document.createElement("div");
|
|
||||||
document.body.appendChild(div);
|
|
||||||
div.classList.add("excalidraw-tooltip");
|
|
||||||
return div;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTooltip = (
|
|
||||||
item: HTMLDivElement,
|
|
||||||
tooltip: HTMLDivElement,
|
|
||||||
label: string,
|
|
||||||
long: boolean,
|
|
||||||
) => {
|
|
||||||
tooltip.classList.add("excalidraw-tooltip--visible");
|
|
||||||
tooltip.style.minWidth = long ? "50ch" : "10ch";
|
|
||||||
tooltip.style.maxWidth = long ? "50ch" : "15ch";
|
|
||||||
|
|
||||||
tooltip.textContent = label;
|
|
||||||
|
|
||||||
const {
|
|
||||||
x: itemX,
|
|
||||||
bottom: itemBottom,
|
|
||||||
top: itemTop,
|
|
||||||
width: itemWidth,
|
|
||||||
} = item.getBoundingClientRect();
|
|
||||||
|
|
||||||
const {
|
|
||||||
width: labelWidth,
|
|
||||||
height: labelHeight,
|
|
||||||
} = tooltip.getBoundingClientRect();
|
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
const margin = 5;
|
|
||||||
|
|
||||||
const left = itemX + itemWidth / 2 - labelWidth / 2;
|
|
||||||
const offsetLeft =
|
|
||||||
left + labelWidth >= viewportWidth ? left + labelWidth - viewportWidth : 0;
|
|
||||||
|
|
||||||
const top = itemBottom + margin;
|
|
||||||
const offsetTop =
|
|
||||||
top + labelHeight >= viewportHeight
|
|
||||||
? itemBottom - itemTop + labelHeight + margin * 2
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
Object.assign(tooltip.style, {
|
|
||||||
top: `${top - offsetTop}px`,
|
|
||||||
left: `${left - offsetLeft}px`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
type TooltipProps = {
|
type TooltipProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
|
position?: "above" | "below";
|
||||||
long?: boolean;
|
long?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
|
export const Tooltip = ({
|
||||||
useEffect(() => {
|
children,
|
||||||
return () =>
|
label,
|
||||||
getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
|
position = "below",
|
||||||
}, []);
|
long = false,
|
||||||
|
}: TooltipProps) => (
|
||||||
return (
|
<div className="Tooltip">
|
||||||
<div
|
<span
|
||||||
className="excalidraw-tooltip-wrapper"
|
className={
|
||||||
onPointerEnter={(event) =>
|
position === "above"
|
||||||
updateTooltip(
|
? "Tooltip__label Tooltip__label--above"
|
||||||
event.currentTarget as HTMLDivElement,
|
: "Tooltip__label Tooltip__label--below"
|
||||||
getTooltipDiv(),
|
|
||||||
label,
|
|
||||||
long,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onPointerLeave={() =>
|
|
||||||
getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
|
|
||||||
}
|
}
|
||||||
|
style={{ width: long ? "50ch" : "10ch" }}
|
||||||
>
|
>
|
||||||
{children}
|
{label}
|
||||||
</div>
|
</span>
|
||||||
);
|
{children}
|
||||||
};
|
</div>
|
||||||
|
);
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user