mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
30 Commits
v0.12.0
...
zsviczian-
Author | SHA1 | Date | |
---|---|---|---|
13309a66c5 | |||
531829d95e | |||
d3cbceb7fa | |||
73111500d3 | |||
9e17b64e5e | |||
326da61573 | |||
994f2a3f1e | |||
5dbcf64353 | |||
eda2320dae | |||
b610c04481 | |||
d969849357 | |||
9a66fc6c05 | |||
158f169c43 | |||
ce27cb6159 | |||
2e04bcd485 | |||
7436f3926b | |||
e429b7048d | |||
e61b447413 | |||
73f0d854bf | |||
cec3cf8334 | |||
8640e75ccf | |||
ca7ce64fea | |||
e3a78fe5df | |||
554985f749 | |||
d3857fbb35 | |||
93c72cbb32 | |||
aeb4d39387 | |||
a0259360d6 | |||
243d8de7a8 | |||
81c927bab6 |
@ -4,19 +4,9 @@ REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
||||
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
|
||||
REACT_APP_WS_SERVER_URL=http://localhost:3002
|
||||
|
||||
# set this only if using the collaboration workflow we use on excalidraw.com
|
||||
REACT_APP_PORTAL_URL=
|
||||
REACT_APP_PORTAL_URL=http://localhost:3002
|
||||
# Fill to set socket server URL used for collaboration.
|
||||
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
|
||||
REACT_APP_WS_SERVER_URL=
|
||||
|
||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
||||
|
||||
# put these in your .env.local, or make sure you don't commit!
|
||||
# must be lowercase `true` when turned on
|
||||
#
|
||||
# whether to enable Service Workers in development
|
||||
REACT_APP_DEV_ENABLE_SW=
|
||||
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||
# debugging Service Workers.
|
||||
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||
|
@ -13,5 +13,3 @@ REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","
|
||||
|
||||
# production-only vars
|
||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||
|
||||
REACT_APP_PLUS_APP=https://app.excalidraw.com
|
||||
|
37
.github/dependabot.yml
vendored
Normal file
37
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: sunday
|
||||
time: "01:00"
|
||||
reviewers:
|
||||
- lipis
|
||||
assignees:
|
||||
- lipis
|
||||
open-pull-requests-limit: 20
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/packages/excalidraw/
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: sunday
|
||||
time: "01:00"
|
||||
reviewers:
|
||||
- ad1992
|
||||
assignees:
|
||||
- ad1992
|
||||
open-pull-requests-limit: 20
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/packages/utils/
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: sunday
|
||||
time: "01:00"
|
||||
reviewers:
|
||||
- ad1992
|
||||
assignees:
|
||||
- ad1992
|
||||
open-pull-requests-limit: 20
|
2
.github/workflows/autorelease-excalidraw.yml
vendored
2
.github/workflows/autorelease-excalidraw.yml
vendored
@ -1,4 +1,4 @@
|
||||
name: Auto release excalidraw next
|
||||
name: Auto release @excalidraw/excalidraw-next
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
|
4
.github/workflows/autorelease-preview.yml
vendored
4
.github/workflows/autorelease-preview.yml
vendored
@ -1,4 +1,4 @@
|
||||
name: Auto release excalidraw preview
|
||||
name: Auto release preview @excalidraw/excalidraw-preview
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
@ -6,7 +6,7 @@ on:
|
||||
jobs:
|
||||
Auto-release-excalidraw-preview:
|
||||
name: Auto release preview
|
||||
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
|
||||
if: github.event.comment.body == '@excalibot release package' && github.event.issue.pull_request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: React to release comment
|
||||
|
43
README.md
43
README.md
@ -128,41 +128,14 @@ For collaboration, you will need to set up [collab server](https://github.com/ex
|
||||
|
||||
#### Commands
|
||||
|
||||
##### Install the dependencies
|
||||
|
||||
```
|
||||
yarn
|
||||
```
|
||||
|
||||
##### Run the project
|
||||
|
||||
```
|
||||
yarn start
|
||||
```
|
||||
|
||||
##### Reformat all files with Prettier
|
||||
|
||||
```
|
||||
yarn fix
|
||||
```
|
||||
|
||||
##### Run tests
|
||||
|
||||
```
|
||||
yarn test
|
||||
```
|
||||
|
||||
##### Update test snapshots
|
||||
|
||||
```
|
||||
yarn test:update
|
||||
```
|
||||
|
||||
##### Test for formatting with Prettier
|
||||
|
||||
```
|
||||
yarn test:code
|
||||
```
|
||||
| Command | Description |
|
||||
| ------------------ | --------------------------------- |
|
||||
| `yarn` | Install the dependencies |
|
||||
| `yarn start` | Run the project |
|
||||
| `yarn fix` | Reformat all files with Prettier |
|
||||
| `yarn test` | Run tests |
|
||||
| `yarn test:update` | Update test snapshots |
|
||||
| `yarn test:code` | Test for formatting with Prettier |
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
|
@ -1,11 +1,12 @@
|
||||
rules_version = '2';
|
||||
service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /{files}/rooms/{room}/{file} {
|
||||
allow get, write: if true;
|
||||
}
|
||||
match /{files}/shareLinks/{shareLink}/{file} {
|
||||
allow get, write: if true;
|
||||
match /{migrations} {
|
||||
match /{scenes}/{scene} {
|
||||
allow get, write: if true;
|
||||
// redundant, but let's be explicit'
|
||||
allow list: if false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
24
package.json
24
package.json
@ -22,23 +22,22 @@
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react": "12.1.2",
|
||||
"@tldraw/vec": "1.4.3",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/pica": "5.1.3",
|
||||
"@types/react": "17.0.39",
|
||||
"@types/react-dom": "17.0.11",
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"browser-fs-access": "0.29.1",
|
||||
"browser-fs-access": "0.24.1",
|
||||
"clsx": "1.1.1",
|
||||
"fake-indexeddb": "3.1.7",
|
||||
"firebase": "8.3.3",
|
||||
"i18next-browser-languagedetector": "6.1.2",
|
||||
"idb-keyval": "6.0.3",
|
||||
"image-blob-reduce": "3.0.1",
|
||||
"jotai": "1.6.4",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"nanoid": "3.3.3",
|
||||
"nanoid": "3.1.32",
|
||||
"open-color": "1.9.1",
|
||||
"pako": "1.0.11",
|
||||
"perfect-freehand": "1.0.16",
|
||||
@ -51,7 +50,7 @@
|
||||
"react-dom": "17.0.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"roughjs": "4.5.2",
|
||||
"sass": "1.51.0",
|
||||
"sass": "1.49.7",
|
||||
"socket.io-client": "2.3.1",
|
||||
"typescript": "4.5.5"
|
||||
},
|
||||
@ -64,13 +63,13 @@
|
||||
"@types/resize-observer-browser": "0.1.6",
|
||||
"chai": "4.3.6",
|
||||
"dotenv": "10.0.0",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "2.4.0",
|
||||
"lint-staged": "12.3.7",
|
||||
"jest-canvas-mock": "2.3.1",
|
||||
"lint-staged": "12.3.3",
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.6.2",
|
||||
"prettier": "2.5.1",
|
||||
"rewire": "5.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
@ -94,8 +93,7 @@
|
||||
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
||||
"build:version": "node ./scripts/build-version.js",
|
||||
"build:prebuild": "node ./scripts/prebuild.js",
|
||||
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"eject": "react-scripts eject",
|
||||
"fix:code": "yarn test:code --fix",
|
||||
"fix:other": "yarn prettier --write",
|
||||
@ -113,8 +111,6 @@
|
||||
"test:typecheck": "tsc",
|
||||
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
||||
"test": "yarn test:app",
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease": "node scripts/prerelease.js",
|
||||
"release": "node scripts/release.js"
|
||||
"autorelease": "node scripts/autorelease.js"
|
||||
}
|
||||
}
|
||||
|
@ -52,25 +52,6 @@
|
||||
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
|
||||
<script>
|
||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||
//
|
||||
// Redirect only the bare root path, so link/room/library urls are not
|
||||
// redirected.
|
||||
//
|
||||
// Putting into index.html for best performance (can't redirect on server
|
||||
// due to location.hash checks).
|
||||
if (
|
||||
window.location.pathname === "/" &&
|
||||
!window.location.hash &&
|
||||
!window.location.search &&
|
||||
// if its present redirect
|
||||
document.cookie.includes("excplus-autoredirect=true")
|
||||
) {
|
||||
window.location.href = "https://app.excalidraw.com";
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||
|
||||
<!-- Excalidraw version -->
|
||||
@ -98,22 +79,6 @@
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD === "true") { %>
|
||||
<script>
|
||||
{
|
||||
const _WebSocket = window.WebSocket;
|
||||
window.WebSocket = function (url) {
|
||||
if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
|
||||
console.info(
|
||||
"[!!!] Live reload is disabled via process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
|
||||
);
|
||||
} else {
|
||||
return new _WebSocket(url);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<% } %>
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
// setting this so that libraries installation reuses this window tab.
|
||||
@ -159,6 +124,26 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.LoadingMessage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.LoadingMessage span {
|
||||
background-color: var(--button-gray-1);
|
||||
border-radius: 5px;
|
||||
padding: 0.8em 1.2em;
|
||||
color: var(--popup-text-color);
|
||||
font-size: 1.3em;
|
||||
}
|
||||
#root {
|
||||
height: 100%;
|
||||
-webkit-touch-callout: none;
|
||||
@ -167,10 +152,8 @@
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1200px) {
|
||||
#root {
|
||||
@media screen and (min-width: 1200px) {
|
||||
-webkit-touch-callout: default;
|
||||
-webkit-user-select: auto;
|
||||
-khtml-user-select: auto;
|
||||
@ -187,6 +170,10 @@
|
||||
<header>
|
||||
<h1 class="visually-hidden">Excalidraw</h1>
|
||||
</header>
|
||||
<div id="root"></div>
|
||||
<div id="root">
|
||||
<div class="LoadingMessage">
|
||||
<span>Loading scene...</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -5,25 +5,22 @@ const core = require("@actions/core");
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
const pkg = require(excalidrawPackage);
|
||||
const isPreview = process.argv.slice(2)[0] === "preview";
|
||||
|
||||
const getShortCommitHash = () => {
|
||||
return execSync("git rev-parse --short HEAD").toString().trim();
|
||||
};
|
||||
|
||||
const publish = () => {
|
||||
const tag = isPreview ? "preview" : "next";
|
||||
|
||||
try {
|
||||
execSync(`yarn --frozen-lockfile`);
|
||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||
execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`);
|
||||
console.info(`Published ${pkg.name}@${tag}🎉`);
|
||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||
console.info("Published 🎉");
|
||||
core.setOutput(
|
||||
"result",
|
||||
`**Preview version has been shipped** :rocket:
|
||||
You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
|
||||
You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/v/${pkg.version}) for testing!`,
|
||||
);
|
||||
} catch (error) {
|
||||
core.setOutput("result", "package couldn't be published :warning:!");
|
||||
@ -54,19 +51,27 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||
}
|
||||
|
||||
// update package.json
|
||||
pkg.name = "@excalidraw/excalidraw-next";
|
||||
let version = `${pkg.version}-${getShortCommitHash()}`;
|
||||
|
||||
// update readme
|
||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||
|
||||
const isPreview = process.argv.slice(2)[0] === "preview";
|
||||
if (isPreview) {
|
||||
// use pullNumber-commithash as the version for preview
|
||||
const pullRequestNumber = process.argv.slice(3)[0];
|
||||
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
|
||||
// replace "excalidraw-next" with "excalidraw-preview"
|
||||
pkg.name = "@excalidraw/excalidraw-preview";
|
||||
data = data.replace(/excalidraw-next/g, "excalidraw-preview");
|
||||
data = data.trim();
|
||||
}
|
||||
pkg.version = version;
|
||||
|
||||
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
||||
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
console.info("Publish in progress...");
|
||||
publish();
|
||||
});
|
||||
|
@ -1,20 +0,0 @@
|
||||
const fs = require("fs");
|
||||
|
||||
// for development purposes we want to have the service-worker.js file
|
||||
// accessible from the public folder. On build though, we need to compile it
|
||||
// and CRA expects that file to be in src/ folder.
|
||||
const moveServiceWorkerScript = () => {
|
||||
const oldPath = "./public/service-worker.js";
|
||||
const newPath = "./src/service-worker.js";
|
||||
|
||||
fs.rename(oldPath, newPath, (error) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
console.info("public/service-worker.js moved to src/");
|
||||
});
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
moveServiceWorkerScript();
|
@ -1,37 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const util = require("util");
|
||||
const exec = util.promisify(require("child_process").exec);
|
||||
const updateChangelog = require("./updateChangelog");
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
|
||||
const updatePackageVersion = (nextVersion) => {
|
||||
const pkg = require(excalidrawPackage);
|
||||
pkg.version = nextVersion;
|
||||
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
||||
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
||||
};
|
||||
|
||||
const prerelease = async (nextVersion) => {
|
||||
try {
|
||||
await updateChangelog(nextVersion);
|
||||
updatePackageVersion(nextVersion);
|
||||
await exec(`git add -u`);
|
||||
await exec(
|
||||
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
||||
);
|
||||
|
||||
console.info("Done!");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const nextVersion = process.argv.slice(2)[0];
|
||||
if (!nextVersion) {
|
||||
console.error("Pass the next version to release!");
|
||||
process.exit(1);
|
||||
}
|
||||
prerelease(nextVersion);
|
@ -1,44 +1,39 @@
|
||||
const fs = require("fs");
|
||||
const { execSync } = require("child_process");
|
||||
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 pkg = require(excalidrawPackage);
|
||||
|
||||
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8");
|
||||
|
||||
const updateReadme = () => {
|
||||
const excalidrawIndex = originalReadMe.indexOf("### Excalidraw");
|
||||
|
||||
// remove note for stable readme
|
||||
const data = originalReadMe.slice(excalidrawIndex);
|
||||
|
||||
// update readme
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
const updatePackageVersion = (nextVersion) => {
|
||||
const pkg = require(excalidrawPackage);
|
||||
pkg.version = nextVersion;
|
||||
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
||||
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
||||
};
|
||||
|
||||
const publish = () => {
|
||||
const release = async (nextVersion) => {
|
||||
try {
|
||||
execSync(`yarn --frozen-lockfile`);
|
||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||
updateReadme();
|
||||
await updateChangelog(nextVersion);
|
||||
updatePackageVersion(nextVersion);
|
||||
await exec(`git add -u`);
|
||||
await exec(
|
||||
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
||||
);
|
||||
/* eslint-disable no-console */
|
||||
console.log("Done!");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const release = () => {
|
||||
updateReadme();
|
||||
console.info("Note for stable readme removed");
|
||||
|
||||
publish();
|
||||
console.info(`Published ${pkg.version}!`);
|
||||
|
||||
// revert readme after release
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
|
||||
console.info("Readme reverted");
|
||||
};
|
||||
|
||||
release();
|
||||
const nextVersion = process.argv.slice(2)[0];
|
||||
if (!nextVersion) {
|
||||
console.error("Pass the next version to release!");
|
||||
process.exit(1);
|
||||
}
|
||||
release(nextVersion);
|
||||
|
27
scripts/updateReadme.js
Normal file
27
scripts/updateReadme.js
Normal file
@ -0,0 +1,27 @@
|
||||
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;
|
@ -7,7 +7,6 @@ import { t } from "../i18n";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
@ -25,9 +24,9 @@ export const actionAddToLibrary = register({
|
||||
}
|
||||
|
||||
return app.library
|
||||
.getLatestLibrary()
|
||||
.loadLibrary()
|
||||
.then((items) => {
|
||||
return app.library.setLibrary([
|
||||
return app.library.saveLibrary([
|
||||
{
|
||||
id: randomId(),
|
||||
status: "unpublished",
|
||||
|
@ -43,7 +43,6 @@ const alignSelectedElements = (
|
||||
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -73,7 +72,6 @@ export const actionAlignTop = register({
|
||||
|
||||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -103,7 +101,6 @@ export const actionAlignBottom = register({
|
||||
|
||||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -133,8 +130,6 @@ export const actionAlignLeft = register({
|
||||
|
||||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -164,8 +159,6 @@ export const actionAlignRight = register({
|
||||
|
||||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -191,7 +184,6 @@ export const actionAlignVerticallyCentered = register({
|
||||
|
||||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
@ -1,136 +0,0 @@
|
||||
import { VERTICAL_ALIGN } from "../constants";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
measureText,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isTextBindableContainer,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
trackEvent: { category: "element" },
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return selectedElements.some((element) => hasBoundTextElement(element));
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const { width, height, baseline } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
);
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
text: boundTextElement.originalText,
|
||||
});
|
||||
mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionBindText = register({
|
||||
name: "bindText",
|
||||
contextItemLabel: "labels.bindText",
|
||||
trackEvent: { category: "element" },
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
if (selectedElements.length === 2) {
|
||||
const textElement =
|
||||
isTextElement(selectedElements[0]) ||
|
||||
isTextElement(selectedElements[1]);
|
||||
|
||||
let bindingContainer;
|
||||
if (isTextBindableContainer(selectedElements[0])) {
|
||||
bindingContainer = selectedElements[0];
|
||||
} else if (isTextBindableContainer(selectedElements[1])) {
|
||||
bindingContainer = selectedElements[1];
|
||||
}
|
||||
if (
|
||||
textElement &&
|
||||
bindingContainer &&
|
||||
getBoundTextElement(bindingContainer) === null
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
let textElement: ExcalidrawTextElement;
|
||||
let container: ExcalidrawTextContainer;
|
||||
|
||||
if (
|
||||
isTextElement(selectedElements[0]) &&
|
||||
isTextBindableContainer(selectedElements[1])
|
||||
) {
|
||||
textElement = selectedElements[0];
|
||||
container = selectedElements[1];
|
||||
} else {
|
||||
textElement = selectedElements[1] as ExcalidrawTextElement;
|
||||
container = selectedElements[0] as ExcalidrawTextContainer;
|
||||
}
|
||||
mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
});
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
const updatedElements = elements.slice();
|
||||
const textElementIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === textElement.id,
|
||||
);
|
||||
updatedElements.splice(textElementIndex, 1);
|
||||
const containerIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === container.id,
|
||||
);
|
||||
updatedElements.splice(containerIndex + 1, 0, textElement);
|
||||
return {
|
||||
elements: updatedElements,
|
||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
@ -1,5 +1,5 @@
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { eraser, zoomIn, zoomOut } from "../components/icons";
|
||||
import { zoomIn, zoomOut } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { THEME, ZOOM_STEP } from "../constants";
|
||||
@ -11,17 +11,15 @@ import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getDefaultAppState, isEraserActive } from "../appState";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import ClearCanvas from "../components/ClearCanvas";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
trackEvent: false,
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, ...value },
|
||||
@ -51,7 +49,6 @@ export const actionChangeViewBackgroundColor = register({
|
||||
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.imageCache.clear();
|
||||
return {
|
||||
@ -62,6 +59,7 @@ export const actionClearCanvas = register({
|
||||
...getDefaultAppState(),
|
||||
files: {},
|
||||
theme: appState.theme,
|
||||
elementLocked: appState.elementLocked,
|
||||
penMode: appState.penMode,
|
||||
penDetected: appState.penDetected,
|
||||
exportBackground: appState.exportBackground,
|
||||
@ -69,10 +67,8 @@ export const actionClearCanvas = register({
|
||||
gridSize: appState.gridSize,
|
||||
showStats: appState.showStats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? { ...appState.activeTool, type: "selection" }
|
||||
: appState.activeTool,
|
||||
elementType:
|
||||
appState.elementType === "image" ? "selection" : appState.elementType,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
@ -83,7 +79,6 @@ export const actionClearCanvas = register({
|
||||
|
||||
export const actionZoomIn = register({
|
||||
name: "zoomIn",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
@ -119,7 +114,6 @@ export const actionZoomIn = register({
|
||||
|
||||
export const actionZoomOut = register({
|
||||
name: "zoomOut",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
@ -155,7 +149,6 @@ export const actionZoomOut = register({
|
||||
|
||||
export const actionResetZoom = register({
|
||||
name: "resetZoom",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
@ -254,7 +247,6 @@ const zoomToFitElements = (
|
||||
|
||||
export const actionZoomToSelected = register({
|
||||
name: "zoomToSelection",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.TWO &&
|
||||
@ -265,7 +257,6 @@ export const actionZoomToSelected = register({
|
||||
|
||||
export const actionZoomToFit = register({
|
||||
name: "zoomToFit",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.ONE &&
|
||||
@ -276,7 +267,6 @@ export const actionZoomToFit = register({
|
||||
|
||||
export const actionToggleTheme = register({
|
||||
name: "toggleTheme",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: {
|
||||
@ -299,49 +289,3 @@ export const actionToggleTheme = register({
|
||||
),
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
});
|
||||
|
||||
export const actionErase = register({
|
||||
name: "eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "eraser",
|
||||
lastActiveToolBeforeEraser: appState.activeTool,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.E,
|
||||
PanelComponent: ({ elements, appState, updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={eraser}
|
||||
className={clsx("eraser", { active: isEraserActive(appState) })}
|
||||
title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
|
||||
aria-label={t("toolBar.eraser")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size={data?.size || "medium"}
|
||||
></ToolButton>
|
||||
),
|
||||
});
|
||||
|
@ -1,23 +1,16 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
probablySupportsClipboardWriteText,
|
||||
} from "../clipboard";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { getSelectedElements } from "../scene/selection";
|
||||
import { exportCanvas } from "../data/index";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
|
||||
copyToClipboard(selectedElements, appState, app.files);
|
||||
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
@ -30,7 +23,6 @@ export const actionCopy = register({
|
||||
|
||||
export const actionCut = register({
|
||||
name: "cut",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, data, app) => {
|
||||
actionCopy.perform(elements, appState, data, app);
|
||||
return actionDeleteSelected.perform(elements, appState);
|
||||
@ -41,7 +33,6 @@ export const actionCut = register({
|
||||
|
||||
export const actionCopyAsSvg = register({
|
||||
name: "copyAsSvg",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
@ -82,7 +73,6 @@ export const actionCopyAsSvg = register({
|
||||
|
||||
export const actionCopyAsPng = register({
|
||||
name: "copyAsPng",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
@ -132,35 +122,3 @@ export const actionCopyAsPng = register({
|
||||
contextItemLabel: "labels.copyAsPng",
|
||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||
});
|
||||
|
||||
export const copyText = register({
|
||||
name: "copyText",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
|
||||
const text = selectedElements
|
||||
.reduce((acc: string[], element) => {
|
||||
if (isTextElement(element)) {
|
||||
acc.push(element.text);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.join("\n\n");
|
||||
copyTextToSystemClipboard(text);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
return (
|
||||
probablySupportsClipboardWriteText &&
|
||||
getSelectedElements(elements, appState, true).some(isTextElement)
|
||||
);
|
||||
},
|
||||
contextItemLabel: "labels.copyText",
|
||||
});
|
||||
|
@ -12,7 +12,6 @@ import { getElementsInGroup } from "../groups";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import { updateActiveTool } from "../utils";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -59,7 +58,6 @@ const handleGroupEditingState = (
|
||||
|
||||
export const actionDeleteSelected = register({
|
||||
name: "deleteSelectedElements",
|
||||
trackEvent: { category: "element", action: "delete" },
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
@ -135,7 +133,7 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||
elementType: "selection",
|
||||
multiElement: null,
|
||||
},
|
||||
commitToHistory: isSomeElementSelected(
|
||||
|
@ -3,7 +3,7 @@ import {
|
||||
DistributeVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { distributeElements, Distribution } from "../distribute";
|
||||
import { distributeElements, Distribution } from "../disitrubte";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
@ -39,7 +39,6 @@ const distributeSelectedElements = (
|
||||
|
||||
export const distributeHorizontally = register({
|
||||
name: "distributeHorizontally",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -69,7 +68,6 @@ export const distributeHorizontally = register({
|
||||
|
||||
export const distributeVertically = register({
|
||||
name: "distributeVertically",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
@ -22,7 +22,6 @@ import { isBoundToContainer } from "../element/typeChecks";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { trackEvent } from "../analytics";
|
||||
import { load, questionCircle, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
@ -7,7 +8,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { resaveAsImageWithScene } from "../data/resave";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
@ -22,8 +23,8 @@ import { Theme } from "../element/types";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
trackEvent: false,
|
||||
perform: (_elements, appState, value) => {
|
||||
trackEvent("change", "title");
|
||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, appProps }) => (
|
||||
@ -40,7 +41,6 @@ export const actionChangeProjectName = register({
|
||||
|
||||
export const actionChangeExportScale = register({
|
||||
name: "changeExportScale",
|
||||
trackEvent: { category: "export", action: "scale" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportScale: value },
|
||||
@ -89,7 +89,6 @@ export const actionChangeExportScale = register({
|
||||
|
||||
export const actionChangeExportBackground = register({
|
||||
name: "changeExportBackground",
|
||||
trackEvent: { category: "export", action: "toggleBackground" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportBackground: value },
|
||||
@ -108,7 +107,6 @@ export const actionChangeExportBackground = register({
|
||||
|
||||
export const actionChangeExportEmbedScene = register({
|
||||
name: "changeExportEmbedScene",
|
||||
trackEvent: { category: "export", action: "embedScene" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportEmbedScene: value },
|
||||
@ -130,7 +128,6 @@ export const actionChangeExportEmbedScene = register({
|
||||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
|
||||
@ -175,7 +172,6 @@ export const actionSaveToActiveFile = register({
|
||||
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(
|
||||
@ -204,7 +200,7 @@ export const actionSaveFileToDisk = register({
|
||||
icon={saveAs}
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
showAriaLabel={useIsMobile()}
|
||||
hidden={!nativeFileSystemSupported}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-as-button"
|
||||
@ -214,7 +210,6 @@ export const actionSaveFileToDisk = register({
|
||||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, _, app) => {
|
||||
try {
|
||||
const {
|
||||
@ -248,7 +243,7 @@ export const actionLoadScene = register({
|
||||
icon={load}
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={updateData}
|
||||
data-testid="load-button"
|
||||
/>
|
||||
@ -257,7 +252,6 @@ export const actionLoadScene = register({
|
||||
|
||||
export const actionExportWithDarkMode = register({
|
||||
name: "exportWithDarkMode",
|
||||
trackEvent: { category: "export", action: "toggleTheme" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportWithDarkMode: value },
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { isInvisiblySmallElement } from "../element";
|
||||
import { updateActiveTool, resetCursor } from "../utils";
|
||||
import { resetCursor } from "../utils";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { done } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
@ -14,12 +14,10 @@ import {
|
||||
bindOrUnbindLinearElement,
|
||||
} from "../element/binding";
|
||||
import { isBindingElement } from "../element/typeChecks";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
|
||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
@ -40,7 +38,6 @@ export const actionFinalize = register({
|
||||
: undefined,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
@ -50,12 +47,8 @@ export const actionFinalize = register({
|
||||
|
||||
let newElements = elements;
|
||||
|
||||
const pendingImageElement =
|
||||
appState.pendingImageElementId &&
|
||||
scene.getElement(appState.pendingImageElementId);
|
||||
|
||||
if (pendingImageElement) {
|
||||
mutateElement(pendingImageElement, { isDeleted: true }, false);
|
||||
if (appState.pendingImageElement) {
|
||||
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
|
||||
}
|
||||
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
@ -126,47 +119,27 @@ export const actionFinalize = register({
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
) {
|
||||
if (!appState.elementLocked && appState.elementType !== "freedraw") {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw") ||
|
||||
(!appState.elementLocked && appState.elementType !== "freedraw") ||
|
||||
!multiPointElement
|
||||
) {
|
||||
resetCursor(canvas);
|
||||
}
|
||||
|
||||
let activeTool: AppState["activeTool"];
|
||||
if (appState.activeTool.type === "eraser") {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "selection",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
elements: newElements,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
activeTool:
|
||||
(appState.activeTool.locked ||
|
||||
appState.activeTool.type === "freedraw") &&
|
||||
elementType:
|
||||
(appState.elementLocked || appState.elementType === "freedraw") &&
|
||||
multiPointElement
|
||||
? appState.activeTool
|
||||
: activeTool,
|
||||
? appState.elementType
|
||||
: "selection",
|
||||
draggingElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
@ -174,16 +147,16 @@ export const actionFinalize = register({
|
||||
suggestedBindings: [],
|
||||
selectedElementIds:
|
||||
multiPointElement &&
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
!appState.elementLocked &&
|
||||
appState.elementType !== "freedraw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[multiPointElement.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
pendingImageElementId: null,
|
||||
pendingImageElement: null,
|
||||
},
|
||||
commitToHistory: appState.activeTool.type === "freedraw",
|
||||
commitToHistory: appState.elementType === "freedraw",
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
@ -192,7 +165,7 @@ export const actionFinalize = register({
|
||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
PanelComponent: ({ appState, updateData, data }) => (
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={done}
|
||||
@ -200,7 +173,6 @@ export const actionFinalize = register({
|
||||
aria-label={t("buttons.done")}
|
||||
onClick={updateData}
|
||||
visible={appState.multiElement != null}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
@ -35,7 +35,6 @@ const enableActionFlipVertical = (
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "horizontal"),
|
||||
@ -51,7 +50,6 @@ export const actionFlipHorizontal = register({
|
||||
|
||||
export const actionFlipVertical = register({
|
||||
name: "flipVertical",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "vertical"),
|
||||
|
@ -54,7 +54,6 @@ const enableActionGroup = (
|
||||
|
||||
export const actionGroup = register({
|
||||
name: "group",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
@ -148,7 +147,6 @@ export const actionGroup = register({
|
||||
|
||||
export const actionUngroup = register({
|
||||
name: "ungroup",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const groupIds = getSelectedGroupIds(appState);
|
||||
if (groupIds.length === 0) {
|
||||
|
@ -62,7 +62,6 @@ type ActionCreator = (history: History) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
name: "undo",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.undoOnce()),
|
||||
keyTest: (event) =>
|
||||
@ -83,7 +82,6 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
|
||||
export const createRedoAction: ActionCreator = (history) => ({
|
||||
name: "redo",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.redoOnce()),
|
||||
keyTest: (event) =>
|
||||
|
@ -9,7 +9,6 @@ import { HelpIcon } from "../components/HelpIcon";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
@ -30,7 +29,6 @@ export const actionToggleCanvasMenu = register({
|
||||
|
||||
export const actionToggleEditMenu = register({
|
||||
name: "toggleEditMenu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_elements, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
@ -55,7 +53,6 @@ export const actionToggleEditMenu = register({
|
||||
|
||||
export const actionFullScreen = register({
|
||||
name: "toggleFullScreen",
|
||||
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
|
||||
perform: () => {
|
||||
if (!isFullScreen()) {
|
||||
allowFullScreen();
|
||||
@ -72,7 +69,6 @@ export const actionFullScreen = register({
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||
perform: (_elements, appState, _, { focusContainer }) => {
|
||||
if (appState.showHelpDialog) {
|
||||
focusContainer();
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { getClientColors } from "../clients";
|
||||
import { getClientColors, getClientInitials } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { Collaborator } from "../types";
|
||||
@ -6,7 +6,6 @@ import { register } from "./register";
|
||||
|
||||
export const actionGoToCollaborator = register({
|
||||
name: "goToCollaborator",
|
||||
trackEvent: { category: "collab" },
|
||||
perform: (_elements, appState, value) => {
|
||||
const point = value as Collaborator["pointer"];
|
||||
if (!point) {
|
||||
@ -31,18 +30,28 @@ export const actionGoToCollaborator = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, data }) => {
|
||||
const [clientId, collaborator] = data as [string, Collaborator];
|
||||
const clientId: string | undefined = data?.id;
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const collaborator = appState.collaborators.get(clientId);
|
||||
|
||||
if (!collaborator) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { background, stroke } = getClientColors(clientId, appState);
|
||||
const shortName = getClientInitials(collaborator.username);
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
color={background}
|
||||
border={stroke}
|
||||
onClick={() => updateData(collaborator.pointer)}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
/>
|
||||
>
|
||||
{shortName}
|
||||
</Avatar>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
@ -166,7 +166,11 @@ const changeFontSize = (
|
||||
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
||||
fontSize: newFontSize,
|
||||
});
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
);
|
||||
|
||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||
|
||||
@ -194,7 +198,6 @@ const changeFontSize = (
|
||||
|
||||
export const actionChangeStrokeColor = register({
|
||||
name: "changeStrokeColor",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
...(value.currentItemStrokeColor && {
|
||||
@ -244,7 +247,6 @@ export const actionChangeStrokeColor = register({
|
||||
|
||||
export const actionChangeBackgroundColor = register({
|
||||
name: "changeBackgroundColor",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
...(value.currentItemBackgroundColor && {
|
||||
@ -287,7 +289,6 @@ export const actionChangeBackgroundColor = register({
|
||||
|
||||
export const actionChangeFillStyle = register({
|
||||
name: "changeFillStyle",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@ -337,7 +338,6 @@ export const actionChangeFillStyle = register({
|
||||
|
||||
export const actionChangeStrokeWidth = register({
|
||||
name: "changeStrokeWidth",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@ -385,7 +385,6 @@ export const actionChangeStrokeWidth = register({
|
||||
|
||||
export const actionChangeSloppiness = register({
|
||||
name: "changeSloppiness",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@ -434,7 +433,6 @@ export const actionChangeSloppiness = register({
|
||||
|
||||
export const actionChangeStrokeStyle = register({
|
||||
name: "changeStrokeStyle",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@ -482,17 +480,12 @@ export const actionChangeStrokeStyle = register({
|
||||
|
||||
export const actionChangeOpacity = register({
|
||||
name: "changeOpacity",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
elements,
|
||||
appState,
|
||||
(el) =>
|
||||
newElementWith(el, {
|
||||
opacity: value,
|
||||
}),
|
||||
true,
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
opacity: value,
|
||||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemOpacity: value },
|
||||
commitToHistory: true,
|
||||
@ -507,6 +500,20 @@ export const actionChangeOpacity = register({
|
||||
max="100"
|
||||
step="10"
|
||||
onChange={(event) => updateData(+event.target.value)}
|
||||
onWheel={(event) => {
|
||||
event.stopPropagation();
|
||||
const target = event.target as HTMLInputElement;
|
||||
const STEP = 10;
|
||||
const MAX = 100;
|
||||
const MIN = 0;
|
||||
const value = +target.value;
|
||||
|
||||
if (event.deltaY < 0 && value < MAX) {
|
||||
updateData(value + STEP);
|
||||
} else if (event.deltaY > 0 && value > MIN) {
|
||||
updateData(value - STEP);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
getFormValue(
|
||||
elements,
|
||||
@ -522,7 +529,6 @@ export const actionChangeOpacity = register({
|
||||
|
||||
export const actionChangeFontSize = register({
|
||||
name: "changeFontSize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return changeFontSize(elements, appState, () => value, value);
|
||||
},
|
||||
@ -580,7 +586,6 @@ export const actionChangeFontSize = register({
|
||||
|
||||
export const actionDecreaseFontSize = register({
|
||||
name: "decreaseFontSize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return changeFontSize(elements, appState, (element) =>
|
||||
Math.round(
|
||||
@ -602,7 +607,6 @@ export const actionDecreaseFontSize = register({
|
||||
|
||||
export const actionIncreaseFontSize = register({
|
||||
name: "increaseFontSize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return changeFontSize(elements, appState, (element) =>
|
||||
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
|
||||
@ -620,7 +624,6 @@ export const actionIncreaseFontSize = register({
|
||||
|
||||
export const actionChangeFontFamily = register({
|
||||
name: "changeFontFamily",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
@ -634,7 +637,11 @@ export const actionChangeFontFamily = register({
|
||||
fontFamily: value,
|
||||
},
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
|
||||
@ -702,7 +709,6 @@ export const actionChangeFontFamily = register({
|
||||
|
||||
export const actionChangeTextAlign = register({
|
||||
name: "changeTextAlign",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
@ -714,7 +720,11 @@ export const actionChangeTextAlign = register({
|
||||
oldElement,
|
||||
{ textAlign: value },
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
|
||||
@ -775,7 +785,6 @@ export const actionChangeTextAlign = register({
|
||||
});
|
||||
export const actionChangeVerticalAlign = register({
|
||||
name: "changeVerticalAlign",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
@ -788,7 +797,11 @@ export const actionChangeVerticalAlign = register({
|
||||
{ verticalAlign: value },
|
||||
);
|
||||
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
|
||||
@ -843,7 +856,6 @@ export const actionChangeVerticalAlign = register({
|
||||
|
||||
export const actionChangeSharpness = register({
|
||||
name: "changeSharpness",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
@ -851,10 +863,10 @@ export const actionChangeSharpness = register({
|
||||
);
|
||||
const shouldUpdateForNonLinearElements = targetElements.length
|
||||
? targetElements.every((el) => !isLinearElement(el))
|
||||
: !isLinearElementType(appState.activeTool.type);
|
||||
: !isLinearElementType(appState.elementType);
|
||||
const shouldUpdateForLinearElements = targetElements.length
|
||||
? targetElements.every(isLinearElement)
|
||||
: isLinearElementType(appState.activeTool.type);
|
||||
: isLinearElementType(appState.elementType);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@ -894,8 +906,8 @@ export const actionChangeSharpness = register({
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.strokeSharpness,
|
||||
(canChangeSharpness(appState.activeTool.type) &&
|
||||
(isLinearElementType(appState.activeTool.type)
|
||||
(canChangeSharpness(appState.elementType) &&
|
||||
(isLinearElementType(appState.elementType)
|
||||
? appState.currentItemLinearStrokeSharpness
|
||||
: appState.currentItemStrokeSharpness)) ||
|
||||
null,
|
||||
@ -908,7 +920,6 @@ export const actionChangeSharpness = register({
|
||||
|
||||
export const actionChangeArrowhead = register({
|
||||
name: "changeArrowhead",
|
||||
trackEvent: false,
|
||||
perform: (
|
||||
elements,
|
||||
appState,
|
||||
|
@ -5,7 +5,6 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
@ -18,8 +17,7 @@ export const actionSelectAll = register({
|
||||
selectedElementIds: elements.reduce((map, element) => {
|
||||
if (
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
element.locked === false
|
||||
!(isTextElement(element) && element.containerId)
|
||||
) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
|
@ -48,7 +48,7 @@ describe("actionStyles", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
||||
Keyboard.codeDown(CODES.C);
|
||||
});
|
||||
const secondRect = JSON.parse(copiedStyles)[0];
|
||||
const secondRect = JSON.parse(copiedStyles);
|
||||
expect(secondRect.id).toBe(h.elements[1].id);
|
||||
|
||||
mouse.reset();
|
||||
|
@ -6,32 +6,23 @@ import {
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import { hasBoundTextElement } from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getContainerElement } from "../element/textElement";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
|
||||
export const actionCopyStyles = register({
|
||||
name: "copyStyles",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const elementsCopied = [];
|
||||
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
||||
elementsCopied.push(element);
|
||||
if (element && hasBoundTextElement(element)) {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
elementsCopied.push(boundTextElement);
|
||||
}
|
||||
if (element) {
|
||||
copiedStyles = JSON.stringify(elementsCopied);
|
||||
copiedStyles = JSON.stringify(element);
|
||||
}
|
||||
return {
|
||||
appState: {
|
||||
@ -48,64 +39,36 @@ export const actionCopyStyles = register({
|
||||
|
||||
export const actionPasteStyles = register({
|
||||
name: "pasteStyles",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const elementsCopied = JSON.parse(copiedStyles);
|
||||
const pastedElement = elementsCopied[0];
|
||||
const boundTextElement = elementsCopied[1];
|
||||
const pastedElement = JSON.parse(copiedStyles);
|
||||
if (!isExcalidrawElement(pastedElement)) {
|
||||
return { elements, commitToHistory: false };
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const selectedElementIds = selectedElements.map((element) => element.id);
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (selectedElementIds.includes(element.id)) {
|
||||
let elementStylesToCopyFrom = pastedElement;
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
elementStylesToCopyFrom = boundTextElement;
|
||||
}
|
||||
if (!elementStylesToCopyFrom) {
|
||||
return element;
|
||||
}
|
||||
let newElement = newElementWith(element, {
|
||||
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
|
||||
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
|
||||
strokeColor: elementStylesToCopyFrom?.strokeColor,
|
||||
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
|
||||
fillStyle: elementStylesToCopyFrom?.fillStyle,
|
||||
opacity: elementStylesToCopyFrom?.opacity,
|
||||
roughness: elementStylesToCopyFrom?.roughness,
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
const newElement = newElementWith(element, {
|
||||
backgroundColor: pastedElement?.backgroundColor,
|
||||
strokeWidth: pastedElement?.strokeWidth,
|
||||
strokeColor: pastedElement?.strokeColor,
|
||||
strokeStyle: pastedElement?.strokeStyle,
|
||||
fillStyle: pastedElement?.fillStyle,
|
||||
opacity: pastedElement?.opacity,
|
||||
roughness: pastedElement?.roughness,
|
||||
});
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
newElement = newElementWith(newElement, {
|
||||
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
|
||||
fontFamily:
|
||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||
textAlign:
|
||||
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
if (isTextElement(newElement) && isTextElement(element)) {
|
||||
mutateElement(newElement, {
|
||||
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
||||
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
});
|
||||
let container = null;
|
||||
if (newElement.containerId) {
|
||||
container =
|
||||
selectedElements.find(
|
||||
(element) =>
|
||||
isTextElement(newElement) &&
|
||||
element.id === newElement.containerId,
|
||||
) || null;
|
||||
}
|
||||
redrawTextBoundingBox(newElement, container);
|
||||
}
|
||||
|
||||
if (newElement.type === "arrow") {
|
||||
newElement = newElementWith(newElement, {
|
||||
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
||||
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
||||
});
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(newElement),
|
||||
appState,
|
||||
);
|
||||
}
|
||||
|
||||
return newElement;
|
||||
}
|
||||
return element;
|
||||
|
@ -2,14 +2,12 @@ import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleGridMode = register({
|
||||
name: "gridMode",
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.gridSize,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
trackEvent("view", "mode", "grid");
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
@ -1,63 +0,0 @@
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleLock = register({
|
||||
name: "toggleLock",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
|
||||
if (!selectedElements.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const operation = getOperation(selectedElements);
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return newElementWith(element, { locked: operation === "lock" });
|
||||
}),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState) => {
|
||||
const selected = getSelectedElements(elements, appState, false);
|
||||
if (selected.length === 1) {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
}
|
||||
|
||||
if (selected.length > 1) {
|
||||
return getOperation(selected) === "lock"
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unexpected zero elements to lock/unlock. This should never happen.",
|
||||
);
|
||||
},
|
||||
keyTest: (event, appState, elements) => {
|
||||
return (
|
||||
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
getSelectedElements(elements, appState, false).length > 0
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const getOperation = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
|
@ -3,7 +3,6 @@ import { CODES, KEYS } from "../keys";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
trackEvent: { category: "menu" },
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
|
@ -1,13 +1,11 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleViewMode = register({
|
||||
name: "viewMode",
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.viewModeEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
trackEvent("view", "mode", "view");
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
@ -1,13 +1,12 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleZenMode = register({
|
||||
name: "zenMode",
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.zenModeEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
trackEvent("view", "mode", "zen");
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
44
src/actions/actionUnbindText.tsx
Normal file
44
src/actions/actionUnbindText.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { getBoundTextElement, measureText } from "../element/textElement";
|
||||
import { ExcalidrawTextElement } from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const { width, height, baseline } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
);
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
text: boundTextElement.originalText,
|
||||
});
|
||||
mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
@ -18,7 +18,6 @@ import {
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveOneLeft(elements, appState),
|
||||
@ -46,7 +45,6 @@ export const actionSendBackward = register({
|
||||
|
||||
export const actionBringForward = register({
|
||||
name: "bringForward",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveOneRight(elements, appState),
|
||||
@ -74,7 +72,6 @@ export const actionBringForward = register({
|
||||
|
||||
export const actionSendToBack = register({
|
||||
name: "sendToBack",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveAllLeft(elements, appState),
|
||||
@ -109,8 +106,6 @@ export const actionSendToBack = register({
|
||||
|
||||
export const actionBringToFront = register({
|
||||
name: "bringToFront",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveAllRight(elements, appState),
|
||||
|
@ -75,13 +75,11 @@ export {
|
||||
actionCut,
|
||||
actionCopyAsPng,
|
||||
actionCopyAsSvg,
|
||||
copyText,
|
||||
} from "./actionClipboard";
|
||||
|
||||
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionUnbindText } from "./actionUnbindText";
|
||||
export { actionLink } from "../element/Hyperlink";
|
||||
export { actionToggleLock } from "./actionToggleLock";
|
||||
|
@ -1,11 +1,11 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Action,
|
||||
ActionsManagerInterface,
|
||||
UpdaterFn,
|
||||
ActionName,
|
||||
ActionResult,
|
||||
PanelComponentProps,
|
||||
ActionSource,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
@ -14,25 +14,21 @@ import { trackEvent } from "../analytics";
|
||||
|
||||
const trackAction = (
|
||||
action: Action,
|
||||
source: ActionSource,
|
||||
appState: Readonly<AppState>,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
source: "ui" | "keyboard" | "api",
|
||||
value: any,
|
||||
) => {
|
||||
if (action.trackEvent) {
|
||||
if (action.trackEvent !== false) {
|
||||
try {
|
||||
if (typeof action.trackEvent === "object") {
|
||||
const shouldTrack = action.trackEvent.predicate
|
||||
? action.trackEvent.predicate(appState, elements, value)
|
||||
: true;
|
||||
if (shouldTrack) {
|
||||
trackEvent(
|
||||
action.trackEvent.category,
|
||||
action.trackEvent.action || action.name,
|
||||
`${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
if (action.trackEvent === true) {
|
||||
trackEvent(
|
||||
action.name,
|
||||
source,
|
||||
typeof value === "number" || typeof value === "string"
|
||||
? String(value)
|
||||
: undefined,
|
||||
);
|
||||
} else {
|
||||
action.trackEvent?.(action, source, value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error while logging action:", error);
|
||||
@ -40,8 +36,8 @@ const trackAction = (
|
||||
}
|
||||
};
|
||||
|
||||
export class ActionManager {
|
||||
actions = {} as Record<ActionName, Action>;
|
||||
export class ActionManager implements ActionsManagerInterface {
|
||||
actions = {} as ActionsManagerInterface["actions"];
|
||||
|
||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||
|
||||
@ -110,26 +106,30 @@ export class ActionManager {
|
||||
}
|
||||
}
|
||||
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const value = null;
|
||||
|
||||
trackAction(action, "keyboard", appState, elements, this.app, null);
|
||||
trackAction(action, "keyboard", null);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.updater(data[0].perform(elements, appState, value, this.app));
|
||||
this.updater(
|
||||
data[0].perform(
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
null,
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
executeAction(action: Action, source: ActionSource = "api") {
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const value = null;
|
||||
|
||||
trackAction(action, source, appState, elements, this.app, value);
|
||||
|
||||
this.updater(action.perform(elements, appState, value, this.app));
|
||||
executeAction(action: Action) {
|
||||
this.updater(
|
||||
action.perform(
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
null,
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
trackAction(action, "api", null);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -147,11 +147,7 @@ export class ActionManager {
|
||||
) {
|
||||
const action = this.actions[name];
|
||||
const PanelComponent = action.PanelComponent!;
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const updateData = (formState?: any) => {
|
||||
trackAction(action, "ui", appState, elements, this.app, formState);
|
||||
|
||||
this.updater(
|
||||
action.perform(
|
||||
this.getElementsIncludingDeleted(),
|
||||
@ -160,6 +156,8 @@ export class ActionManager {
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
|
||||
trackAction(action, "ui", formState);
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -29,7 +29,6 @@ export type ShortcutName = SubtypeOf<
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "hyperlink"
|
||||
| "toggleLock"
|
||||
>;
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
@ -68,7 +67,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
flipVertical: [getShortcutKey("Shift+V")],
|
||||
viewMode: [getShortcutKey("Alt+R")],
|
||||
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
|
||||
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||
|
@ -6,8 +6,7 @@ import {
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
} from "../types";
|
||||
|
||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
||||
import { ToolButtonSize } from "../components/ToolButton";
|
||||
|
||||
/** if false, the action should be prevented */
|
||||
export type ActionResult =
|
||||
@ -40,7 +39,6 @@ export type ActionName =
|
||||
| "paste"
|
||||
| "copyAsPng"
|
||||
| "copyAsSvg"
|
||||
| "copyText"
|
||||
| "sendBackward"
|
||||
| "bringForward"
|
||||
| "sendToBack"
|
||||
@ -108,17 +106,14 @@ export type ActionName =
|
||||
| "increaseFontSize"
|
||||
| "decreaseFontSize"
|
||||
| "unbindText"
|
||||
| "hyperlink"
|
||||
| "eraser"
|
||||
| "bindText"
|
||||
| "toggleLock";
|
||||
| "hyperlink";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
updateData: (formData?: any) => void;
|
||||
appProps: ExcalidrawProps;
|
||||
data?: Record<string, any>;
|
||||
data?: Partial<{ id: string; size: ToolButtonSize }>;
|
||||
};
|
||||
|
||||
export interface Action {
|
||||
@ -142,23 +137,15 @@ export interface Action {
|
||||
appState: AppState,
|
||||
) => boolean;
|
||||
checked?: (appState: Readonly<AppState>) => boolean;
|
||||
trackEvent:
|
||||
| false
|
||||
| {
|
||||
category:
|
||||
| "toolbar"
|
||||
| "element"
|
||||
| "canvas"
|
||||
| "export"
|
||||
| "history"
|
||||
| "menu"
|
||||
| "collab"
|
||||
| "hyperlink";
|
||||
action?: string;
|
||||
predicate?: (
|
||||
appState: Readonly<AppState>,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
value: any,
|
||||
) => boolean;
|
||||
};
|
||||
trackEvent?:
|
||||
| boolean
|
||||
| ((action: Action, type: "ui" | "keyboard" | "api", value: any) => void);
|
||||
}
|
||||
|
||||
export interface ActionsManagerInterface {
|
||||
actions: Record<ActionName, Action>;
|
||||
registerAction: (action: Action) => void;
|
||||
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
|
||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||
executeAction: (action: Action) => void;
|
||||
}
|
||||
|
@ -4,19 +4,15 @@ export const trackEvent =
|
||||
typeof window !== "undefined" &&
|
||||
window.gtag
|
||||
? (category: string, action: string, label?: string, value?: number) => {
|
||||
try {
|
||||
window.gtag("event", action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("error logging to ga", error);
|
||||
}
|
||||
window.gtag("event", action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
}
|
||||
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
||||
? (category: string, action: string, label?: string, value?: number) => {}
|
||||
: (category: string, action: string, label?: string, value?: number) => {
|
||||
// Uncomment the next line to track locally
|
||||
// console.log("Track Event", { category, action, label, value });
|
||||
// console.info("Track Event", category, action, label, value);
|
||||
};
|
||||
|
@ -41,12 +41,8 @@ export const getDefaultAppState = (): Omit<
|
||||
editingElement: null,
|
||||
editingGroupId: null,
|
||||
editingLinearElement: null,
|
||||
activeTool: {
|
||||
type: "selection",
|
||||
customType: null,
|
||||
locked: false,
|
||||
lastActiveToolBeforeEraser: null,
|
||||
},
|
||||
elementLocked: false,
|
||||
elementType: "selection",
|
||||
penMode: false,
|
||||
penDetected: false,
|
||||
errorMessage: null,
|
||||
@ -58,7 +54,6 @@ export const getDefaultAppState = (): Omit<
|
||||
gridSize: null,
|
||||
isBindingEnabled: true,
|
||||
isLibraryOpen: false,
|
||||
isLibraryMenuDocked: false,
|
||||
isLoading: false,
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
@ -88,7 +83,7 @@ export const getDefaultAppState = (): Omit<
|
||||
value: 1 as NormalizedZoomValue,
|
||||
},
|
||||
viewModeEnabled: false,
|
||||
pendingImageElementId: null,
|
||||
pendingImageElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
};
|
||||
};
|
||||
@ -135,9 +130,10 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
editingElement: { browser: false, export: false, server: false },
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
editingLinearElement: { browser: false, export: false, server: false },
|
||||
activeTool: { browser: true, export: false, server: false },
|
||||
penMode: { browser: true, export: false, server: false },
|
||||
penDetected: { browser: true, export: false, server: false },
|
||||
elementLocked: { browser: true, export: false, server: false },
|
||||
elementType: { browser: true, export: false, server: false },
|
||||
penMode: { browser: false, export: false, server: false },
|
||||
penDetected: { browser: false, export: false, server: false },
|
||||
errorMessage: { browser: false, export: false, server: false },
|
||||
exportBackground: { browser: true, export: false, server: false },
|
||||
exportEmbedScene: { browser: true, export: false, server: false },
|
||||
@ -147,8 +143,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
gridSize: { browser: true, export: true, server: true },
|
||||
height: { browser: false, export: false, server: false },
|
||||
isBindingEnabled: { browser: false, export: false, server: false },
|
||||
isLibraryOpen: { browser: true, export: false, server: false },
|
||||
isLibraryMenuDocked: { browser: true, export: false, server: false },
|
||||
isLibraryOpen: { browser: false, export: false, server: false },
|
||||
isLoading: { browser: false, export: false, server: false },
|
||||
isResizing: { browser: false, export: false, server: false },
|
||||
isRotating: { browser: false, export: false, server: false },
|
||||
@ -179,7 +174,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
zenModeEnabled: { browser: true, export: false, server: false },
|
||||
zoom: { browser: true, export: false, server: false },
|
||||
viewModeEnabled: { browser: false, export: false, server: false },
|
||||
pendingImageElementId: { browser: false, export: false, server: false },
|
||||
pendingImageElement: { browser: false, export: false, server: false },
|
||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||
});
|
||||
|
||||
@ -218,9 +213,3 @@ export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
||||
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
|
||||
return _clearAppStateForStorage(appState, "server");
|
||||
};
|
||||
|
||||
export const isEraserActive = ({
|
||||
activeTool,
|
||||
}: {
|
||||
activeTool: AppState["activeTool"];
|
||||
}) => activeTool.type === "eraser";
|
||||
|
@ -1,121 +0,0 @@
|
||||
import {
|
||||
Spreadsheet,
|
||||
tryParseCells,
|
||||
tryParseNumber,
|
||||
VALID_SPREADSHEET,
|
||||
} from "./charts";
|
||||
|
||||
describe("charts", () => {
|
||||
describe("tryParseNumber", () => {
|
||||
it.each<[string, number]>([
|
||||
["1", 1],
|
||||
["0", 0],
|
||||
["-1", -1],
|
||||
["0.1", 0.1],
|
||||
[".1", 0.1],
|
||||
["1.", 1],
|
||||
["424.", 424],
|
||||
["$1", 1],
|
||||
["-.1", -0.1],
|
||||
["-$1", -1],
|
||||
["$-1", -1],
|
||||
])("should correctly identify %s as numbers", (given, expected) => {
|
||||
expect(tryParseNumber(given)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each<[string]>([["a"], ["$"], ["$a"], ["-$a"]])(
|
||||
"should correctly identify %s as not a number",
|
||||
(given) => {
|
||||
expect(tryParseNumber(given)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("tryParseCells", () => {
|
||||
it("Successfully parses a spreadsheet", () => {
|
||||
const spreadsheet = [
|
||||
["time", "value"],
|
||||
["01:00", "61"],
|
||||
["02:00", "-60"],
|
||||
["03:00", "85"],
|
||||
["04:00", "-67"],
|
||||
["05:00", "54"],
|
||||
["06:00", "95"],
|
||||
];
|
||||
|
||||
const result = tryParseCells(spreadsheet);
|
||||
|
||||
expect(result.type).toBe(VALID_SPREADSHEET);
|
||||
|
||||
const { title, labels, values } = (
|
||||
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
||||
).spreadsheet;
|
||||
|
||||
expect(title).toEqual("value");
|
||||
expect(labels).toEqual([
|
||||
"01:00",
|
||||
"02:00",
|
||||
"03:00",
|
||||
"04:00",
|
||||
"05:00",
|
||||
"06:00",
|
||||
]);
|
||||
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
||||
});
|
||||
|
||||
it("Uses the second column as the label if it is not a number", () => {
|
||||
const spreadsheet = [
|
||||
["time", "value"],
|
||||
["01:00", "61"],
|
||||
["02:00", "-60"],
|
||||
["03:00", "85"],
|
||||
["04:00", "-67"],
|
||||
["05:00", "54"],
|
||||
["06:00", "95"],
|
||||
];
|
||||
|
||||
const result = tryParseCells(spreadsheet);
|
||||
|
||||
expect(result.type).toBe(VALID_SPREADSHEET);
|
||||
|
||||
const { title, labels, values } = (
|
||||
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
||||
).spreadsheet;
|
||||
|
||||
expect(title).toEqual("value");
|
||||
expect(labels).toEqual([
|
||||
"01:00",
|
||||
"02:00",
|
||||
"03:00",
|
||||
"04:00",
|
||||
"05:00",
|
||||
"06:00",
|
||||
]);
|
||||
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
||||
});
|
||||
|
||||
it("treats the first column as labels if both columns are numbers", () => {
|
||||
const spreadsheet = [
|
||||
["time", "value"],
|
||||
["01", "61"],
|
||||
["02", "-60"],
|
||||
["03", "85"],
|
||||
["04", "-67"],
|
||||
["05", "54"],
|
||||
["06", "95"],
|
||||
];
|
||||
|
||||
const result = tryParseCells(spreadsheet);
|
||||
|
||||
expect(result.type).toBe(VALID_SPREADSHEET);
|
||||
|
||||
const { title, labels, values } = (
|
||||
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
||||
).spreadsheet;
|
||||
|
||||
expect(title).toEqual("value");
|
||||
expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]);
|
||||
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
||||
});
|
||||
});
|
||||
});
|
@ -29,24 +29,18 @@ type ParseSpreadsheetResult =
|
||||
| { type: typeof NOT_SPREADSHEET; reason: string }
|
||||
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
||||
|
||||
/**
|
||||
* @private exported for testing
|
||||
*/
|
||||
export const tryParseNumber = (s: string): number | null => {
|
||||
const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
|
||||
const tryParseNumber = (s: string): number | null => {
|
||||
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
|
||||
return parseFloat(match[1].replace(/,/g, ""));
|
||||
};
|
||||
|
||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
||||
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
||||
|
||||
/**
|
||||
* @private exported for testing
|
||||
*/
|
||||
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||
const numCols = cells[0].length;
|
||||
|
||||
if (numCols > 2) {
|
||||
@ -77,16 +71,13 @@ export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||
};
|
||||
}
|
||||
|
||||
const labelColumnNumeric = isNumericColumn(cells, 0);
|
||||
const valueColumnNumeric = isNumericColumn(cells, 1);
|
||||
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
||||
|
||||
if (!labelColumnNumeric && !valueColumnNumeric) {
|
||||
if (!isNumericColumn(cells, valueColumnIndex)) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||
}
|
||||
|
||||
const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
|
||||
? [0, 1]
|
||||
: [1, 0];
|
||||
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
||||
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
|
||||
const rows = hasHeader ? cells.slice(1) : cells;
|
||||
|
||||
@ -176,7 +167,6 @@ const commonProps = {
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
locked: false,
|
||||
} as const;
|
||||
|
||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||
|
@ -2,16 +2,16 @@ import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import { AppState, BinaryFiles } from "./types";
|
||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||
import { isInitializedImageElement } from "./element/typeChecks";
|
||||
import { isPromiseLike } from "./utils";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
elements: ExcalidrawElement[];
|
||||
files: BinaryFiles | undefined;
|
||||
};
|
||||
|
||||
@ -56,20 +56,19 @@ const clipboardContainsElements = (
|
||||
export const copyToClipboard = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles | null,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
// select binded text elements when copying
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const contents: ElementsClipboard = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements,
|
||||
files: files
|
||||
? elements.reduce((acc, element) => {
|
||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||
acc[element.fileId] = files[element.fileId];
|
||||
}
|
||||
return acc;
|
||||
}, {} as BinaryFiles)
|
||||
: undefined,
|
||||
elements: selectedElements,
|
||||
files: selectedElements.reduce((acc, element) => {
|
||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||
acc[element.fileId] = files[element.fileId];
|
||||
}
|
||||
return acc;
|
||||
}, {} as BinaryFiles),
|
||||
};
|
||||
const json = JSON.stringify(contents);
|
||||
CLIPBOARD = json;
|
||||
@ -167,35 +166,10 @@ export const parseClipboard = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||
let promise;
|
||||
try {
|
||||
// in Safari so far we need to construct the ClipboardItem synchronously
|
||||
// (i.e. in the same tick) otherwise browser will complain for lack of
|
||||
// user intent. Using a Promise ClipboardItem constructor solves this.
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=222262
|
||||
//
|
||||
// not await so that we can detect whether the thrown error likely relates
|
||||
// to a lack of support for the Promise ClipboardItem constructor
|
||||
promise = navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
[MIME_TYPES.png]: blob,
|
||||
}),
|
||||
]);
|
||||
} catch (error: any) {
|
||||
// if we're using a Promise ClipboardItem, let's try constructing
|
||||
// with resolution value instead
|
||||
if (isPromiseLike(blob)) {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
[MIME_TYPES.png]: await blob,
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await promise;
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
|
||||
]);
|
||||
};
|
||||
|
||||
export const copyTextToSystemClipboard = async (text: string | null) => {
|
||||
|
@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import {
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
@ -15,28 +15,22 @@ import {
|
||||
} from "../scene";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import {
|
||||
capitalizeString,
|
||||
isTransparent,
|
||||
updateActiveTool,
|
||||
setCursorForShape,
|
||||
} from "../utils";
|
||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
elements,
|
||||
renderAction,
|
||||
activeTool,
|
||||
elementType,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
renderAction: ActionManager["renderAction"];
|
||||
activeTool: AppState["activeTool"]["type"];
|
||||
elementType: ExcalidrawElement["type"];
|
||||
}) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
@ -52,22 +46,19 @@ export const SelectedShapeActions = ({
|
||||
isSingleElementBoundContainer = true;
|
||||
}
|
||||
const isEditing = Boolean(appState.editingElement);
|
||||
const device = useDevice();
|
||||
const isMobile = useIsMobile();
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const showFillIcons =
|
||||
hasBackground(activeTool) ||
|
||||
hasBackground(elementType) ||
|
||||
targetElements.some(
|
||||
(element) =>
|
||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||
);
|
||||
const showChangeBackgroundIcons =
|
||||
hasBackground(activeTool) ||
|
||||
hasBackground(elementType) ||
|
||||
targetElements.some((element) => hasBackground(element.type));
|
||||
|
||||
const showLinkIcon =
|
||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||
|
||||
let commonSelectedType: string | null = targetElements[0]?.type || null;
|
||||
|
||||
for (const element of targetElements) {
|
||||
@ -79,23 +70,23 @@ export const SelectedShapeActions = ({
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
{((hasStrokeColor(activeTool) &&
|
||||
activeTool !== "image" &&
|
||||
{((hasStrokeColor(elementType) &&
|
||||
elementType !== "image" &&
|
||||
commonSelectedType !== "image") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
{(hasStrokeWidth(activeTool) ||
|
||||
{(hasStrokeWidth(elementType) ||
|
||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||
renderAction("changeStrokeWidth")}
|
||||
|
||||
{(activeTool === "freedraw" ||
|
||||
{(elementType === "freedraw" ||
|
||||
targetElements.some((element) => element.type === "freedraw")) &&
|
||||
renderAction("changeStrokeShape")}
|
||||
|
||||
{(hasStrokeStyle(activeTool) ||
|
||||
{(hasStrokeStyle(elementType) ||
|
||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeStrokeStyle")}
|
||||
@ -103,12 +94,12 @@ export const SelectedShapeActions = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canChangeSharpness(activeTool) ||
|
||||
{(canChangeSharpness(elementType) ||
|
||||
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
||||
<>{renderAction("changeSharpness")}</>
|
||||
)}
|
||||
|
||||
{(hasText(activeTool) ||
|
||||
{(hasText(elementType) ||
|
||||
targetElements.some((element) => hasText(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeFontSize")}
|
||||
@ -123,7 +114,7 @@ export const SelectedShapeActions = ({
|
||||
(element) =>
|
||||
hasBoundTextElement(element) || isBoundToContainer(element),
|
||||
) && renderAction("changeVerticalAlign")}
|
||||
{(canHaveArrowheads(activeTool) ||
|
||||
{(canHaveArrowheads(elementType) ||
|
||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||
<>{renderAction("changeArrowhead")}</>
|
||||
)}
|
||||
@ -177,11 +168,11 @@ export const SelectedShapeActions = ({
|
||||
<fieldset>
|
||||
<legend>{t("labels.actions")}</legend>
|
||||
<div className="buttonList">
|
||||
{!device.isMobile && renderAction("duplicateSelection")}
|
||||
{!device.isMobile && renderAction("deleteSelectedElements")}
|
||||
{!isMobile && renderAction("duplicateSelection")}
|
||||
{!isMobile && renderAction("deleteSelectedElements")}
|
||||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
{targetElements.length === 1 && renderAction("hyperlink")}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
@ -191,16 +182,14 @@ export const SelectedShapeActions = ({
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
canvas,
|
||||
activeTool,
|
||||
elementType,
|
||||
setAppState,
|
||||
onImageAction,
|
||||
appState,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
activeTool: AppState["activeTool"];
|
||||
elementType: ExcalidrawElement["type"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
appState: AppState;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
@ -215,37 +204,20 @@ export const ShapesSwitcher = ({
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
checked={elementType === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={`${index + 1}`}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={value}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: value,
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
elementType: value,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(canvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
setCursorForShape(canvas, value);
|
||||
if (value === "image") {
|
||||
onImageAction({ pointerType });
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -12,11 +12,5 @@
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
|
||||
&-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,36 +1,20 @@
|
||||
import "./Avatar.scss";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { getClientInitials } from "../clients";
|
||||
import React from "react";
|
||||
|
||||
type AvatarProps = {
|
||||
children: string;
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
color: string;
|
||||
border: string;
|
||||
name: string;
|
||||
src?: string;
|
||||
};
|
||||
|
||||
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
|
||||
const shortName = getClientInitials(name);
|
||||
const [error, setError] = useState(false);
|
||||
const loadImg = !error && src;
|
||||
const style = loadImg
|
||||
? undefined
|
||||
: { background: color, border: `1px solid ${border}` };
|
||||
return (
|
||||
<div className="Avatar" style={style} onClick={onClick}>
|
||||
{loadImg ? (
|
||||
<img
|
||||
className="Avatar-img"
|
||||
src={src}
|
||||
alt={shortName}
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
) : (
|
||||
shortName
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
|
||||
<div
|
||||
className="Avatar"
|
||||
style={{ background: color, border: `1px solid ${border}` }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "./App";
|
||||
import { useIsMobile } from "./App";
|
||||
import { trash } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
||||
icon={trash}
|
||||
title={t("buttons.clearReset")}
|
||||
aria-label={t("buttons.clearReset")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={toggleDialog}
|
||||
data-testid="clear-canvas-button"
|
||||
/>
|
||||
|
@ -18,15 +18,13 @@
|
||||
left: -5px;
|
||||
}
|
||||
min-width: 1em;
|
||||
min-height: 1em;
|
||||
line-height: 1;
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
padding: 3px;
|
||||
border-radius: 50%;
|
||||
background-color: $oc-green-6;
|
||||
color: $oc-white;
|
||||
font-size: 0.6em;
|
||||
font-family: "Cascadia";
|
||||
font-size: 0.7em;
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { users } from "./icons";
|
||||
|
||||
import "./CollabButton.scss";
|
||||
@ -26,9 +26,9 @@ const CollabButton = ({
|
||||
type="button"
|
||||
title={t("labels.liveCollaboration")}
|
||||
aria-label={t("labels.liveCollaboration")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
showAriaLabel={useIsMobile()}
|
||||
>
|
||||
{isCollaborating && (
|
||||
{collaboratorCount > 0 && (
|
||||
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
||||
)}
|
||||
</ToolButton>
|
||||
|
@ -128,33 +128,45 @@ const Picker = ({
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
let handled = false;
|
||||
if (isArrowKey(event.key)) {
|
||||
handled = true;
|
||||
if (event.key === KEYS.TAB) {
|
||||
const { activeElement } = document;
|
||||
if (event.shiftKey) {
|
||||
if (activeElement === firstItem.current) {
|
||||
colorInput.current?.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
} else if (activeElement === colorInput.current) {
|
||||
firstItem.current?.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
} else if (isArrowKey(event.key)) {
|
||||
const { activeElement } = document;
|
||||
const isRTL = getLanguage().rtl;
|
||||
let isCustom = false;
|
||||
let index = Array.prototype.indexOf.call(
|
||||
gallery.current!.querySelector(".color-picker-content--default")
|
||||
?.children,
|
||||
gallery!.current!.querySelector(".color-picker-content--default")!
|
||||
.children,
|
||||
activeElement,
|
||||
);
|
||||
if (index === -1) {
|
||||
index = Array.prototype.indexOf.call(
|
||||
gallery.current!.querySelector(".color-picker-content--canvas-colors")
|
||||
?.children,
|
||||
gallery!.current!.querySelector(
|
||||
".color-picker-content--canvas-colors",
|
||||
)!.children,
|
||||
activeElement,
|
||||
);
|
||||
if (index !== -1) {
|
||||
isCustom = true;
|
||||
}
|
||||
}
|
||||
const parentElement = isCustom
|
||||
? gallery.current?.querySelector(".color-picker-content--canvas-colors")
|
||||
: gallery.current?.querySelector(".color-picker-content--default");
|
||||
const parentSelector = isCustom
|
||||
? gallery!.current!.querySelector(
|
||||
".color-picker-content--canvas-colors",
|
||||
)!
|
||||
: gallery!.current!.querySelector(".color-picker-content--default")!;
|
||||
|
||||
if (parentElement && index !== -1) {
|
||||
const length = parentElement.children.length - (showInput ? 1 : 0);
|
||||
if (index !== -1) {
|
||||
const length = parentSelector!.children.length - (showInput ? 1 : 0);
|
||||
const nextIndex =
|
||||
event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
|
||||
? (index + 1) % length
|
||||
@ -165,38 +177,30 @@ const Picker = ({
|
||||
: !isCustom && event.key === KEYS.ARROW_UP
|
||||
? (length + index - 5) % length
|
||||
: index;
|
||||
(parentElement.children[nextIndex] as HTMLElement | undefined)?.focus();
|
||||
(parentSelector!.children![nextIndex] as HTMLElement)?.focus();
|
||||
}
|
||||
event.preventDefault();
|
||||
} else if (
|
||||
keyBindings.includes(event.key.toLowerCase()) &&
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
!event.altKey &&
|
||||
!isWritableElement(event.target)
|
||||
) {
|
||||
handled = true;
|
||||
const index = keyBindings.indexOf(event.key.toLowerCase());
|
||||
const isCustom = index >= MAX_DEFAULT_COLORS;
|
||||
const parentElement = isCustom
|
||||
? gallery?.current?.querySelector(
|
||||
const parentSelector = isCustom
|
||||
? gallery!.current!.querySelector(
|
||||
".color-picker-content--canvas-colors",
|
||||
)
|
||||
: gallery?.current?.querySelector(".color-picker-content--default");
|
||||
)!
|
||||
: gallery!.current!.querySelector(".color-picker-content--default")!;
|
||||
const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
|
||||
(
|
||||
parentElement?.children[actualIndex] as HTMLElement | undefined
|
||||
)?.focus();
|
||||
(parentSelector!.children![actualIndex] as HTMLElement)?.focus();
|
||||
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||
handled = true;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
if (handled) {
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
}
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const renderColors = (colors: Array<string>, custom: boolean = false) => {
|
||||
@ -260,8 +264,7 @@ const Picker = ({
|
||||
gallery.current = el;
|
||||
}
|
||||
}}
|
||||
// to allow focusing by clicking but not by tabbing
|
||||
tabIndex={-1}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="color-picker-content--default">
|
||||
{renderColors(colors)}
|
||||
|
@ -70,9 +70,7 @@ const ContextMenu = ({
|
||||
dangerous: actionName === "deleteSelectedElements",
|
||||
checkmark: option.checked?.(appState),
|
||||
})}
|
||||
onClick={() =>
|
||||
actionManager.executeAction(option, "contextMenu")
|
||||
}
|
||||
onClick={() => actionManager.executeAction(option)}
|
||||
>
|
||||
<div className="context-menu-option__label">{label}</div>
|
||||
<kbd className="context-menu-option__shortcut">
|
||||
|
@ -2,14 +2,13 @@ import clsx from "clsx";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import { useExcalidrawContainer, useDevice } from "../components/App";
|
||||
import { useExcalidrawContainer, useIsMobile } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import "./Dialog.scss";
|
||||
import { back, close } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import { Modal } from "./Modal";
|
||||
import { AppState } from "../types";
|
||||
import { queryFocusableElements } from "../utils";
|
||||
|
||||
export interface DialogProps {
|
||||
children: React.ReactNode;
|
||||
@ -65,6 +64,14 @@ export const Dialog = (props: DialogProps) => {
|
||||
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
||||
}, [islandNode, props.autofocus]);
|
||||
|
||||
const queryFocusableElements = (node: HTMLElement) => {
|
||||
const focusableElements = node.querySelectorAll<HTMLElement>(
|
||||
"button, a, input, select, textarea, div[tabindex]",
|
||||
);
|
||||
|
||||
return focusableElements ? Array.from(focusableElements) : [];
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
(lastActiveElement as HTMLElement).focus();
|
||||
props.onCloseRequest();
|
||||
@ -87,7 +94,7 @@ export const Dialog = (props: DialogProps) => {
|
||||
onClick={onClose}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{useDevice().isMobile ? back : close}
|
||||
{useIsMobile() ? back : close}
|
||||
</button>
|
||||
</h2>
|
||||
<div className="Dialog__content">{props.children}</div>
|
||||
|
@ -139,7 +139,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
<Section title={t("helpDialog.shortcuts")}>
|
||||
<Columns>
|
||||
<Column>
|
||||
<ShortcutIsland caption={t("helpDialog.tools")}>
|
||||
<ShortcutIsland caption={t("helpDialog.shapes")}>
|
||||
<Shortcut
|
||||
label={t("toolBar.selection")}
|
||||
shortcuts={["V", "1"]}
|
||||
@ -149,7 +149,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
shortcuts={["R", "2"]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
|
||||
<Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} />
|
||||
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
|
||||
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||
<Shortcut
|
||||
@ -159,10 +159,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
|
||||
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.eraser")}
|
||||
shortcuts={[getShortcutKey("E")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.editSelectedShape")}
|
||||
shortcuts={[
|
||||
@ -363,10 +359,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.toggleElementLock")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.undo")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
|
||||
|
@ -11,7 +11,6 @@ import {
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { isEraserActive } from "../appState";
|
||||
|
||||
interface HintViewerProps {
|
||||
appState: AppState;
|
||||
@ -20,32 +19,25 @@ interface HintViewerProps {
|
||||
}
|
||||
|
||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (appState.isLibraryOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
return t("hints.eraserRevert");
|
||||
}
|
||||
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
||||
if (elementType === "arrow" || elementType === "line") {
|
||||
if (!multiMode) {
|
||||
return t("hints.linearElement");
|
||||
}
|
||||
return t("hints.linearElementMulti");
|
||||
}
|
||||
|
||||
if (activeTool.type === "freedraw") {
|
||||
if (elementType === "freedraw") {
|
||||
return t("hints.freeDraw");
|
||||
}
|
||||
|
||||
if (activeTool.type === "text") {
|
||||
if (elementType === "text") {
|
||||
return t("hints.text");
|
||||
}
|
||||
|
||||
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
|
||||
if (appState.elementType === "image" && appState.pendingImageElement) {
|
||||
return t("hints.placeImage");
|
||||
}
|
||||
|
||||
@ -77,7 +69,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
return t("hints.text_editing");
|
||||
}
|
||||
|
||||
if (activeTool.type === "selection") {
|
||||
if (elementType === "selection") {
|
||||
if (
|
||||
appState.draggingElement?.type === "selection" &&
|
||||
!appState.editingElement &&
|
||||
|
@ -1,11 +1,12 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
import { ActionsManagerInterface } from "../actions/types";
|
||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||
import { canvasToBlob } from "../data/blob";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "./App";
|
||||
import { useIsMobile } from "./App";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas } from "../scene/export";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
@ -18,7 +19,6 @@ import OpenColor from "open-color";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
|
||||
const supportsContextFilters =
|
||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||
@ -90,7 +90,7 @@ const ImageExportModal = ({
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionManager;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
@ -229,7 +229,7 @@ export const ImageExportDialog = ({
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionManager;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
@ -250,7 +250,7 @@ export const ImageExportDialog = ({
|
||||
icon={exportImage}
|
||||
type="button"
|
||||
aria-label={t("buttons.exportImage")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
showAriaLabel={useIsMobile()}
|
||||
title={t("buttons.exportImage")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
|
@ -14,11 +14,11 @@ export const InitializeApp = (props: Props) => {
|
||||
useEffect(() => {
|
||||
const updateLang = async () => {
|
||||
await setLanguage(currentLang);
|
||||
setLoading(false);
|
||||
};
|
||||
const currentLang =
|
||||
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
||||
updateLang();
|
||||
setLoading(false);
|
||||
}, [props.langCode]);
|
||||
|
||||
return loading ? <LoadingMessage /> : props.children;
|
||||
|
@ -1,7 +1,8 @@
|
||||
import React, { useState } from "react";
|
||||
import { ActionsManagerInterface } from "../actions/types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "./App";
|
||||
import { useIsMobile } from "./App";
|
||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||
@ -11,9 +12,6 @@ import { Card } from "./Card";
|
||||
|
||||
import "./ExportDialog.scss";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getFrame } from "../utils";
|
||||
|
||||
export type ExportCB = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
@ -31,7 +29,7 @@ const JSONExportModal = ({
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionManager;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onCloseRequest: () => void;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
@ -56,7 +54,7 @@ const JSONExportModal = ({
|
||||
aria-label={t("exportDialog.disk_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => {
|
||||
actionManager.executeAction(actionSaveFileToDisk, "ui");
|
||||
actionManager.executeAction(actionSaveFileToDisk);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
@ -72,10 +70,9 @@ const JSONExportModal = ({
|
||||
title={t("exportDialog.link_button")}
|
||||
aria-label={t("exportDialog.link_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => {
|
||||
onExportToBackend(elements, appState, files, canvas);
|
||||
trackEvent("export", "link", `ui (${getFrame()})`);
|
||||
}}
|
||||
onClick={() =>
|
||||
onExportToBackend(elements, appState, files, canvas)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
@ -97,7 +94,7 @@ export const JSONExportDialog = ({
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
actionManager: ActionManager;
|
||||
actionManager: ActionsManagerInterface;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
}) => {
|
||||
@ -117,7 +114,7 @@ export const JSONExportDialog = ({
|
||||
icon={exportFile}
|
||||
type="button"
|
||||
aria-label={t("buttons.export")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
showAriaLabel={useIsMobile()}
|
||||
title={t("buttons.export")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
|
@ -1,63 +1,9 @@
|
||||
@import "open-color/open-color";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.layer-ui__sidebar {
|
||||
position: absolute;
|
||||
top: var(--sat);
|
||||
bottom: var(--sab);
|
||||
right: var(--sar);
|
||||
z-index: 5;
|
||||
|
||||
box-shadow: var(--shadow-island);
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-lg);
|
||||
margin: var(--space-factor);
|
||||
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
|
||||
|
||||
.Island {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.ToolIcon__icon__close {
|
||||
.Modal__close {
|
||||
width: calc(var(--space-factor) * 7);
|
||||
height: calc(var(--space-factor) * 7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.Island {
|
||||
--padding: 0;
|
||||
background-color: var(--island-bg-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__wrapper.animate {
|
||||
transition: width 0.1s ease-in-out;
|
||||
}
|
||||
.layer-ui__wrapper {
|
||||
// when the rightside sidebar is docked, we need to resize the UI by its
|
||||
// width, making the nested UI content shift to the left. To do this,
|
||||
// we need the UI container to actually have dimensions set, but
|
||||
// then we also need to disable pointer events else the canvas below
|
||||
// wouldn't be interactive.
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: var(--zIndex-layerUI);
|
||||
|
||||
&__top-right {
|
||||
display: flex;
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import clsx from "clsx";
|
||||
import React, { useCallback } from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
||||
import { CLASSES } from "../constants";
|
||||
import { exportCanvas } from "../data";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||
@ -25,8 +26,9 @@ import { PasteChartDialog } from "./PasteChartDialog";
|
||||
import { Section } from "./Section";
|
||||
import { HelpDialog } from "./HelpDialog";
|
||||
import Stack from "./Stack";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { UserList } from "./UserList";
|
||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
import Library from "../data/library";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
@ -35,10 +37,6 @@ import { LibraryMenu } from "./LibraryMenu";
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useDevice } from "../components/App";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@ -57,9 +55,11 @@ interface LayerUIProps {
|
||||
toggleZenMode: () => void;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
@ -68,6 +68,7 @@ interface LayerUIProps {
|
||||
id: string;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
}
|
||||
|
||||
const LayerUI = ({
|
||||
actionManager,
|
||||
appState,
|
||||
@ -86,7 +87,6 @@ const LayerUI = ({
|
||||
isCollaborating,
|
||||
renderTopRightUI,
|
||||
renderCustomFooter,
|
||||
renderCustomStats,
|
||||
viewModeEnabled,
|
||||
libraryReturnUrl,
|
||||
UIOptions,
|
||||
@ -95,7 +95,7 @@ const LayerUI = ({
|
||||
id,
|
||||
onImageAction,
|
||||
}: LayerUIProps) => {
|
||||
const device = useDevice();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const renderJSONExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
@ -122,7 +122,6 @@ const LayerUI = ({
|
||||
const createExporter =
|
||||
(type: ExportType): ExportCB =>
|
||||
async (exportedElements) => {
|
||||
trackEvent("export", type, "ui");
|
||||
const fileHandle = await exportCanvas(
|
||||
type,
|
||||
exportedElements,
|
||||
@ -249,7 +248,7 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
activeTool={appState.activeTool.type}
|
||||
elementType={appState.elementType}
|
||||
/>
|
||||
</Island>
|
||||
</Section>
|
||||
@ -276,9 +275,7 @@ const LayerUI = ({
|
||||
<LibraryMenu
|
||||
pendingElements={getSelectedElements(elements, appState, true)}
|
||||
onClose={closeLibrary}
|
||||
onInsertLibraryItems={(libraryItems) => {
|
||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||
}}
|
||||
onInsertShape={onInsertElements}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
@ -328,8 +325,8 @@ const LayerUI = ({
|
||||
/>
|
||||
<LockButton
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={() => onLockToggle()}
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
<Island
|
||||
@ -341,14 +338,13 @@ const LayerUI = ({
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
isMobile={device.isMobile}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
activeTool={appState.activeTool}
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
@ -363,6 +359,7 @@ const LayerUI = ({
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
</Stack.Col>
|
||||
)}
|
||||
</Section>
|
||||
@ -375,11 +372,23 @@ const LayerUI = ({
|
||||
},
|
||||
)}
|
||||
>
|
||||
<UserList
|
||||
collaborators={appState.collaborators}
|
||||
actionManager={actionManager}
|
||||
/>
|
||||
{renderTopRightUI?.(device.isMobile, appState)}
|
||||
<UserList>
|
||||
{appState.collaborators.size > 0 &&
|
||||
Array.from(appState.collaborators)
|
||||
// Collaborator is either not initialized or is actually the current user.
|
||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||
.map(([clientId, client]) => (
|
||||
<Tooltip
|
||||
label={client.username || "Unknown user"}
|
||||
key={clientId}
|
||||
>
|
||||
{actionManager.renderAction("goToCollaborator", {
|
||||
id: clientId,
|
||||
})}
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
{renderTopRightUI?.(isMobile, appState)}
|
||||
</div>
|
||||
</div>
|
||||
</FixedSideContainer>
|
||||
@ -409,39 +418,16 @@ const LayerUI = ({
|
||||
/>
|
||||
</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>
|
||||
|
||||
<div
|
||||
className={clsx("eraser-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("eraser", { size: "small" })}
|
||||
</div>
|
||||
</>
|
||||
<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>
|
||||
)}
|
||||
{!viewModeEnabled &&
|
||||
appState.multiElement &&
|
||||
device.isTouchScreen && (
|
||||
<div
|
||||
className={clsx("finalize-button zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("finalize", { size: "small" })}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
@ -480,7 +466,7 @@ const LayerUI = ({
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
{appState.isLoading && <LoadingMessage />}
|
||||
{appState.errorMessage && (
|
||||
<ErrorDialog
|
||||
message={appState.errorMessage}
|
||||
@ -509,24 +495,7 @@ const LayerUI = ({
|
||||
</>
|
||||
);
|
||||
|
||||
const renderStats = () => {
|
||||
if (!appState.showStats) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Stats
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
elements={elements}
|
||||
onClose={() => {
|
||||
actionManager.executeAction(actionToggleStats);
|
||||
}}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return device.isMobile ? (
|
||||
return isMobile ? (
|
||||
<>
|
||||
{dialogs}
|
||||
<MobileMenu
|
||||
@ -538,7 +507,7 @@ const LayerUI = ({
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
setAppState={setAppState}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={() => onLockToggle()}
|
||||
onLockToggle={onLockToggle}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
canvas={canvas}
|
||||
isCollaborating={isCollaborating}
|
||||
@ -547,48 +516,33 @@ const LayerUI = ({
|
||||
showThemeBtn={showThemeBtn}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderStats={renderStats}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper", {
|
||||
"disable-pointerEvents":
|
||||
appState.draggingElement ||
|
||||
appState.resizingElement ||
|
||||
(appState.editingElement &&
|
||||
!isTextElement(appState.editingElement)),
|
||||
})}
|
||||
style={
|
||||
appState.isLibraryOpen &&
|
||||
appState.isLibraryMenuDocked &&
|
||||
device.canDeviceFitSidebar
|
||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{dialogs}
|
||||
{renderFixedSideContainer()}
|
||||
{renderBottomAppMenu()}
|
||||
{renderStats()}
|
||||
{appState.scrolledOutside && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{appState.isLibraryOpen && (
|
||||
<div className="layer-ui__sidebar">{libraryMenu}</div>
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper", {
|
||||
"disable-pointerEvents":
|
||||
appState.draggingElement ||
|
||||
appState.resizingElement ||
|
||||
(appState.editingElement && !isTextElement(appState.editingElement)),
|
||||
})}
|
||||
>
|
||||
{dialogs}
|
||||
{renderFixedSideContainer()}
|
||||
{renderBottomAppMenu()}
|
||||
{appState.scrolledOutside && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -3,8 +3,6 @@ import clsx from "clsx";
|
||||
import { t } from "../i18n";
|
||||
import { AppState } from "../types";
|
||||
import { capitalizeString } from "../utils";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useDevice } from "./App";
|
||||
|
||||
const LIBRARY_ICON = (
|
||||
<svg viewBox="0 0 576 512">
|
||||
@ -20,7 +18,6 @@ export const LibraryButton: React.FC<{
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
isMobile?: boolean;
|
||||
}> = ({ appState, setAppState, isMobile }) => {
|
||||
const device = useDevice();
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
@ -37,19 +34,7 @@ export const LibraryButton: React.FC<{
|
||||
type="checkbox"
|
||||
name="editor-library"
|
||||
onChange={(event) => {
|
||||
document
|
||||
.querySelector(".layer-ui__wrapper")
|
||||
?.classList.remove("animate");
|
||||
const nextState = event.target.checked;
|
||||
setAppState({ isLibraryOpen: nextState });
|
||||
// track only openings
|
||||
if (nextState) {
|
||||
trackEvent(
|
||||
"library",
|
||||
"toggleLibrary (open)",
|
||||
`toolbar (${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
setAppState({ isLibraryOpen: event.target.checked });
|
||||
}}
|
||||
checked={appState.isLibraryOpen}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__library {
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -10,41 +11,25 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 2px 0 15px 0;
|
||||
.Spinner {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
margin: 2px 0;
|
||||
|
||||
button {
|
||||
// 2px from the left to account for focus border of left-most button
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__sidebar {
|
||||
.layer-ui__library {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.library-menu-items-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
a {
|
||||
margin-inline-start: auto;
|
||||
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||
padding-inline-end: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__library-message {
|
||||
padding: 2em 4em;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
.Spinner {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
span {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
padding: 10px 20px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.publish-library-success {
|
||||
@ -67,38 +52,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.library-menu-browse-button {
|
||||
width: 80%;
|
||||
min-height: 22px;
|
||||
margin: 0 auto;
|
||||
margin-top: 1rem;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: var(--color-primary);
|
||||
color: $oc-white;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
text-decoration: none !important;
|
||||
&:hover {
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--color-primary-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
.library-menu-browse-button--mobile {
|
||||
min-height: 22px;
|
||||
margin-left: auto;
|
||||
a {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,5 @@
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
RefObject,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import Library, { libraryItemsAtom } from "../data/library";
|
||||
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
|
||||
import Library from "../data/library";
|
||||
import { t } from "../i18n";
|
||||
import { randomId } from "../random";
|
||||
import {
|
||||
@ -25,11 +18,7 @@ import "./LibraryMenu.scss";
|
||||
import LibraryMenuItems from "./LibraryMenuItems";
|
||||
import { EVENT } from "../constants";
|
||||
import { KEYS } from "../keys";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import Spinner from "./Spinner";
|
||||
import { useDevice } from "./App";
|
||||
import { arrayToMap } from "../utils";
|
||||
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
@ -64,20 +53,9 @@ const getSelectedItems = (
|
||||
selectedItems: LibraryItem["id"][],
|
||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||
|
||||
const LibraryMenuWrapper = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ children: React.ReactNode }
|
||||
>(({ children }, ref) => {
|
||||
return (
|
||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||
{children}
|
||||
</Island>
|
||||
);
|
||||
});
|
||||
|
||||
export const LibraryMenu = ({
|
||||
onClose,
|
||||
onInsertLibraryItems,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
theme,
|
||||
@ -91,7 +69,7 @@ export const LibraryMenu = ({
|
||||
}: {
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onClose: () => void;
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||
onAddToLibrary: () => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
@ -104,30 +82,17 @@ export const LibraryMenu = ({
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const device = useDevice();
|
||||
|
||||
useOnClickOutside(
|
||||
ref,
|
||||
useCallback(
|
||||
(event) => {
|
||||
// If click on the library icon, do nothing.
|
||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||
return;
|
||||
}
|
||||
if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar],
|
||||
),
|
||||
);
|
||||
useOnClickOutside(ref, (event) => {
|
||||
// If click on the library icon, do nothing.
|
||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
(!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
|
||||
) {
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
@ -135,8 +100,13 @@ export const LibraryMenu = ({
|
||||
return () => {
|
||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
};
|
||||
}, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
|
||||
}, [onClose]);
|
||||
|
||||
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
||||
|
||||
const [loadingState, setIsLoading] = useState<
|
||||
"preloading" | "loading" | "ready"
|
||||
>("preloading");
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||
useState(false);
|
||||
@ -144,35 +114,55 @@ export const LibraryMenu = ({
|
||||
url: string;
|
||||
authorName: string;
|
||||
}>(null);
|
||||
const loadingTimerRef = useRef<number | null>(null);
|
||||
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
useEffect(() => {
|
||||
Promise.race([
|
||||
new Promise((resolve) => {
|
||||
loadingTimerRef.current = window.setTimeout(() => {
|
||||
resolve("loading");
|
||||
}, 100);
|
||||
}),
|
||||
library.loadLibrary().then((items) => {
|
||||
setLibraryItems(items);
|
||||
setIsLoading("ready");
|
||||
}),
|
||||
]).then((data) => {
|
||||
if (data === "loading") {
|
||||
setIsLoading("loading");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(loadingTimerRef.current!);
|
||||
};
|
||||
}, [library]);
|
||||
|
||||
const removeFromLibrary = useCallback(
|
||||
async (libraryItems: LibraryItems) => {
|
||||
const nextItems = libraryItems.filter(
|
||||
(item) => !selectedItems.includes(item.id),
|
||||
);
|
||||
library.setLibrary(nextItems).catch(() => {
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setSelectedItems([]);
|
||||
},
|
||||
[library, setAppState, selectedItems, setSelectedItems],
|
||||
);
|
||||
const removeFromLibrary = useCallback(async () => {
|
||||
const items = await library.loadLibrary();
|
||||
|
||||
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setSelectedItems([]);
|
||||
setLibraryItems(nextItems);
|
||||
}, [library, setAppState, selectedItems, setSelectedItems]);
|
||||
|
||||
const resetLibrary = useCallback(() => {
|
||||
library.resetLibrary();
|
||||
setLibraryItems([]);
|
||||
focusContainer();
|
||||
}, [library, focusContainer]);
|
||||
|
||||
const addToLibrary = useCallback(
|
||||
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
|
||||
trackEvent("element", "addToLibrary", "ui");
|
||||
async (elements: LibraryItem["elements"]) => {
|
||||
if (elements.some((element) => element.type === "image")) {
|
||||
return setAppState({
|
||||
errorMessage: "Support for adding images to the library coming soon!",
|
||||
});
|
||||
}
|
||||
const items = await library.loadLibrary();
|
||||
const nextItems: LibraryItems = [
|
||||
{
|
||||
status: "unpublished",
|
||||
@ -180,12 +170,14 @@ export const LibraryMenu = ({
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
},
|
||||
...libraryItems,
|
||||
...items,
|
||||
];
|
||||
onAddToLibrary();
|
||||
library.setLibrary(nextItems).catch(() => {
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||
});
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
[onAddToLibrary, library, setAppState],
|
||||
);
|
||||
@ -224,7 +216,7 @@ export const LibraryMenu = ({
|
||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||
|
||||
const onPublishLibSuccess = useCallback(
|
||||
(data, libraryItems: LibraryItems) => {
|
||||
(data) => {
|
||||
setShowPublishLibraryDialog(false);
|
||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||
const nextLibItems = libraryItems.slice();
|
||||
@ -233,71 +225,102 @@ export const LibraryMenu = ({
|
||||
libItem.status = "published";
|
||||
}
|
||||
});
|
||||
library.setLibrary(nextLibItems);
|
||||
library.saveLibrary(nextLibItems);
|
||||
setLibraryItems(nextLibItems);
|
||||
},
|
||||
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||
[
|
||||
setShowPublishLibraryDialog,
|
||||
setPublishLibSuccess,
|
||||
libraryItems,
|
||||
selectedItems,
|
||||
library,
|
||||
],
|
||||
);
|
||||
|
||||
if (
|
||||
libraryItemsData.status === "loading" &&
|
||||
!libraryItemsData.isInitialized
|
||||
) {
|
||||
return (
|
||||
<LibraryMenuWrapper ref={ref}>
|
||||
<div className="layer-ui__library-message">
|
||||
<Spinner size="2em" />
|
||||
<span>{t("labels.libraryLoadingMessage")}</span>
|
||||
</div>
|
||||
</LibraryMenuWrapper>
|
||||
);
|
||||
}
|
||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||
LibraryItem["id"] | null
|
||||
>(null);
|
||||
|
||||
return (
|
||||
<LibraryMenuWrapper ref={ref}>
|
||||
return loadingState === "preloading" ? null : (
|
||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||
{showPublishLibraryDialog && (
|
||||
<PublishLibrary
|
||||
onClose={() => setShowPublishLibraryDialog(false)}
|
||||
libraryItems={getSelectedItems(
|
||||
libraryItemsData.libraryItems,
|
||||
selectedItems,
|
||||
)}
|
||||
libraryItems={getSelectedItems(libraryItems, selectedItems)}
|
||||
appState={appState}
|
||||
onSuccess={(data) =>
|
||||
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||
}
|
||||
onSuccess={onPublishLibSuccess}
|
||||
onError={(error) => window.alert(error)}
|
||||
updateItemsInStorage={() =>
|
||||
library.setLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
|
||||
onRemove={(id: string) =>
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publishLibSuccess && renderPublishSuccess()}
|
||||
<LibraryMenuItems
|
||||
isLoading={libraryItemsData.status === "loading"}
|
||||
libraryItems={libraryItemsData.libraryItems}
|
||||
onRemoveFromLibrary={() =>
|
||||
removeFromLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onAddToLibrary={(elements) =>
|
||||
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||
}
|
||||
onInsertLibraryItems={onInsertLibraryItems}
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
appState={appState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
theme={theme}
|
||||
files={files}
|
||||
id={id}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={(ids) => setSelectedItems(ids)}
|
||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
</LibraryMenuWrapper>
|
||||
|
||||
{loadingState === "loading" ? (
|
||||
<div className="layer-ui__library-message">
|
||||
{t("labels.libraryLoadingMessage")}
|
||||
</div>
|
||||
) : (
|
||||
<LibraryMenuItems
|
||||
libraryItems={libraryItems}
|
||||
onRemoveFromLibrary={removeFromLibrary}
|
||||
onAddToLibrary={addToLibrary}
|
||||
onInsertShape={onInsertShape}
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
theme={theme}
|
||||
files={files}
|
||||
id={id}
|
||||
selectedItems={selectedItems}
|
||||
onToggle={(id, event) => {
|
||||
const shouldSelect = !selectedItems.includes(id);
|
||||
|
||||
if (shouldSelect) {
|
||||
if (event.shiftKey && lastSelectedItem) {
|
||||
const rangeStart = libraryItems.findIndex(
|
||||
(item) => item.id === lastSelectedItem,
|
||||
);
|
||||
const rangeEnd = libraryItems.findIndex(
|
||||
(item) => item.id === id,
|
||||
);
|
||||
|
||||
if (rangeStart === -1 || rangeEnd === -1) {
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedItemsMap = arrayToMap(selectedItems);
|
||||
const nextSelectedIds = libraryItems.reduce(
|
||||
(acc: LibraryItem["id"][], item, idx) => {
|
||||
if (
|
||||
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||
selectedItemsMap.has(item.id)
|
||||
) {
|
||||
acc.push(item.id);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
setSelectedItems(nextSelectedIds);
|
||||
} else {
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
}
|
||||
setLastSelectedItem(id);
|
||||
} else {
|
||||
setLastSelectedItem(null);
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||
}
|
||||
}}
|
||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
);
|
||||
};
|
||||
|
@ -2,17 +2,8 @@
|
||||
|
||||
.excalidraw {
|
||||
.library-menu-items-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
.library-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
margin-right: auto;
|
||||
align-items: center;
|
||||
|
||||
button .library-actions-counter {
|
||||
position: absolute;
|
||||
@ -96,16 +87,12 @@
|
||||
}
|
||||
}
|
||||
&__items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
margin-bottom: 1rem;
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.6em 0.2em;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { chunk } from "lodash";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
|
||||
import { useCallback, useState } from "react";
|
||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||
import Library from "../data/library";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
@ -11,57 +11,48 @@ import {
|
||||
LibraryItem,
|
||||
LibraryItems,
|
||||
} from "../types";
|
||||
import { arrayToMap, muteFSAbortError } from "../utils";
|
||||
import { useDevice } from "./App";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { useIsMobile } from "./App";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./LibraryMenuItems.scss";
|
||||
import { MIME_TYPES, VERSIONS } from "../constants";
|
||||
import Spinner from "./Spinner";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
|
||||
import { SidebarLockButton } from "./SidebarLockButton";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { VERSIONS } from "../constants";
|
||||
|
||||
const LibraryMenuItems = ({
|
||||
isLoading,
|
||||
libraryItems,
|
||||
onRemoveFromLibrary,
|
||||
onAddToLibrary,
|
||||
onInsertLibraryItems,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
theme,
|
||||
setAppState,
|
||||
appState,
|
||||
libraryReturnUrl,
|
||||
library,
|
||||
files,
|
||||
id,
|
||||
selectedItems,
|
||||
onSelectItems,
|
||||
onToggle,
|
||||
onPublish,
|
||||
resetLibrary,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
libraryItems: LibraryItems;
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onRemoveFromLibrary: () => void;
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
appState: AppState;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
library: Library;
|
||||
id: string;
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
|
||||
onPublish: () => void;
|
||||
resetLibrary: () => void;
|
||||
}) => {
|
||||
@ -93,7 +84,9 @@ const LibraryMenuItems = ({
|
||||
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
||||
|
||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||
const device = useDevice();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const renderLibraryActions = () => {
|
||||
const itemsSelected = !!selectedItems.length;
|
||||
const items = itemsSelected
|
||||
@ -104,34 +97,24 @@ const LibraryMenuItems = ({
|
||||
: t("buttons.resetLibrary");
|
||||
return (
|
||||
<div className="library-actions">
|
||||
{!itemsSelected && (
|
||||
{(!itemsSelected || !isMobile) && (
|
||||
<ToolButton
|
||||
key="import"
|
||||
type="button"
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
icon={load}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await library.updateLibrary({
|
||||
libraryItems: fileOpen({
|
||||
description: "Excalidraw library files",
|
||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||
/*
|
||||
extensions: [".json", ".excalidrawlib"],
|
||||
*/
|
||||
}),
|
||||
merge: true,
|
||||
openLibraryMenu: true,
|
||||
onClick={() => {
|
||||
importLibraryFromJSON(library)
|
||||
.then(() => {
|
||||
// Close and then open to get the libraries updated
|
||||
setAppState({ isLibraryOpen: false });
|
||||
setAppState({ isLibraryOpen: true });
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
console.warn(error);
|
||||
return;
|
||||
}
|
||||
setAppState({ errorMessage: t("errors.importLibraryError") });
|
||||
}
|
||||
}}
|
||||
className="library-actions--load"
|
||||
/>
|
||||
@ -147,7 +130,7 @@ const LibraryMenuItems = ({
|
||||
onClick={async () => {
|
||||
const libraryItems = itemsSelected
|
||||
? items
|
||||
: await library.getLatestLibrary();
|
||||
: await library.loadLibrary();
|
||||
saveLibraryAsJSON(libraryItems)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
@ -179,7 +162,7 @@ const LibraryMenuItems = ({
|
||||
</ToolButton>
|
||||
</>
|
||||
)}
|
||||
{itemsSelected && (
|
||||
{itemsSelected && !isPublished && (
|
||||
<Tooltip label={t("hints.publishLibrary")}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
@ -189,7 +172,7 @@ const LibraryMenuItems = ({
|
||||
className="library-actions--publish"
|
||||
onClick={onPublish}
|
||||
>
|
||||
{!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
||||
{!isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
@ -198,89 +181,17 @@ const LibraryMenuItems = ({
|
||||
</ToolButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{device.isMobile && (
|
||||
<div className="library-menu-browse-button--mobile">
|
||||
<a
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4;
|
||||
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||
|
||||
const referrer =
|
||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||
|
||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||
LibraryItem["id"] | null
|
||||
>(null);
|
||||
|
||||
const onItemSelectToggle = (
|
||||
id: LibraryItem["id"],
|
||||
event: React.MouseEvent,
|
||||
) => {
|
||||
const shouldSelect = !selectedItems.includes(id);
|
||||
|
||||
const orderedItems = [...unpublishedItems, ...publishedItems];
|
||||
|
||||
if (shouldSelect) {
|
||||
if (event.shiftKey && lastSelectedItem) {
|
||||
const rangeStart = orderedItems.findIndex(
|
||||
(item) => item.id === lastSelectedItem,
|
||||
);
|
||||
const rangeEnd = orderedItems.findIndex((item) => item.id === id);
|
||||
|
||||
if (rangeStart === -1 || rangeEnd === -1) {
|
||||
onSelectItems([...selectedItems, id]);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedItemsMap = arrayToMap(selectedItems);
|
||||
const nextSelectedIds = orderedItems.reduce(
|
||||
(acc: LibraryItem["id"][], item, idx) => {
|
||||
if (
|
||||
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||
selectedItemsMap.has(item.id)
|
||||
) {
|
||||
acc.push(item.id);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
onSelectItems(nextSelectedIds);
|
||||
} else {
|
||||
onSelectItems([...selectedItems, id]);
|
||||
}
|
||||
setLastSelectedItem(id);
|
||||
} else {
|
||||
setLastSelectedItem(null);
|
||||
onSelectItems(selectedItems.filter((_id) => _id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const getInsertedElements = (id: string) => {
|
||||
let targetElements;
|
||||
if (selectedItems.includes(id)) {
|
||||
targetElements = libraryItems.filter((item) =>
|
||||
selectedItems.includes(item.id),
|
||||
);
|
||||
} else {
|
||||
targetElements = libraryItems.filter((item) => item.id === id);
|
||||
}
|
||||
return targetElements;
|
||||
};
|
||||
const isPublished = selectedItems.some(
|
||||
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
|
||||
);
|
||||
|
||||
const createLibraryItemCompo = (params: {
|
||||
item:
|
||||
@ -302,12 +213,8 @@ const LibraryMenuItems = ({
|
||||
onClick={params.onClick || (() => {})}
|
||||
id={params.item?.id || null}
|
||||
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
|
||||
onToggle={onItemSelectToggle}
|
||||
onDrag={(id, event) => {
|
||||
event.dataTransfer.setData(
|
||||
MIME_TYPES.excalidrawlib,
|
||||
serializeLibraryAsJSON(getInsertedElements(id)),
|
||||
);
|
||||
onToggle={(id, event) => {
|
||||
onToggle(id, event);
|
||||
}}
|
||||
/>
|
||||
</Stack.Col>
|
||||
@ -327,7 +234,7 @@ const LibraryMenuItems = ({
|
||||
if (item.id) {
|
||||
return createLibraryItemCompo({
|
||||
item,
|
||||
onClick: () => onInsertLibraryItems(getInsertedElements(item.id)),
|
||||
onClick: () => onInsertShape(item.elements),
|
||||
key: item.id,
|
||||
});
|
||||
}
|
||||
@ -366,192 +273,49 @@ const LibraryMenuItems = ({
|
||||
});
|
||||
};
|
||||
|
||||
const unpublishedItems = libraryItems.filter(
|
||||
(item) => item.status !== "published",
|
||||
);
|
||||
const publishedItems = libraryItems.filter(
|
||||
(item) => item.status === "published",
|
||||
);
|
||||
const unpublishedItems = [
|
||||
// append pending library item
|
||||
...(pendingElements.length
|
||||
? [{ id: null, elements: pendingElements }]
|
||||
: []),
|
||||
...libraryItems.filter((item) => item.status !== "published"),
|
||||
];
|
||||
|
||||
const renderLibraryHeader = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="layer-ui__library-header" key="library-header">
|
||||
{renderLibraryActions()}
|
||||
{device.canDeviceFitSidebar && (
|
||||
<>
|
||||
<div className="layer-ui__sidebar-lock-button">
|
||||
<SidebarLockButton
|
||||
checked={appState.isLibraryMenuDocked}
|
||||
onChange={() => {
|
||||
document
|
||||
.querySelector(".layer-ui__wrapper")
|
||||
?.classList.add("animate");
|
||||
const nextState = !appState.isLibraryMenuDocked;
|
||||
setAppState({
|
||||
isLibraryMenuDocked: nextState,
|
||||
});
|
||||
trackEvent(
|
||||
"library",
|
||||
`toggleLibraryDock (${nextState ? "dock" : "undock"})`,
|
||||
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!device.isMobile && (
|
||||
<div className="ToolIcon__icon__close">
|
||||
<button
|
||||
className="Modal__close"
|
||||
onClick={() =>
|
||||
setAppState({
|
||||
isLibraryOpen: false,
|
||||
})
|
||||
}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{close}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLibraryMenuItems = () => {
|
||||
return (
|
||||
return (
|
||||
<div className="library-menu-items-container">
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
<div className="layer-ui__library-header" key="library-header">
|
||||
{renderLibraryActions()}
|
||||
<a
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
</div>
|
||||
<Stack.Col
|
||||
className="library-menu-items-container__items"
|
||||
align="start"
|
||||
gap={1}
|
||||
style={{
|
||||
flex: publishedItems.length > 0 ? 1 : "0 1 auto",
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<div className="separator">
|
||||
{(pendingElements.length > 0 ||
|
||||
unpublishedItems.length > 0 ||
|
||||
publishedItems.length > 0) && (
|
||||
<div>{t("labels.personalLib")}</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
marginRight: "1rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontWeight: "normal",
|
||||
}}
|
||||
>
|
||||
<div style={{ transform: "translateY(2px)" }}>
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!pendingElements.length && !unpublishedItems.length ? (
|
||||
<div
|
||||
style={{
|
||||
height: 65,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
fontSize: ".9rem",
|
||||
}}
|
||||
>
|
||||
{t("library.noItems")}
|
||||
<div
|
||||
style={{
|
||||
margin: ".6rem 0",
|
||||
fontSize: ".8em",
|
||||
width: "70%",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{publishedItems.length > 0
|
||||
? t("library.hint_emptyPrivateLibrary")
|
||||
: t("library.hint_emptyLibrary")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
renderLibrarySection([
|
||||
// append pending library item
|
||||
...(pendingElements.length
|
||||
? [{ id: null, elements: pendingElements }]
|
||||
: []),
|
||||
...unpublishedItems,
|
||||
])
|
||||
)}
|
||||
<div className="separator">{t("labels.personalLib")}</div>
|
||||
{renderLibrarySection(unpublishedItems)}
|
||||
</>
|
||||
|
||||
<>
|
||||
{(publishedItems.length > 0 ||
|
||||
(!device.isMobile &&
|
||||
(pendingElements.length > 0 || unpublishedItems.length > 0))) && (
|
||||
<div className="separator">{t("labels.excalidrawLib")}</div>
|
||||
)}
|
||||
{publishedItems.length > 0 ? (
|
||||
renderLibrarySection(publishedItems)
|
||||
) : unpublishedItems.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
margin: "1rem 0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
fontSize: ".9rem",
|
||||
}}
|
||||
>
|
||||
{t("library.noItems")}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="separator">{t("labels.excalidrawLib")} </div>
|
||||
|
||||
{renderLibrarySection(publishedItems)}
|
||||
</>
|
||||
</Stack.Col>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLibraryFooter = () => {
|
||||
return (
|
||||
<a
|
||||
className="library-menu-browse-button"
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="library-menu-items-container"
|
||||
style={
|
||||
device.isMobile
|
||||
? {
|
||||
minHeight: "200px",
|
||||
maxHeight: "70vh",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
{renderLibraryHeader()}
|
||||
{renderLibraryMenuItems()}
|
||||
{!device.isMobile && renderLibraryFooter()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -3,7 +3,7 @@
|
||||
.excalidraw {
|
||||
.library-unit {
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border: 1px solid var(--button-gray-2);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
@ -21,6 +21,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark .library-unit {
|
||||
border-color: rgb(48, 48, 48);
|
||||
}
|
||||
|
||||
.library-unit__dragger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -1,7 +1,8 @@
|
||||
import clsx from "clsx";
|
||||
import oc from "open-color";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDevice } from "../components/App";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { BinaryFiles, LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
@ -28,7 +29,6 @@ export const LibraryUnit = ({
|
||||
onClick,
|
||||
selected,
|
||||
onToggle,
|
||||
onDrag,
|
||||
}: {
|
||||
id: LibraryItem["id"] | /** for pending item */ null;
|
||||
elements?: LibraryItem["elements"];
|
||||
@ -37,7 +37,6 @@ export const LibraryUnit = ({
|
||||
onClick: () => void;
|
||||
selected: boolean;
|
||||
onToggle: (id: string, event: React.MouseEvent) => void;
|
||||
onDrag: (id: string, event: React.DragEvent) => void;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
@ -67,7 +66,7 @@ export const LibraryUnit = ({
|
||||
}, [elements, files]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useDevice().isMobile;
|
||||
const isMobile = useIsMobile();
|
||||
const adder = isPending && (
|
||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||
);
|
||||
@ -100,12 +99,11 @@ export const LibraryUnit = ({
|
||||
: undefined
|
||||
}
|
||||
onDragStart={(event) => {
|
||||
if (!id) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
setIsHovered(false);
|
||||
onDrag(id, event);
|
||||
event.dataTransfer.setData(
|
||||
MIME_TYPES.excalidrawlib,
|
||||
JSON.stringify(elements),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{adder}
|
||||
|
@ -1,30 +1,10 @@
|
||||
import { t } from "../i18n";
|
||||
import { useState, useEffect } from "react";
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
export const LoadingMessage: React.FC<{ delay?: number }> = ({ delay }) => {
|
||||
const [isWaiting, setIsWaiting] = useState(!!delay);
|
||||
|
||||
useEffect(() => {
|
||||
if (!delay) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setIsWaiting(false);
|
||||
}, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
if (isWaiting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const LoadingMessage = () => {
|
||||
// !! KEEP THIS IN SYNC WITH index.html !!
|
||||
return (
|
||||
<div className="LoadingMessage">
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div className="LoadingMessage-text">{t("labels.loadingScene")}</div>
|
||||
<span>{t("labels.loadingScene")}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -8,7 +8,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { Island } from "./Island";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import CollabButton from "./CollabButton";
|
||||
@ -32,10 +32,7 @@ type MobileMenuProps = {
|
||||
onPenModeToggle: () => void;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
isCollaborating: boolean;
|
||||
renderCustomFooter?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
showThemeBtn: boolean;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
@ -43,7 +40,6 @@ type MobileMenuProps = {
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
renderStats: () => JSX.Element | null;
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
@ -64,7 +60,6 @@ export const MobileMenu = ({
|
||||
showThemeBtn,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
renderStats,
|
||||
}: MobileMenuProps) => {
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
@ -77,9 +72,8 @@ export const MobileMenu = ({
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
activeTool={appState.activeTool}
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
@ -91,7 +85,7 @@ export const MobileMenu = ({
|
||||
</Island>
|
||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
<LockButton
|
||||
checked={appState.activeTool.locked}
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
isMobile
|
||||
@ -119,12 +113,6 @@ export const MobileMenu = ({
|
||||
};
|
||||
|
||||
const renderAppToolbar = () => {
|
||||
// Render eraser conditionally in mobile
|
||||
const showEraser =
|
||||
!appState.viewModeEnabled &&
|
||||
!appState.editingElement &&
|
||||
getSelectedElements(elements, appState).length === 0;
|
||||
|
||||
if (viewModeEnabled) {
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
@ -132,16 +120,12 @@ export const MobileMenu = ({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
{actionManager.renderAction("toggleCanvasMenu")}
|
||||
{actionManager.renderAction("toggleEditMenu")}
|
||||
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
{showEraser && actionManager.renderAction("eraser")}
|
||||
|
||||
{actionManager.renderAction(
|
||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||
)}
|
||||
@ -186,7 +170,6 @@ export const MobileMenu = ({
|
||||
return (
|
||||
<>
|
||||
{!viewModeEnabled && renderToolbar()}
|
||||
{renderStats()}
|
||||
<div
|
||||
className="App-bottom-bar"
|
||||
style={{
|
||||
@ -205,11 +188,20 @@ export const MobileMenu = ({
|
||||
{appState.collaborators.size > 0 && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
<UserList
|
||||
mobile
|
||||
collaborators={appState.collaborators}
|
||||
actionManager={actionManager}
|
||||
/>
|
||||
<UserList mobile>
|
||||
{Array.from(appState.collaborators)
|
||||
// Collaborator is either not initialized or is actually the current user.
|
||||
.filter(
|
||||
([_, client]) => Object.keys(client).length !== 0,
|
||||
)
|
||||
.map(([clientId, client]) => (
|
||||
<React.Fragment key={clientId}>
|
||||
{actionManager.renderAction("goToCollaborator", {
|
||||
id: clientId,
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</UserList>
|
||||
</fieldset>
|
||||
)}
|
||||
</Stack.Col>
|
||||
@ -223,7 +215,7 @@ export const MobileMenu = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
activeTool={appState.activeTool.type}
|
||||
elementType={appState.elementType}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
|
@ -4,7 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { KEYS } from "../keys";
|
||||
import { useExcalidrawContainer, useDevice } from "./App";
|
||||
import { useExcalidrawContainer, useIsMobile } from "./App";
|
||||
import { AppState } from "../types";
|
||||
import { THEME } from "../constants";
|
||||
|
||||
@ -59,17 +59,17 @@ export const Modal = (props: {
|
||||
const useBodyRoot = (theme: AppState["theme"]) => {
|
||||
const [div, setDiv] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const device = useDevice();
|
||||
const isMobileRef = useRef(device.isMobile);
|
||||
isMobileRef.current = device.isMobile;
|
||||
const isMobile = useIsMobile();
|
||||
const isMobileRef = useRef(isMobile);
|
||||
isMobileRef.current = isMobile;
|
||||
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (div) {
|
||||
div.classList.toggle("excalidraw--mobile", device.isMobile);
|
||||
div.classList.toggle("excalidraw--mobile", isMobile);
|
||||
}
|
||||
}, [div, device.isMobile]);
|
||||
}, [div, isMobile]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const isDarkTheme =
|
||||
|
@ -1,8 +1,6 @@
|
||||
import React, { useLayoutEffect, useRef, useEffect } from "react";
|
||||
import "./Popover.scss";
|
||||
import { unstable_batchedUpdates } from "react-dom";
|
||||
import { queryFocusableElements } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
type Props = {
|
||||
top?: number;
|
||||
@ -29,41 +27,6 @@ export const Popover = ({
|
||||
}: Props) => {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const container = popoverRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.TAB) {
|
||||
const focusableElements = queryFocusableElements(container);
|
||||
const { activeElement } = document;
|
||||
const currentIndex = focusableElements.findIndex(
|
||||
(element) => element === activeElement,
|
||||
);
|
||||
|
||||
if (currentIndex === 0 && event.shiftKey) {
|
||||
focusableElements[focusableElements.length - 1].focus();
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
} else if (
|
||||
currentIndex === focusableElements.length - 1 &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
focusableElements[0].focus();
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => container.removeEventListener("keydown", handleKeyDown);
|
||||
}, [container]);
|
||||
|
||||
// ensure the popover doesn't overflow the viewport
|
||||
useLayoutEffect(() => {
|
||||
if (fitInViewport && popoverRef.current) {
|
||||
|
@ -82,10 +82,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&-warning {
|
||||
color: $oc-red-6;
|
||||
}
|
||||
|
||||
&-note {
|
||||
padding: 1em;
|
||||
font-style: italic;
|
||||
|
@ -295,11 +295,6 @@ const PublishLibrary = ({
|
||||
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
|
||||
|
||||
const shouldRenderForm = !!libraryItems.length;
|
||||
|
||||
const containsPublishedItems = libraryItems.some(
|
||||
(item) => item.status === "published",
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={onDialogClose}
|
||||
@ -334,11 +329,6 @@ const PublishLibrary = ({
|
||||
<div className="publish-library-note">
|
||||
{t("publishDialog.noteItems")}
|
||||
</div>
|
||||
{containsPublishedItems && (
|
||||
<span className="publish-library-note publish-library-warning">
|
||||
{t("publishDialog.republishWarning")}
|
||||
</span>
|
||||
)}
|
||||
{renderLibraryItems()}
|
||||
<div className="publish-library__fields">
|
||||
<label>
|
||||
|
@ -1,22 +0,0 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__sidebar-lock-button {
|
||||
@include toolbarButtonColorStates;
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
.ToolIcon_type_floating .side_lock_icon {
|
||||
width: calc(var(--space-factor) * 7);
|
||||
height: calc(var(--space-factor) * 7);
|
||||
svg {
|
||||
// mirror
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon_type_checkbox {
|
||||
&:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,46 +0,0 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "./ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./SidebarLockButton.scss";
|
||||
|
||||
type SidebarLockIconProps = {
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
||||
|
||||
const SIDE_LIBRARY_TOGGLE_ICON = (
|
||||
<svg viewBox="0 0 24 24" fill="#ffffff">
|
||||
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SidebarLockButton = (props: SidebarLockIconProps) => {
|
||||
return (
|
||||
<Tooltip label={t("labels.sidebarLock")}>
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
aria-label={t("labels.sidebarLock")}
|
||||
/>{" "}
|
||||
<div className="ToolIcon__icon side_lock_icon" tabIndex={0}>
|
||||
{SIDE_LIBRARY_TOGGLE_ICON}
|
||||
</div>{" "}
|
||||
</label>{" "}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
@ -3,24 +3,11 @@
|
||||
.excalidraw {
|
||||
.single-library-item {
|
||||
position: relative;
|
||||
|
||||
&-status {
|
||||
position: absolute;
|
||||
top: 0.3rem;
|
||||
left: 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
color: $oc-red-7;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 0.1rem 0.2rem;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
&__svg {
|
||||
background-color: $oc-white;
|
||||
padding: 0.3rem;
|
||||
width: 7.5rem;
|
||||
height: 7.5rem;
|
||||
border: 1px solid var(--button-gray-2);
|
||||
margin: 0.3rem;
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@ -53,7 +40,7 @@
|
||||
&--remove {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
right: 1rem;
|
||||
right: 1.3rem;
|
||||
|
||||
.ToolIcon__icon {
|
||||
margin: 0;
|
||||
|
@ -45,11 +45,6 @@ const SingleLibraryItem = ({
|
||||
|
||||
return (
|
||||
<div className="single-library-item">
|
||||
{libItem.status === "published" && (
|
||||
<span className="single-library-item-status">
|
||||
{t("labels.statusPublished")}
|
||||
</span>
|
||||
)}
|
||||
<div ref={svgRef} className="single-library-item__svg" />
|
||||
<ToolButton
|
||||
aria-label={t("buttons.remove")}
|
||||
|
@ -41,7 +41,6 @@ const ColStack = ({
|
||||
align,
|
||||
justifyContent,
|
||||
className,
|
||||
style,
|
||||
}: StackProps) => {
|
||||
return (
|
||||
<div
|
||||
@ -50,7 +49,6 @@ const ColStack = ({
|
||||
"--gap": gap,
|
||||
justifyItems: align,
|
||||
justifyContent,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
@ -7,7 +7,6 @@
|
||||
right: 12px;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
pointer-events: all;
|
||||
|
||||
h3 {
|
||||
margin: 0 24px 8px 0;
|
||||
|
@ -2,7 +2,7 @@ import React from "react";
|
||||
import { getCommonBounds } from "../element/bounds";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { getTargetElements } from "../scene";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import { close } from "./icons";
|
||||
@ -16,13 +16,16 @@ export const Stats = (props: {
|
||||
onClose: () => void;
|
||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const boundingBox = getCommonBounds(props.elements);
|
||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||
const selectedBoundingBox = getCommonBounds(selectedElements);
|
||||
if (device.isMobile && props.appState.openMenu) {
|
||||
|
||||
if (isMobile && props.appState.openMenu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Stats">
|
||||
<Island padding={2}>
|
||||
|
@ -2,9 +2,6 @@
|
||||
|
||||
.excalidraw {
|
||||
.Toast {
|
||||
$closeButtonSize: 1.2rem;
|
||||
$closeButtonPadding: 0.4rem;
|
||||
|
||||
animation: fade-in 0.5s;
|
||||
background-color: var(--button-gray-1);
|
||||
border-radius: 4px;
|
||||
@ -18,24 +15,11 @@
|
||||
text-align: center;
|
||||
width: 300px;
|
||||
z-index: 999999;
|
||||
}
|
||||
|
||||
.Toast__message {
|
||||
padding: 0 $closeButtonSize + ($closeButtonPadding);
|
||||
color: var(--popup-text-color);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: $closeButtonPadding;
|
||||
|
||||
.ToolIcon__icon {
|
||||
width: $closeButtonSize;
|
||||
height: $closeButtonSize;
|
||||
}
|
||||
}
|
||||
.Toast__message {
|
||||
color: var(--popup-text-color);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
|
@ -1,59 +1,34 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { close } from "./icons";
|
||||
import { TOAST_TIMEOUT } from "../constants";
|
||||
import "./Toast.scss";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
const DEFAULT_TOAST_TIMEOUT = 5000;
|
||||
|
||||
export const Toast = ({
|
||||
message,
|
||||
clearToast,
|
||||
closable = false,
|
||||
// To prevent autoclose, pass duration as Infinity
|
||||
duration = DEFAULT_TOAST_TIMEOUT,
|
||||
}: {
|
||||
message: string;
|
||||
clearToast: () => void;
|
||||
closable?: boolean;
|
||||
duration?: number;
|
||||
}) => {
|
||||
const timerRef = useRef<number>(0);
|
||||
const shouldAutoClose = duration !== Infinity;
|
||||
const scheduleTimeout = useCallback(() => {
|
||||
if (!shouldAutoClose) {
|
||||
return;
|
||||
}
|
||||
timerRef.current = window.setTimeout(() => clearToast(), duration);
|
||||
}, [clearToast, duration, shouldAutoClose]);
|
||||
|
||||
const scheduleTimeout = useCallback(
|
||||
() =>
|
||||
(timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)),
|
||||
[clearToast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoClose) {
|
||||
return;
|
||||
}
|
||||
scheduleTimeout();
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [scheduleTimeout, message, duration, shouldAutoClose]);
|
||||
}, [scheduleTimeout, message]);
|
||||
|
||||
const onMouseEnter = shouldAutoClose
|
||||
? () => clearTimeout(timerRef?.current)
|
||||
: undefined;
|
||||
const onMouseLeave = shouldAutoClose ? scheduleTimeout : undefined;
|
||||
return (
|
||||
<div
|
||||
className="Toast"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseEnter={() => clearTimeout(timerRef?.current)}
|
||||
onMouseLeave={scheduleTimeout}
|
||||
>
|
||||
<p className="Toast__message">{message}</p>
|
||||
{closable && (
|
||||
<ToolButton
|
||||
icon={close}
|
||||
aria-label="close"
|
||||
type="icon"
|
||||
onClick={clearToast}
|
||||
className="close"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -48,7 +48,6 @@ type ToolButtonProps =
|
||||
type: "radio";
|
||||
checked: boolean;
|
||||
onChange?(data: { pointerType: PointerType | null }): void;
|
||||
onPointerDown?(data: { pointerType: PointerType }): void;
|
||||
});
|
||||
|
||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
@ -150,7 +149,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
title={props.title}
|
||||
onPointerDown={(event) => {
|
||||
lastPointerTypeRef.current = event.pointerType || null;
|
||||
props.onPointerDown?.({ pointerType: event.pointerType || null });
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
requestAnimationFrame(() => {
|
||||
|
@ -155,7 +155,7 @@
|
||||
}
|
||||
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
height: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,26 @@
|
||||
@import "open-color/open-color.scss";
|
||||
@import "../css/variables.module";
|
||||
|
||||
@mixin toolbarButtonColorStates {
|
||||
.ToolIcon_type_radio,
|
||||
.ToolIcon_type_checkbox {
|
||||
& + .ToolIcon__icon:active {
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
&:checked + .ToolIcon__icon {
|
||||
background: var(--color-primary);
|
||||
--icon-fill-color: #{$oc-white};
|
||||
--keybinding-color: #{$oc-white};
|
||||
}
|
||||
&:checked + .ToolIcon__icon:active {
|
||||
background: var(--color-primary-darker);
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__keybinding {
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
.App-toolbar-container {
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
// container in body where the actual tooltip is appended to
|
||||
.excalidraw-tooltip {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
|
||||
padding: 8px;
|
||||
|
@ -2,51 +2,17 @@ import "./UserList.scss";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { AppState, Collaborator } from "../types";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
|
||||
export const UserList: React.FC<{
|
||||
type UserListProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
mobile?: boolean;
|
||||
collaborators: AppState["collaborators"];
|
||||
actionManager: ActionManager;
|
||||
}> = ({ className, mobile, collaborators, actionManager }) => {
|
||||
const uniqueCollaborators = new Map<string, Collaborator>();
|
||||
|
||||
collaborators.forEach((collaborator, socketId) => {
|
||||
uniqueCollaborators.set(
|
||||
// filter on user id, else fall back on unique socketId
|
||||
collaborator.id || socketId,
|
||||
collaborator,
|
||||
);
|
||||
});
|
||||
|
||||
const avatars =
|
||||
uniqueCollaborators.size > 0 &&
|
||||
Array.from(uniqueCollaborators)
|
||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||
.map(([clientId, collaborator]) => {
|
||||
const avatarJSX = actionManager.renderAction("goToCollaborator", [
|
||||
clientId,
|
||||
collaborator,
|
||||
]);
|
||||
|
||||
return mobile ? (
|
||||
<Tooltip
|
||||
label={collaborator.username || "Unknown user"}
|
||||
key={clientId}
|
||||
>
|
||||
{avatarJSX}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={clientId}>{avatarJSX}</React.Fragment>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const UserList = ({ children, className, mobile }: UserListProps) => {
|
||||
return (
|
||||
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
|
||||
{avatars}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -934,7 +934,3 @@ export const editIcon = createIcon(
|
||||
></path>,
|
||||
{ width: 640, height: 512 },
|
||||
);
|
||||
|
||||
export const eraser = createIcon(
|
||||
<path d="M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z" />,
|
||||
);
|
||||
|
@ -63,6 +63,8 @@ export const ENV = {
|
||||
|
||||
export const CLASSES = {
|
||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||
SHAPE_ACTIONS_MOBILE_MENU: "App-mobile-menu",
|
||||
MOBILE_TOOLBAR: "App-toolbar-content",
|
||||
};
|
||||
|
||||
// 1-based in case we ever do `if(element.fontFamily)`
|
||||
@ -94,9 +96,7 @@ export const MIME_TYPES = {
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
json: "application/json",
|
||||
svg: "image/svg+xml",
|
||||
"excalidraw.svg": "image/svg+xml",
|
||||
png: "image/png",
|
||||
"excalidraw.png": "image/png",
|
||||
jpg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
binary: "application/octet-stream",
|
||||
@ -108,14 +108,14 @@ export const EXPORT_DATA_TYPES = {
|
||||
excalidrawLibrary: "excalidrawlib",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_SOURCE =
|
||||
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
||||
export const EXPORT_SOURCE = window.location.origin;
|
||||
|
||||
// time in milliseconds
|
||||
export const IMAGE_RENDER_TIMEOUT = 500;
|
||||
export const TAP_TWICE_TIMEOUT = 300;
|
||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||
export const TITLE_TIMEOUT = 10000;
|
||||
export const TOAST_TIMEOUT = 5000;
|
||||
export const VERSION_TIMEOUT = 30000;
|
||||
export const SCROLL_TIMEOUT = 100;
|
||||
export const ZOOM_STEP = 0.1;
|
||||
@ -154,19 +154,9 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
},
|
||||
};
|
||||
|
||||
// breakpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
// sm screen
|
||||
export const MQ_SM_MAX_WIDTH = 640;
|
||||
// md screen
|
||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||
// sidebar
|
||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const LIBRARY_SIDEBAR_WIDTH = parseInt(cssVariables.rightSidebarWidth);
|
||||
|
||||
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||
|
||||
@ -200,9 +190,3 @@ export const VERTICAL_ALIGN = {
|
||||
MIDDLE: "middle",
|
||||
BOTTOM: "bottom",
|
||||
};
|
||||
|
||||
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
||||
|
||||
export const COOKIES = {
|
||||
AUTH_STATE_COOKIE: "excplus-auth",
|
||||
} as const;
|
||||
|
42
src/createInverseContext.tsx
Normal file
42
src/createInverseContext.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
export const createInverseContext = <T extends unknown = null>(
|
||||
initialValue: T,
|
||||
) => {
|
||||
const Context = React.createContext(initialValue) as React.Context<T> & {
|
||||
_updateProviderValue?: (value: T) => void;
|
||||
};
|
||||
|
||||
class InverseConsumer extends React.Component {
|
||||
state = { value: initialValue };
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
Context._updateProviderValue = (value: T) => this.setState({ value });
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<Context.Provider value={this.state.value}>
|
||||
{this.props.children}
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InverseProvider extends React.Component<{ value: T }> {
|
||||
componentDidMount() {
|
||||
Context._updateProviderValue?.(this.props.value);
|
||||
}
|
||||
componentDidUpdate() {
|
||||
Context._updateProviderValue?.(this.props.value);
|
||||
}
|
||||
render() {
|
||||
return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Context,
|
||||
Consumer: InverseConsumer,
|
||||
Provider: InverseProvider,
|
||||
};
|
||||
};
|
@ -16,17 +16,15 @@
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
|
||||
.Spinner {
|
||||
font-size: 2.8em;
|
||||
}
|
||||
|
||||
.LoadingMessage-text {
|
||||
margin-top: 1em;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.LoadingMessage span {
|
||||
background-color: var(--button-gray-1);
|
||||
border-radius: 5px;
|
||||
padding: 0.8em 1.2em;
|
||||
color: var(--popup-text-color);
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
@ -290,16 +290,6 @@
|
||||
width: 100%;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
.eraser {
|
||||
&.ToolIcon:hover {
|
||||
--icon-fill-color: #fff;
|
||||
--keybinding-color: #fff;
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.App-toolbar-content {
|
||||
@ -350,6 +340,7 @@
|
||||
align-items: flex-start;
|
||||
cursor: default;
|
||||
pointer-events: none !important;
|
||||
z-index: 100;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
left: 0.25rem;
|
||||
@ -390,7 +381,6 @@
|
||||
|
||||
.App-menu__left {
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-island);
|
||||
}
|
||||
|
||||
.dropdown-select {
|
||||
@ -449,7 +439,6 @@
|
||||
bottom: 30px;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 20px;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
@ -478,17 +467,7 @@
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
|
||||
.finalize-button {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: 0.4em;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-inline-start: 0.6em;
|
||||
}
|
||||
|
||||
.undo-redo-buttons,
|
||||
.eraser-buttons {
|
||||
.undo-redo-buttons {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: 0.4em;
|
||||
@ -568,22 +547,6 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// use custom, minimalistic scrollbar
|
||||
// (doesn't work in Firefox)
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--button-gray-2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--button-gray-3);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: var(--button-gray-2);
|
||||
}
|
||||
}
|
||||
|
||||
.ErrorSplash.excalidraw {
|
||||
|
@ -6,32 +6,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
@mixin toolbarButtonColorStates {
|
||||
.ToolIcon_type_radio,
|
||||
.ToolIcon_type_checkbox {
|
||||
& + .ToolIcon__icon:active {
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
&:checked + .ToolIcon__icon {
|
||||
background: var(--color-primary);
|
||||
--icon-fill-color: #{$oc-white};
|
||||
--keybinding-color: #{$oc-white};
|
||||
}
|
||||
&:checked + .ToolIcon__icon:active {
|
||||
background: var(--color-primary-darker);
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__keybinding {
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
$theme-filter: "invert(93%) hue-rotate(180deg)";
|
||||
$right-sidebar-width: "302px";
|
||||
|
||||
:export {
|
||||
themeFilter: unquote($theme-filter);
|
||||
rightSidebarWidth: unquote($right-sidebar-width);
|
||||
}
|
||||
|
234
src/data/blob.ts
234
src/data/blob.ts
@ -1,16 +1,20 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
EXPORT_DATA_TYPES,
|
||||
MIME_TYPES,
|
||||
} from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement, FileId } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { AppState, DataURL, LibraryItem } from "../types";
|
||||
import { AppState, DataURL } from "../types";
|
||||
import { bytesToHexString } from "../utils";
|
||||
import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
|
||||
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||
import { restore, restoreLibraryItems } from "./restore";
|
||||
import { FileSystemHandle } from "./filesystem";
|
||||
import { isValidExcalidrawData } from "./json";
|
||||
import { restore } from "./restore";
|
||||
import { ImportedLibraryData } from "./types";
|
||||
|
||||
const parseFileContents = async (blob: Blob | File) => {
|
||||
@ -123,91 +127,49 @@ export const isSupportedImageFile = (
|
||||
);
|
||||
};
|
||||
|
||||
export const loadSceneOrLibraryFromBlob = async (
|
||||
blob: Blob | File,
|
||||
export const loadFromBlob = async (
|
||||
blob: Blob,
|
||||
/** @see restore.localAppState */
|
||||
localAppState: AppState | null,
|
||||
localElements: readonly ExcalidrawElement[] | null,
|
||||
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||
fileHandle?: FileSystemHandle | null,
|
||||
) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
try {
|
||||
const data = JSON.parse(contents);
|
||||
if (isValidExcalidrawData(data)) {
|
||||
return {
|
||||
type: MIME_TYPES.excalidraw,
|
||||
data: restore(
|
||||
{
|
||||
elements: clearElementsForExport(data.elements || []),
|
||||
appState: {
|
||||
theme: localAppState?.theme,
|
||||
fileHandle: fileHandle || blob.handle || null,
|
||||
...cleanAppStateForExport(data.appState || {}),
|
||||
...(localAppState
|
||||
? calculateScrollCenter(
|
||||
data.elements || [],
|
||||
localAppState,
|
||||
null,
|
||||
)
|
||||
: {}),
|
||||
},
|
||||
files: data.files,
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
),
|
||||
};
|
||||
} else if (isValidLibrary(data)) {
|
||||
return {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
data,
|
||||
};
|
||||
if (!isValidExcalidrawData(data)) {
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
}
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
const result = restore(
|
||||
{
|
||||
elements: clearElementsForExport(data.elements || []),
|
||||
appState: {
|
||||
theme: localAppState?.theme,
|
||||
fileHandle: blob.handle || null,
|
||||
...cleanAppStateForExport(data.appState || {}),
|
||||
...(localAppState
|
||||
? calculateScrollCenter(data.elements || [], localAppState, null)
|
||||
: {}),
|
||||
},
|
||||
files: data.files,
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error(error.message);
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
}
|
||||
};
|
||||
|
||||
export const loadFromBlob = async (
|
||||
blob: Blob,
|
||||
/** @see restore.localAppState */
|
||||
localAppState: AppState | null,
|
||||
localElements: readonly ExcalidrawElement[] | null,
|
||||
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||
fileHandle?: FileSystemHandle | null,
|
||||
) => {
|
||||
const ret = await loadSceneOrLibraryFromBlob(
|
||||
blob,
|
||||
localAppState,
|
||||
localElements,
|
||||
fileHandle,
|
||||
);
|
||||
if (ret.type !== MIME_TYPES.excalidraw) {
|
||||
export const loadLibraryFromBlob = async (blob: Blob) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
const data: ImportedLibraryData = JSON.parse(contents);
|
||||
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
}
|
||||
return ret.data;
|
||||
};
|
||||
|
||||
export const parseLibraryJSON = (
|
||||
json: string,
|
||||
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||
) => {
|
||||
const data: ImportedLibraryData | undefined = JSON.parse(json);
|
||||
if (!isValidLibrary(data)) {
|
||||
throw new Error("Invalid library");
|
||||
}
|
||||
const libraryItems = data.libraryItems || data.library;
|
||||
return restoreLibraryItems(libraryItems, defaultStatus);
|
||||
};
|
||||
|
||||
export const loadLibraryFromBlob = async (
|
||||
blob: Blob,
|
||||
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||
) => {
|
||||
return parseLibraryJSON(await parseFileContents(blob), defaultStatus);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const canvasToBlob = async (
|
||||
@ -238,7 +200,7 @@ export const generateIdFromFile = async (file: File): Promise<FileId> => {
|
||||
try {
|
||||
const hashBuffer = await window.crypto.subtle.digest(
|
||||
"SHA-1",
|
||||
await blobToArrayBuffer(file),
|
||||
await file.arrayBuffer(),
|
||||
);
|
||||
return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
|
||||
} catch (error: any) {
|
||||
@ -327,125 +289,3 @@ export const SVGStringToFile = (SVGString: string, filename: string = "") => {
|
||||
type: MIME_TYPES.svg,
|
||||
}) as File & { type: typeof MIME_TYPES.svg };
|
||||
};
|
||||
|
||||
export const getFileFromEvent = async (
|
||||
event: React.DragEvent<HTMLDivElement>,
|
||||
) => {
|
||||
const file = event.dataTransfer.files.item(0);
|
||||
const fileHandle = await getFileHandle(event);
|
||||
|
||||
return { file: file ? await normalizeFile(file) : null, fileHandle };
|
||||
};
|
||||
|
||||
export const getFileHandle = async (
|
||||
event: React.DragEvent<HTMLDivElement>,
|
||||
): Promise<FileSystemHandle | null> => {
|
||||
if (nativeFileSystemSupported) {
|
||||
try {
|
||||
const item = event.dataTransfer.items[0];
|
||||
const handle: FileSystemHandle | null =
|
||||
(await (item as any).getAsFileSystemHandle()) || null;
|
||||
|
||||
return handle;
|
||||
} catch (error: any) {
|
||||
console.warn(error.name, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* attemps to detect if a buffer is a valid image by checking its leading bytes
|
||||
*/
|
||||
const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => {
|
||||
let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
|
||||
null;
|
||||
|
||||
const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `;
|
||||
|
||||
// uint8 leading bytes
|
||||
const headerBytes = {
|
||||
// https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header
|
||||
png: "137 80 78 71 13 10 26 10 ",
|
||||
// https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure
|
||||
// jpg is a bit wonky. Checking the first three bytes should be enough,
|
||||
// but may yield false positives. (https://stackoverflow.com/a/23360709/927631)
|
||||
jpg: "255 216 255 ",
|
||||
// https://en.wikipedia.org/wiki/GIF#Example_GIF_file
|
||||
gif: "71 73 70 56 57 97 ",
|
||||
};
|
||||
|
||||
if (first8Bytes === headerBytes.png) {
|
||||
mimeType = MIME_TYPES.png;
|
||||
} else if (first8Bytes.startsWith(headerBytes.jpg)) {
|
||||
mimeType = MIME_TYPES.jpg;
|
||||
} else if (first8Bytes.startsWith(headerBytes.gif)) {
|
||||
mimeType = MIME_TYPES.gif;
|
||||
}
|
||||
return mimeType;
|
||||
};
|
||||
|
||||
export const createFile = (
|
||||
blob: File | Blob | ArrayBuffer,
|
||||
mimeType: ValueOf<typeof MIME_TYPES>,
|
||||
name: string | undefined,
|
||||
) => {
|
||||
return new File([blob], name || "", {
|
||||
type: mimeType,
|
||||
});
|
||||
};
|
||||
|
||||
/** attemps to detect correct mimeType if none is set, or if an image
|
||||
* has an incorrect extension.
|
||||
* Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
|
||||
export const normalizeFile = async (file: File) => {
|
||||
if (!file.type) {
|
||||
if (file?.name?.endsWith(".excalidrawlib")) {
|
||||
file = createFile(
|
||||
await blobToArrayBuffer(file),
|
||||
MIME_TYPES.excalidrawlib,
|
||||
file.name,
|
||||
);
|
||||
} else if (file?.name?.endsWith(".excalidraw")) {
|
||||
file = createFile(
|
||||
await blobToArrayBuffer(file),
|
||||
MIME_TYPES.excalidraw,
|
||||
file.name,
|
||||
);
|
||||
} else {
|
||||
const buffer = await blobToArrayBuffer(file);
|
||||
const mimeType = getActualMimeTypeFromImage(buffer);
|
||||
if (mimeType) {
|
||||
file = createFile(buffer, mimeType, file.name);
|
||||
}
|
||||
}
|
||||
// when the file is an image, make sure the extension corresponds to the
|
||||
// actual mimeType (this is an edge case, but happens sometime)
|
||||
} else if (isSupportedImageFile(file)) {
|
||||
const buffer = await blobToArrayBuffer(file);
|
||||
const mimeType = getActualMimeTypeFromImage(buffer);
|
||||
if (mimeType && mimeType !== file.type) {
|
||||
file = createFile(buffer, mimeType, file.name);
|
||||
}
|
||||
}
|
||||
|
||||
return file;
|
||||
};
|
||||
|
||||
export const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
|
||||
if ("arrayBuffer" in blob) {
|
||||
return blob.arrayBuffer();
|
||||
}
|
||||
// Safari
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (!event.target?.result) {
|
||||
return reject(new Error("Couldn't convert blob to ArrayBuffer"));
|
||||
}
|
||||
resolve(event.target.result as ArrayBuffer);
|
||||
};
|
||||
reader.readAsArrayBuffer(blob);
|
||||
});
|
||||
};
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user