mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
8 Commits
expose_app
...
aakansha-p
Author | SHA1 | Date | |
---|---|---|---|
e9d5b41fbf | |||
ef5614b697 | |||
cd5d9111e9 | |||
8467093e83 | |||
504ea987b0 | |||
ac08f433e9 | |||
4fb906baad | |||
bfc8415554 |
@ -5,4 +5,3 @@ package-lock.json
|
||||
firebase/
|
||||
dist/
|
||||
public/workbox
|
||||
src/packages/excalidraw/types
|
||||
|
26
.github/workflows/autorelease-excalidraw.yml
vendored
26
.github/workflows/autorelease-excalidraw.yml
vendored
@ -1,26 +0,0 @@
|
||||
name: Auto release @excalidraw/excalidraw-next
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
Auto-release-excalidraw-next:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js 14.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
- name: Set up publish access
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Auto release
|
||||
run: |
|
||||
yarn autorelease
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,11 +5,9 @@
|
||||
.env.test.local
|
||||
.envrc
|
||||
.eslintcache
|
||||
.history
|
||||
.idea
|
||||
.vercel
|
||||
.vscode
|
||||
.yarn
|
||||
*.log
|
||||
*.tgz
|
||||
build
|
||||
|
@ -10,7 +10,7 @@ ARG NODE_ENV=production
|
||||
COPY . .
|
||||
RUN yarn build:app:docker
|
||||
|
||||
FROM nginx:1.21-alpine
|
||||
FROM nginx:1.17-alpine
|
||||
|
||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
||||
|
||||
|
18
README.md
18
README.md
@ -70,8 +70,6 @@ The first set of digits is the room. This is visible from the server that’s go
|
||||
|
||||
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
||||
|
||||
> Note: Please ensure that the encryption key is 22 characters long.
|
||||
|
||||
## Shape libraries
|
||||
|
||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||
@ -95,7 +93,7 @@ These instructions will get you a copy of the project up and running on your loc
|
||||
#### Requirements
|
||||
|
||||
- [Node.js](https://nodejs.org/en/)
|
||||
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
|
||||
- [Yarn](https://yarnpkg.com/getting-started/install)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
#### Clone the repo
|
||||
@ -104,20 +102,6 @@ These instructions will get you a copy of the project up and running on your loc
|
||||
git clone https://github.com/excalidraw/excalidraw.git
|
||||
```
|
||||
|
||||
#### Install the dependencies
|
||||
|
||||
```bash
|
||||
yarn
|
||||
```
|
||||
|
||||
#### Start the server
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
|
||||
|
||||
#### Commands
|
||||
|
||||
| Command | Description |
|
||||
|
@ -2,8 +2,5 @@
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"storage": {
|
||||
"rules": "storage.rules"
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +0,0 @@
|
||||
rules_version = '2';
|
||||
service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /{migrations} {
|
||||
match /{scenes}/{scene} {
|
||||
allow get, write: if true;
|
||||
// redundant, but let's be explicit'
|
||||
allow list: if false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
21
package.json
21
package.json
@ -19,28 +19,22 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@dwelle/browser-fs-access": "0.21.3",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"@testing-library/jest-dom": "5.11.10",
|
||||
"@testing-library/react": "11.2.6",
|
||||
"@tldraw/vec": "0.0.106",
|
||||
"@types/jest": "26.0.22",
|
||||
"@types/pica": "5.1.3",
|
||||
"@types/react": "17.0.3",
|
||||
"@types/react-dom": "17.0.3",
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"browser-fs-access": "0.16.4",
|
||||
"clsx": "1.1.1",
|
||||
"fake-indexeddb": "3.1.3",
|
||||
"firebase": "8.3.3",
|
||||
"firebase": "8.3.2",
|
||||
"i18next-browser-languagedetector": "6.1.0",
|
||||
"idb-keyval": "5.1.3",
|
||||
"image-blob-reduce": "3.0.1",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"nanoid": "3.1.22",
|
||||
"open-color": "1.8.0",
|
||||
"pako": "1.0.11",
|
||||
"perfect-freehand": "1.0.15",
|
||||
"png-chunk-text": "1.0.0",
|
||||
"png-chunks-encode": "1.0.0",
|
||||
"png-chunks-extract": "1.0.0",
|
||||
@ -49,8 +43,8 @@
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"roughjs": "4.4.1",
|
||||
"sass": "1.32.10",
|
||||
"roughjs": "4.3.1",
|
||||
"sass": "1.32.8",
|
||||
"socket.io-client": "2.3.1",
|
||||
"typescript": "4.2.4"
|
||||
},
|
||||
@ -60,7 +54,7 @@
|
||||
"@types/lodash.throttle": "4.1.6",
|
||||
"@types/pako": "1.0.1",
|
||||
"@types/resize-observer-browser": "0.1.5",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-config-prettier": "8.1.0",
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"firebase-tools": "9.9.0",
|
||||
"husky": "4.3.8",
|
||||
@ -81,7 +75,7 @@
|
||||
},
|
||||
"jest": {
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)"
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
||||
],
|
||||
"resetMocks": false
|
||||
},
|
||||
@ -109,7 +103,6 @@
|
||||
"test:other": "yarn prettier --list-different",
|
||||
"test:typecheck": "tsc",
|
||||
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
||||
"test": "yarn test:app",
|
||||
"autorelease": "node scripts/autorelease.js"
|
||||
"test": "yarn test:app"
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
@ -13,18 +13,6 @@
|
||||
|
||||
<meta name="theme-color" content="#000" />
|
||||
|
||||
<!-- Declarative Link Capturing (https://web.dev/declarative-link-capturing/) -->
|
||||
<meta
|
||||
http-equiv="origin-trial"
|
||||
content="Ak3VyzTheARtX2CnxBZ3ZKxImB0mNyvDakmMxeAChgxrWFMZ3IGN64VP+uj36VxM5OegsbLmrP258b1xvqp7+Q8AAABbeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJBcHBMaW5rQ2FwdHVyaW5nIiwiZXhwaXJ5IjoxNjM0MDgzMTk5fQ=="
|
||||
/>
|
||||
|
||||
<!-- File Handling (https://web.dev/file-handling/) -->
|
||||
<meta
|
||||
http-equiv="origin-trial"
|
||||
content="AkMQsAnFmKfRfPKQHNCv2WmZREqgwkqhyt2M7aOwQiCStB+hPYnGnv+mNbkPDAsGXrwsj/waFi76wPzTDUaEeQ0AAABUeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJGaWxlSGFuZGxpbmciLCJleHBpcnkiOjE2MzQwODMxOTl9"
|
||||
/>
|
||||
|
||||
<!-- General tags -->
|
||||
<meta
|
||||
name="description"
|
||||
@ -119,17 +107,15 @@
|
||||
|
||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
body {
|
||||
margin: 0;
|
||||
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||
Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: var(--ui-font);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
@ -139,7 +125,6 @@
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap; /* added line */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.LoadingMessage {
|
||||
@ -164,21 +149,6 @@
|
||||
}
|
||||
#root {
|
||||
height: 100%;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
@media screen and (min-width: 1200px) {
|
||||
-webkit-touch-callout: default;
|
||||
-webkit-user-select: auto;
|
||||
-khtml-user-select: auto;
|
||||
-moz-user-select: auto;
|
||||
-ms-user-select: auto;
|
||||
user-select: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
@ -26,7 +26,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"capture_links": "new-client",
|
||||
"capture_links": "new_client",
|
||||
"share_target": {
|
||||
"action": "/web-share-target",
|
||||
"method": "POST",
|
||||
|
@ -1,52 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const { exec, execSync } = require("child_process");
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
const pkg = require(excalidrawPackage);
|
||||
|
||||
const getShortCommitHash = () => {
|
||||
return execSync("git rev-parse --short HEAD").toString().trim();
|
||||
};
|
||||
|
||||
const publish = () => {
|
||||
try {
|
||||
execSync(`yarn --frozen-lockfile`);
|
||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
// get files changed between prev and head commit
|
||||
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||
if (error || stderr) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const changedFiles = stdout.trim().split("\n");
|
||||
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
||||
|
||||
const excalidrawPackageFiles = changedFiles.filter((file) => {
|
||||
return (
|
||||
(file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
|
||||
!filesToIgnoreRegex.test(file)
|
||||
);
|
||||
});
|
||||
if (!excalidrawPackageFiles.length) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// update package.json
|
||||
pkg.version = `${pkg.version}-${getShortCommitHash()}`;
|
||||
pkg.name = "@excalidraw/excalidraw-next";
|
||||
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
||||
|
||||
// update readme
|
||||
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
publish();
|
||||
});
|
@ -37,9 +37,6 @@ const crowdinMap = {
|
||||
"uk-UA": "en-uk",
|
||||
"zh-CN": "en-zhcn",
|
||||
"zh-TW": "en-zhtw",
|
||||
"lv-LV": "en-lv",
|
||||
"cs-CZ": "en-cs",
|
||||
"kk-KZ": "en-kk",
|
||||
};
|
||||
|
||||
const flags = {
|
||||
@ -77,9 +74,6 @@ const flags = {
|
||||
"uk-UA": "🇺🇦",
|
||||
"zh-CN": "🇨🇳",
|
||||
"zh-TW": "🇹🇼",
|
||||
"lv-LV": "🇱🇻",
|
||||
"cs-CZ": "🇨🇿",
|
||||
"kk-KZ": "🇰🇿",
|
||||
};
|
||||
|
||||
const languages = {
|
||||
@ -117,9 +111,6 @@ const languages = {
|
||||
"uk-UA": "Українська",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "繁體中文",
|
||||
"lv-LV": "Latviešu",
|
||||
"cs-CZ": "Česky",
|
||||
"kk-KZ": "Қазақ тілі",
|
||||
};
|
||||
|
||||
const percentages = fs.readFileSync(
|
||||
|
@ -1,39 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const util = require("util");
|
||||
const exec = util.promisify(require("child_process").exec);
|
||||
const updateReadme = require("./updateReadme");
|
||||
const updateChangelog = require("./updateChangelog");
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
|
||||
const updatePackageVersion = (nextVersion) => {
|
||||
const pkg = require(excalidrawPackage);
|
||||
pkg.version = nextVersion;
|
||||
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
||||
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
||||
};
|
||||
|
||||
const release = async (nextVersion) => {
|
||||
try {
|
||||
updateReadme();
|
||||
await updateChangelog(nextVersion);
|
||||
updatePackageVersion(nextVersion);
|
||||
await exec(`git add -u`);
|
||||
await exec(
|
||||
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
||||
);
|
||||
/* eslint-disable no-console */
|
||||
console.log("Done!");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const nextVersion = process.argv.slice(2)[0];
|
||||
if (!nextVersion) {
|
||||
console.error("Pass the next version to release!");
|
||||
process.exit(1);
|
||||
}
|
||||
release(nextVersion);
|
@ -1,97 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const util = require("util");
|
||||
const exec = util.promisify(require("child_process").exec);
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
const pkg = require(excalidrawPackage);
|
||||
const lastVersion = pkg.version;
|
||||
const existingChangeLog = fs.readFileSync(
|
||||
`${excalidrawDir}/CHANGELOG.md`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
|
||||
const headerForType = {
|
||||
feat: "Features",
|
||||
fix: "Fixes",
|
||||
style: "Styles",
|
||||
refactor: " Refactor",
|
||||
perf: "Performance",
|
||||
build: "Build",
|
||||
};
|
||||
|
||||
const getCommitHashForLastVersion = async () => {
|
||||
try {
|
||||
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
|
||||
const { stdout } = await exec(
|
||||
`git log --format=format:"%H" --grep=${commitMessage}`,
|
||||
);
|
||||
return stdout;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const getLibraryCommitsSinceLastRelease = async () => {
|
||||
const commitHash = await getCommitHashForLastVersion();
|
||||
const { stdout } = await exec(
|
||||
`git log --pretty=format:%s ${commitHash}...master`,
|
||||
);
|
||||
const commitsSinceLastRelease = stdout.split("\n");
|
||||
const commitList = {};
|
||||
supportedTypes.forEach((type) => {
|
||||
commitList[type] = [];
|
||||
});
|
||||
|
||||
commitsSinceLastRelease.forEach((commit) => {
|
||||
const indexOfColon = commit.indexOf(":");
|
||||
const type = commit.slice(0, indexOfColon);
|
||||
if (!supportedTypes.includes(type)) {
|
||||
return;
|
||||
}
|
||||
const messageWithoutType = commit.slice(indexOfColon + 1).trim();
|
||||
const messageWithCapitalizeFirst =
|
||||
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
|
||||
const prNumber = commit.match(/\(#([0-9]*)\)/)[1];
|
||||
|
||||
// return if the changelog already contains the pr number which would happen for package updates
|
||||
if (existingChangeLog.includes(prNumber)) {
|
||||
return;
|
||||
}
|
||||
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
|
||||
const messageWithPRLink = messageWithCapitalizeFirst.replace(
|
||||
/\(#[0-9]*\)/,
|
||||
prMarkdown,
|
||||
);
|
||||
commitList[type].push(messageWithPRLink);
|
||||
});
|
||||
return commitList;
|
||||
};
|
||||
|
||||
const updateChangelog = async (nextVersion) => {
|
||||
const commitList = await getLibraryCommitsSinceLastRelease();
|
||||
let changelogForLibrary =
|
||||
"## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
|
||||
supportedTypes.forEach((type) => {
|
||||
if (commitList[type].length) {
|
||||
changelogForLibrary += `### ${headerForType[type]}\n\n`;
|
||||
const commits = commitList[type];
|
||||
commits.forEach((commit) => {
|
||||
changelogForLibrary += `- ${commit}\n\n`;
|
||||
});
|
||||
}
|
||||
});
|
||||
changelogForLibrary += "---\n";
|
||||
const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
|
||||
let updatedContent =
|
||||
existingChangeLog.slice(0, lastVersionIndex) +
|
||||
changelogForLibrary +
|
||||
existingChangeLog.slice(lastVersionIndex);
|
||||
const currentDate = new Date().toISOString().slice(0, 10);
|
||||
const newVersion = `## ${nextVersion} (${currentDate})`;
|
||||
updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
|
||||
fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
|
||||
};
|
||||
|
||||
module.exports = updateChangelog;
|
@ -1,27 +0,0 @@
|
||||
const fs = require("fs");
|
||||
|
||||
const updateReadme = () => {
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||
|
||||
// remove note for unstable release
|
||||
data = data.replace(
|
||||
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
|
||||
"",
|
||||
);
|
||||
|
||||
// replace "excalidraw-next" with "excalidraw"
|
||||
data = data.replace(/excalidraw-next/g, "excalidraw");
|
||||
data = data.trim();
|
||||
|
||||
const demoIndex = data.indexOf("### Demo");
|
||||
const excalidrawNextNote =
|
||||
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
|
||||
// Add excalidraw next note to try out for unreleased changes
|
||||
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
|
||||
|
||||
// update readme
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
};
|
||||
|
||||
module.exports = updateReadme;
|
@ -2,20 +2,18 @@ import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { Library } from "../data/library";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
perform: (elements, appState, _, app) => {
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
app.library.loadLibrary().then((items) => {
|
||||
app.library.saveLibrary([
|
||||
...items,
|
||||
selectedElements.map(deepCopyElement),
|
||||
]);
|
||||
Library.loadLibrary().then((items) => {
|
||||
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
||||
});
|
||||
return false;
|
||||
},
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
|
@ -1,11 +1,14 @@
|
||||
import React from "react";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { zoomIn, zoomOut } from "../components/icons";
|
||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { THEME, ZOOM_STEP } from "../constants";
|
||||
import { ZOOM_STEP } from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
@ -13,17 +16,13 @@ import { getNewZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import ClearCanvas from "../components/ClearCanvas";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, ...value },
|
||||
commitToHistory: !!value.viewBackgroundColor,
|
||||
appState: { ...appState, viewBackgroundColor: value },
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => {
|
||||
@ -33,11 +32,7 @@ export const actionChangeViewBackgroundColor = register({
|
||||
label={t("labels.canvasBackground")}
|
||||
type="canvasBackground"
|
||||
color={appState.viewBackgroundColor}
|
||||
onChange={(color) => updateData({ viewBackgroundColor: color })}
|
||||
isActive={appState.openPopup === "canvasColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "canvasColorPicker" : null })
|
||||
}
|
||||
onChange={(color) => updateData(color)}
|
||||
data-testid="canvas-background-picker"
|
||||
/>
|
||||
</div>
|
||||
@ -47,28 +42,40 @@ export const actionChangeViewBackgroundColor = register({
|
||||
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.imageCache.clear();
|
||||
perform: (elements, appState: AppState) => {
|
||||
return {
|
||||
elements: elements.map((element) =>
|
||||
newElementWith(element, { isDeleted: true }),
|
||||
),
|
||||
appState: {
|
||||
...getDefaultAppState(),
|
||||
files: {},
|
||||
theme: appState.theme,
|
||||
elementLocked: appState.elementLocked,
|
||||
exportBackground: appState.exportBackground,
|
||||
exportEmbedScene: appState.exportEmbedScene,
|
||||
gridSize: appState.gridSize,
|
||||
shouldAddWatermark: appState.shouldAddWatermark,
|
||||
showStats: appState.showStats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
||||
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={trash}
|
||||
title={t("buttons.clearReset")}
|
||||
aria-label={t("buttons.clearReset")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("alerts.clearReset"))) {
|
||||
updateData(null);
|
||||
}
|
||||
}}
|
||||
data-testid="clear-canvas-button"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionZoomIn = register({
|
||||
@ -97,7 +104,6 @@ export const actionZoomIn = register({
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@ -132,7 +138,6 @@ export const actionZoomOut = register({
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@ -159,21 +164,16 @@ export const actionResetZoom = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<Tooltip label={t("buttons.resetZoom")}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
className="reset-zoom-button"
|
||||
title={t("buttons.resetZoom")}
|
||||
aria-label={t("buttons.resetZoom")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{(appState.zoom.value * 100).toFixed(0)}%
|
||||
</ToolButton>
|
||||
</Tooltip>
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={resetZoom}
|
||||
title={t("buttons.resetZoom")}
|
||||
aria-label={t("buttons.resetZoom")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||
@ -260,28 +260,3 @@ export const actionZoomToFit = register({
|
||||
!event.altKey &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionToggleTheme = register({
|
||||
name: "toggleTheme",
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
theme:
|
||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
||||
<DarkModeToggle
|
||||
value={appState.theme}
|
||||
onChange={(theme) => {
|
||||
updateData(theme);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
});
|
||||
|
@ -9,8 +9,8 @@ import { t } from "../i18n";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
perform: (elements, appState, _, app) => {
|
||||
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
||||
perform: (elements, appState) => {
|
||||
copyToClipboard(getNonDeletedElements(elements), appState);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
@ -50,7 +50,7 @@ export const actionCopyAsSvg = register({
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.files,
|
||||
app.canvas,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
@ -89,7 +89,7 @@ export const actionCopyAsPng = register({
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.files,
|
||||
app.canvas,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { KEYS } from "../keys";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import React from "react";
|
||||
import { trash } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import {
|
||||
DistributeHorizontallyIcon,
|
||||
DistributeVerticallyIcon,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
|
@ -1,25 +1,17 @@
|
||||
import React from "react";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { load, questionCircle, saveAs } from "../components/icons";
|
||||
import { load, questionCircle, save, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import "../components/ToolIcon.scss";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { resaveAsImageWithScene } from "../data/resave";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
import { getExportSize } from "../scene/export";
|
||||
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ActiveFile } from "../components/ActiveFile";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { Theme } from "../element/types";
|
||||
import { supported } from "browser-fs-access";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
@ -39,54 +31,6 @@ export const actionChangeProjectName = register({
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeExportScale = register({
|
||||
name: "changeExportScale",
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportScale: value },
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
||||
const elements = getNonDeletedElements(allElements);
|
||||
const exportSelected = isSomeElementSelected(elements, appState);
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState)
|
||||
: elements;
|
||||
|
||||
return (
|
||||
<>
|
||||
{EXPORT_SCALES.map((s) => {
|
||||
const [width, height] = getExportSize(
|
||||
exportedElements,
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
s,
|
||||
);
|
||||
|
||||
const scaleButtonTitle = `${t(
|
||||
"buttons.scale",
|
||||
)} ${s}x (${width}x${height})`;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={s}
|
||||
size="small"
|
||||
type="radio"
|
||||
icon={`${s}x`}
|
||||
name="export-canvas-scale"
|
||||
title={scaleButtonTitle}
|
||||
aria-label={scaleButtonTitle}
|
||||
id="export-canvas-scale"
|
||||
checked={s === appState.exportScale}
|
||||
onChange={() => updateData(s)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeExportBackground = register({
|
||||
name: "changeExportBackground",
|
||||
perform: (_elements, appState, value) => {
|
||||
@ -96,12 +40,14 @@ export const actionChangeExportBackground = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<CheckboxItem
|
||||
checked={appState.exportBackground}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.exportBackground}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
{t("labels.withBackground")}
|
||||
</CheckboxItem>
|
||||
</label>
|
||||
),
|
||||
});
|
||||
|
||||
@ -114,35 +60,57 @@ export const actionChangeExportEmbedScene = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<CheckboxItem
|
||||
checked={appState.exportEmbedScene}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
<label style={{ display: "flex" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.exportEmbedScene}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
{t("labels.exportEmbedScene")}
|
||||
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
||||
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
|
||||
<Tooltip
|
||||
label={t("labels.exportEmbedScene_details")}
|
||||
position="above"
|
||||
long={true}
|
||||
>
|
||||
<div className="TooltipIcon">{questionCircle}</div>
|
||||
</Tooltip>
|
||||
</CheckboxItem>
|
||||
</label>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
perform: async (elements, appState, value, app) => {
|
||||
export const actionChangeShouldAddWatermark = register({
|
||||
name: "changeShouldAddWatermark",
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, shouldAddWatermark: value },
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.shouldAddWatermark}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
{t("labels.addWatermark")}
|
||||
</label>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSaveScene = register({
|
||||
name: "saveScene",
|
||||
perform: async (elements, appState, value) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
|
||||
try {
|
||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||
? await resaveAsImageWithScene(elements, appState, app.files)
|
||||
: await saveAsJSON(elements, appState, app.files);
|
||||
|
||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
fileHandle,
|
||||
toastMessage: fileHandleExists
|
||||
? fileHandle?.name
|
||||
? fileHandle.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
@ -160,26 +128,27 @@ export const actionSaveToActiveFile = register({
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ActiveFile
|
||||
onSave={() => updateData(null)}
|
||||
fileName={appState.fileHandle?.name}
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={save}
|
||||
title={t("buttons.save")}
|
||||
aria-label={t("buttons.save")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-button"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
perform: async (elements, appState, value, app) => {
|
||||
export const actionSaveAsScene = register({
|
||||
name: "saveAsScene",
|
||||
perform: async (elements, appState, value) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(
|
||||
elements,
|
||||
{
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
},
|
||||
app.files,
|
||||
);
|
||||
const { fileHandle } = await saveAsJSON(elements, {
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
});
|
||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
@ -197,7 +166,7 @@ export const actionSaveFileToDisk = register({
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
hidden={!nativeFileSystemSupported}
|
||||
hidden={!supported}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-as-button"
|
||||
/>
|
||||
@ -206,17 +175,15 @@ export const actionSaveFileToDisk = register({
|
||||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
perform: async (elements, appState, _, app) => {
|
||||
perform: async (elements, appState) => {
|
||||
try {
|
||||
const {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
files,
|
||||
} = await loadFromJSON(appState, elements);
|
||||
} = await loadFromJSON(appState);
|
||||
return {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
files,
|
||||
commitToHistory: true,
|
||||
};
|
||||
} catch (error) {
|
||||
@ -226,7 +193,6 @@ export const actionLoadScene = register({
|
||||
return {
|
||||
elements,
|
||||
appState: { ...appState, errorMessage: error.message },
|
||||
files: app.files,
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
@ -263,9 +229,9 @@ export const actionExportWithDarkMode = register({
|
||||
}}
|
||||
>
|
||||
<DarkModeToggle
|
||||
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
|
||||
onChange={(theme: Theme) => {
|
||||
updateData(theme === THEME.DARK);
|
||||
value={appState.exportWithDarkMode ? "dark" : "light"}
|
||||
onChange={(theme: Appearence) => {
|
||||
updateData(theme === "dark");
|
||||
}}
|
||||
title={t("labels.toggleExportColorScheme")}
|
||||
/>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { isInvisiblySmallElement } from "../element";
|
||||
import { resetCursor } from "../utils";
|
||||
import React from "react";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { done } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
@ -17,7 +18,7 @@ import { isBindingElement } from "../element/typeChecks";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
||||
perform: (elements, appState, _, { canvas }) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
elementId,
|
||||
@ -49,25 +50,20 @@ export const actionFinalize = register({
|
||||
}
|
||||
|
||||
let newElements = elements;
|
||||
|
||||
if (appState.pendingImageElement) {
|
||||
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
|
||||
}
|
||||
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
focusContainer();
|
||||
window.document.activeElement.blur();
|
||||
}
|
||||
|
||||
const multiPointElement = appState.multiElement
|
||||
? appState.multiElement
|
||||
: appState.editingElement?.type === "freedraw"
|
||||
: appState.editingElement?.type === "draw"
|
||||
? appState.editingElement
|
||||
: null;
|
||||
|
||||
if (multiPointElement) {
|
||||
// pen and mouse have hover
|
||||
if (
|
||||
multiPointElement.type !== "freedraw" &&
|
||||
multiPointElement.type !== "draw" &&
|
||||
appState.lastPointerDownWith !== "touch"
|
||||
) {
|
||||
const { points, lastCommittedPoint } = multiPointElement;
|
||||
@ -90,7 +86,7 @@ export const actionFinalize = register({
|
||||
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
||||
if (
|
||||
multiPointElement.type === "line" ||
|
||||
multiPointElement.type === "freedraw"
|
||||
multiPointElement.type === "draw"
|
||||
) {
|
||||
if (isLoop) {
|
||||
const linePoints = multiPointElement.points;
|
||||
@ -122,24 +118,22 @@ export const actionFinalize = register({
|
||||
);
|
||||
}
|
||||
|
||||
if (!appState.elementLocked && appState.elementType !== "freedraw") {
|
||||
if (!appState.elementLocked && appState.elementType !== "draw") {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!appState.elementLocked && appState.elementType !== "freedraw") ||
|
||||
(!appState.elementLocked && appState.elementType !== "draw") ||
|
||||
!multiPointElement
|
||||
) {
|
||||
resetCursor(canvas);
|
||||
}
|
||||
|
||||
return {
|
||||
elements: newElements,
|
||||
appState: {
|
||||
...appState,
|
||||
elementType:
|
||||
(appState.elementLocked || appState.elementType === "freedraw") &&
|
||||
(appState.elementLocked || appState.elementType === "draw") &&
|
||||
multiPointElement
|
||||
? appState.elementType
|
||||
: "selection",
|
||||
@ -151,15 +145,14 @@ export const actionFinalize = register({
|
||||
selectedElementIds:
|
||||
multiPointElement &&
|
||||
!appState.elementLocked &&
|
||||
appState.elementType !== "freedraw"
|
||||
appState.elementType !== "draw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[multiPointElement.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
pendingImageElement: null,
|
||||
},
|
||||
commitToHistory: appState.elementType === "freedraw",
|
||||
commitToHistory: appState.elementType === "draw",
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
|
@ -6,7 +6,7 @@ import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
||||
import { AppState } from "../types";
|
||||
import { getTransformHandles } from "../element/transformHandles";
|
||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { updateBoundElements } from "../element/binding";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
||||
@ -93,13 +93,13 @@ const flipElements = (
|
||||
appState: AppState,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
): ExcalidrawElement[] => {
|
||||
elements.forEach((element) => {
|
||||
flipElement(element, appState);
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
flipElement(elements[i], appState);
|
||||
// If vertical flip, rotate an extra 180
|
||||
if (flipDirection === "vertical") {
|
||||
rotateElement(element, Math.PI);
|
||||
rotateElement(elements[i], Math.PI);
|
||||
}
|
||||
});
|
||||
}
|
||||
return elements;
|
||||
};
|
||||
|
||||
@ -114,7 +114,7 @@ const flipElement = (
|
||||
const originalAngle = normalizeAngle(element.angle);
|
||||
|
||||
let finalOffsetX = 0;
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
if (isLinearElement(element)) {
|
||||
finalOffsetX =
|
||||
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
|
||||
element.width;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../utils";
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { Action, ActionResult } from "./types";
|
||||
import React from "react";
|
||||
import { undo, redo } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import History, { HistoryEntry } from "../history";
|
||||
import { SceneHistory, HistoryEntry } from "../history";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { isWindows, KEYS } from "../keys";
|
||||
@ -58,7 +59,7 @@ const writeData = (
|
||||
return { commitToHistory };
|
||||
};
|
||||
|
||||
type ActionCreator = (history: History) => Action;
|
||||
type ActionCreator = (history: SceneHistory) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
name: "undo",
|
||||
@ -68,13 +69,12 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key.toLowerCase() === KEYS.Z &&
|
||||
!event.shiftKey,
|
||||
PanelComponent: ({ updateData, data }) => (
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={undo}
|
||||
aria-label={t("buttons.undo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
commitToHistory: () => false,
|
||||
@ -89,13 +89,12 @@ export const createRedoAction: ActionCreator = (history) => ({
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === KEYS.Z) ||
|
||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||
PanelComponent: ({ updateData, data }) => (
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={redo}
|
||||
aria-label={t("buttons.redo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
commitToHistory: () => false,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { menu, palette } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
@ -69,10 +70,7 @@ export const actionFullScreen = register({
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
perform: (_elements, appState, _, { focusContainer }) => {
|
||||
if (appState.showHelpDialog) {
|
||||
focusContainer();
|
||||
}
|
||||
perform: (_elements, appState) => {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { getClientColors, getClientInitials } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
@ -29,8 +30,8 @@ export const actionGoToCollaborator = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, data }) => {
|
||||
const clientId: string | undefined = data?.id;
|
||||
PanelComponent: ({ appState, updateData, id }) => {
|
||||
const clientId = id;
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { AppState } from "../../src/types";
|
||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
@ -12,13 +13,6 @@ import {
|
||||
FillCrossHatchIcon,
|
||||
FillHachureIcon,
|
||||
FillSolidIcon,
|
||||
FontFamilyCodeIcon,
|
||||
FontFamilyHandDrawnIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FontSizeExtraLargeIcon,
|
||||
FontSizeLargeIcon,
|
||||
FontSizeMediumIcon,
|
||||
FontSizeSmallIcon,
|
||||
SloppinessArchitectIcon,
|
||||
SloppinessArtistIcon,
|
||||
SloppinessCartoonistIcon,
|
||||
@ -26,15 +20,18 @@ import {
|
||||
StrokeStyleDottedIcon,
|
||||
StrokeStyleSolidIcon,
|
||||
StrokeWidthIcon,
|
||||
TextAlignCenterIcon,
|
||||
FontSizeSmallIcon,
|
||||
FontSizeMediumIcon,
|
||||
FontSizeLargeIcon,
|
||||
FontSizeExtraLargeIcon,
|
||||
FontFamilyHandDrawnIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FontFamilyCodeIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignCenterIcon,
|
||||
TextAlignRightIcon,
|
||||
} from "../components/icons";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
} from "../constants";
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
isTextElement,
|
||||
@ -47,7 +44,7 @@ import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
FontFamilyValues,
|
||||
FontFamily,
|
||||
TextAlign,
|
||||
} from "../element/types";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
@ -59,7 +56,6 @@ import {
|
||||
getTargetElements,
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { register } from "./register";
|
||||
|
||||
const changeProperty = (
|
||||
@ -103,20 +99,13 @@ export const actionChangeStrokeColor = register({
|
||||
name: "changeStrokeColor",
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
...(value.currentItemStrokeColor && {
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
return hasStrokeColor(el.type)
|
||||
? newElementWith(el, {
|
||||
strokeColor: value.currentItemStrokeColor,
|
||||
})
|
||||
: el;
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
strokeColor: value,
|
||||
}),
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
commitToHistory: !!value.currentItemStrokeColor,
|
||||
),
|
||||
appState: { ...appState, currentItemStrokeColor: value },
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
@ -131,11 +120,7 @@ export const actionChangeStrokeColor = register({
|
||||
(element) => element.strokeColor,
|
||||
appState.currentItemStrokeColor,
|
||||
)}
|
||||
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
||||
isActive={appState.openPopup === "strokeColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
||||
}
|
||||
onChange={updateData}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@ -145,18 +130,13 @@ export const actionChangeBackgroundColor = register({
|
||||
name: "changeBackgroundColor",
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
...(value.currentItemBackgroundColor && {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
backgroundColor: value.currentItemBackgroundColor,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
commitToHistory: !!value.currentItemBackgroundColor,
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
backgroundColor: value,
|
||||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemBackgroundColor: value },
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
@ -171,11 +151,7 @@ export const actionChangeBackgroundColor = register({
|
||||
(element) => element.backgroundColor,
|
||||
appState.currentItemBackgroundColor,
|
||||
)}
|
||||
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
||||
isActive={appState.openPopup === "backgroundColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
||||
}
|
||||
onChange={updateData}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@ -505,23 +481,19 @@ export const actionChangeFontFamily = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
const options: {
|
||||
value: FontFamilyValues;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
}[] = [
|
||||
const options: { value: FontFamily; text: string; icon: JSX.Element }[] = [
|
||||
{
|
||||
value: FONT_FAMILY.Virgil,
|
||||
value: 1,
|
||||
text: t("labels.handDrawn"),
|
||||
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY.Helvetica,
|
||||
value: 2,
|
||||
text: t("labels.normal"),
|
||||
icon: <FontFamilyNormalIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY.Cascadia,
|
||||
value: 3,
|
||||
text: t("labels.code"),
|
||||
icon: <FontFamilyCodeIcon theme={appState.theme} />,
|
||||
},
|
||||
@ -530,7 +502,7 @@ export const actionChangeFontFamily = register({
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontFamily")}</legend>
|
||||
<ButtonIconSelect<FontFamilyValues | false>
|
||||
<ButtonIconSelect<FontFamily | false>
|
||||
group="font-family"
|
||||
options={options}
|
||||
value={getFormValue(
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { register } from "./register";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
@ -14,6 +13,4 @@ export const actionToggleStats = register({
|
||||
},
|
||||
checked: (appState) => appState.showStats,
|
||||
contextItemLabel: "stats.title",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
||||
});
|
||||
|
@ -10,6 +10,7 @@ export const actionToggleViewMode = register({
|
||||
appState: {
|
||||
...appState,
|
||||
viewModeEnabled: !this.checked!(appState),
|
||||
selectedElementIds: {},
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
|
@ -26,7 +26,6 @@ export {
|
||||
actionZoomOut,
|
||||
actionResetZoom,
|
||||
actionZoomToFit,
|
||||
actionToggleTheme,
|
||||
} from "./actionCanvas";
|
||||
|
||||
export { actionFinalize } from "./actionFinalize";
|
||||
@ -34,8 +33,8 @@ export { actionFinalize } from "./actionFinalize";
|
||||
export {
|
||||
actionChangeProjectName,
|
||||
actionChangeExportBackground,
|
||||
actionSaveToActiveFile,
|
||||
actionSaveFileToDisk,
|
||||
actionSaveScene,
|
||||
actionSaveAsScene,
|
||||
actionLoadScene,
|
||||
} from "./actionExport";
|
||||
|
||||
|
@ -5,12 +5,15 @@ import {
|
||||
UpdaterFn,
|
||||
ActionName,
|
||||
ActionResult,
|
||||
PanelComponentProps,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { AppProps, AppState } from "../types";
|
||||
import { MODES } from "../constants";
|
||||
|
||||
// This is the <App> component, but for now we don't care about anything but its
|
||||
// `canvas` state.
|
||||
type App = { canvas: HTMLCanvasElement | null; props: AppProps };
|
||||
|
||||
export class ActionManager implements ActionsManagerInterface {
|
||||
actions = {} as ActionsManagerInterface["actions"];
|
||||
|
||||
@ -18,13 +21,13 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
|
||||
getAppState: () => Readonly<AppState>;
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||
app: AppClassProperties;
|
||||
app: App;
|
||||
|
||||
constructor(
|
||||
updater: UpdaterFn,
|
||||
getAppState: () => AppState,
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
app: App,
|
||||
) {
|
||||
this.updater = (actionResult) => {
|
||||
if (actionResult && "then" in actionResult) {
|
||||
@ -48,7 +51,7 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
actions.forEach((action) => this.registerAction(action));
|
||||
}
|
||||
|
||||
handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) {
|
||||
handleKeyDown(event: KeyboardEvent) {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
const data = Object.values(this.actions)
|
||||
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
|
||||
@ -98,10 +101,11 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param data additional data sent to the PanelComponent
|
||||
*/
|
||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
||||
// Id is an attribute that we can use to pass in data like keys.
|
||||
// This is needed for dynamically generated action components
|
||||
// like the user list. We can use this key to extract more
|
||||
// data from app state. This is an alternative to generic prop hell!
|
||||
renderAction = (name: ActionName, id?: string) => {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
|
||||
if (
|
||||
@ -129,8 +133,8 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
elements={this.getElementsIncludingDeleted()}
|
||||
appState={this.getAppState()}
|
||||
updateData={updateData}
|
||||
id={id}
|
||||
appProps={this.app.props}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||
zenMode: [getShortcutKey("Alt+Z")],
|
||||
stats: [getShortcutKey("Alt+/")],
|
||||
stats: [],
|
||||
addToLibrary: [],
|
||||
flipHorizontal: [getShortcutKey("Shift+H")],
|
||||
flipVertical: [getShortcutKey("Shift+V")],
|
||||
|
@ -1,12 +1,6 @@
|
||||
import React from "react";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
} from "../types";
|
||||
import { ToolButtonSize } from "../components/ToolButton";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
|
||||
/** if false, the action should be prevented */
|
||||
export type ActionResult =
|
||||
@ -16,10 +10,8 @@ export type ActionResult =
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
> | null;
|
||||
files?: BinaryFiles | null;
|
||||
commitToHistory: boolean;
|
||||
syncHistory?: boolean;
|
||||
replaceFiles?: boolean;
|
||||
}
|
||||
| false;
|
||||
|
||||
@ -27,7 +19,7 @@ type ActionFn = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
formData: any,
|
||||
app: AppClassProperties,
|
||||
app: { canvas: HTMLCanvasElement | null },
|
||||
) => ActionResult | Promise<ActionResult>;
|
||||
|
||||
export type UpdaterFn = (res: ActionResult) => void;
|
||||
@ -53,7 +45,6 @@ export type ActionName =
|
||||
| "changeBackgroundColor"
|
||||
| "changeFillStyle"
|
||||
| "changeStrokeWidth"
|
||||
| "changeStrokeShape"
|
||||
| "changeSloppiness"
|
||||
| "changeStrokeStyle"
|
||||
| "changeArrowhead"
|
||||
@ -67,9 +58,9 @@ export type ActionName =
|
||||
| "changeProjectName"
|
||||
| "changeExportBackground"
|
||||
| "changeExportEmbedScene"
|
||||
| "changeExportScale"
|
||||
| "saveToActiveFile"
|
||||
| "saveFileToDisk"
|
||||
| "changeShouldAddWatermark"
|
||||
| "saveScene"
|
||||
| "saveAsScene"
|
||||
| "loadScene"
|
||||
| "duplicateSelection"
|
||||
| "deleteSelectedElements"
|
||||
@ -100,24 +91,21 @@ export type ActionName =
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "viewMode"
|
||||
| "exportWithDarkMode"
|
||||
| "toggleTheme";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
updateData: (formData?: any) => void;
|
||||
appProps: ExcalidrawProps;
|
||||
data?: Partial<{ id: string; size: ToolButtonSize }>;
|
||||
};
|
||||
| "exportWithDarkMode";
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
PanelComponent?: React.FC<PanelComponentProps>;
|
||||
PanelComponent?: React.FC<{
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
updateData: (formData?: any) => void;
|
||||
appProps: ExcalidrawProps;
|
||||
id?: string;
|
||||
}>;
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
keyTest?: (
|
||||
event: React.KeyboardEvent | KeyboardEvent,
|
||||
event: KeyboardEvent,
|
||||
appState: AppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => boolean;
|
||||
@ -132,7 +120,6 @@ export interface Action {
|
||||
export interface ActionsManagerInterface {
|
||||
actions: Record<ActionName, Action>;
|
||||
registerAction: (action: Action) => void;
|
||||
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
|
||||
handleKeyDown: (event: KeyboardEvent) => boolean;
|
||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||
executeAction: (action: Action) => void;
|
||||
}
|
||||
|
160
src/appState.ts
160
src/appState.ts
@ -3,23 +3,17 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
EXPORT_SCALES,
|
||||
THEME,
|
||||
} from "./constants";
|
||||
import { t } from "./i18n";
|
||||
import { AppState, NormalizedZoomValue } from "./types";
|
||||
import { getDateTime } from "./utils";
|
||||
|
||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||
? devicePixelRatio
|
||||
: 1;
|
||||
|
||||
export const getDefaultAppState = (): Omit<
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
> => {
|
||||
return {
|
||||
theme: THEME.LIGHT,
|
||||
theme: "light",
|
||||
collaborators: new Map(),
|
||||
currentChartType: "bar",
|
||||
currentItemBackgroundColor: "transparent",
|
||||
@ -45,7 +39,6 @@ export const getDefaultAppState = (): Omit<
|
||||
elementType: "selection",
|
||||
errorMessage: null,
|
||||
exportBackground: true,
|
||||
exportScale: defaultExportScale,
|
||||
exportEmbedScene: false,
|
||||
exportWithDarkMode: false,
|
||||
fileHandle: null,
|
||||
@ -59,7 +52,6 @@ export const getDefaultAppState = (): Omit<
|
||||
multiElement: null,
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
openMenu: null,
|
||||
openPopup: null,
|
||||
pasteDialog: { shown: false, data: null },
|
||||
previousSelectedElementIds: {},
|
||||
resizingElement: null,
|
||||
@ -69,6 +61,7 @@ export const getDefaultAppState = (): Omit<
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
selectionElement: null,
|
||||
shouldAddWatermark: false,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showHelpDialog: false,
|
||||
showStats: false,
|
||||
@ -79,7 +72,6 @@ export const getDefaultAppState = (): Omit<
|
||||
zenModeEnabled: false,
|
||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||
viewModeEnabled: false,
|
||||
pendingImageElement: null,
|
||||
};
|
||||
};
|
||||
|
||||
@ -93,87 +85,77 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
browser: boolean;
|
||||
/** whether to keep when exporting to file/database */
|
||||
export: boolean;
|
||||
/** server (shareLink/collab/...) */
|
||||
server: boolean;
|
||||
},
|
||||
T extends Record<keyof AppState, Values>
|
||||
>(
|
||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
||||
) => config)({
|
||||
theme: { browser: true, export: false, server: false },
|
||||
collaborators: { browser: false, export: false, server: false },
|
||||
currentChartType: { browser: true, export: false, server: false },
|
||||
currentItemBackgroundColor: { browser: true, export: false, server: false },
|
||||
currentItemEndArrowhead: { browser: true, export: false, server: false },
|
||||
currentItemFillStyle: { browser: true, export: false, server: false },
|
||||
currentItemFontFamily: { browser: true, export: false, server: false },
|
||||
currentItemFontSize: { browser: true, export: false, server: false },
|
||||
currentItemLinearStrokeSharpness: {
|
||||
browser: true,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
currentItemOpacity: { browser: true, export: false, server: false },
|
||||
currentItemRoughness: { browser: true, export: false, server: false },
|
||||
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||
currentItemStrokeSharpness: { browser: true, export: false, server: false },
|
||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||
cursorButton: { browser: true, export: false, server: false },
|
||||
draggingElement: { browser: false, export: false, server: false },
|
||||
editingElement: { browser: false, export: false, server: false },
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
editingLinearElement: { browser: false, export: false, server: false },
|
||||
elementLocked: { browser: true, export: false, server: false },
|
||||
elementType: { browser: true, 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 },
|
||||
exportScale: { browser: true, export: false, server: false },
|
||||
exportWithDarkMode: { browser: true, export: false, server: false },
|
||||
fileHandle: { browser: false, export: false, server: false },
|
||||
gridSize: { browser: true, export: true, server: true },
|
||||
height: { browser: false, export: false, server: false },
|
||||
isBindingEnabled: { browser: false, 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 },
|
||||
lastPointerDownWith: { browser: true, export: false, server: false },
|
||||
multiElement: { browser: false, export: false, server: false },
|
||||
name: { browser: true, export: false, server: false },
|
||||
offsetLeft: { browser: false, export: false, server: false },
|
||||
offsetTop: { browser: false, export: false, server: false },
|
||||
openMenu: { browser: true, export: false, server: false },
|
||||
openPopup: { browser: false, export: false, server: false },
|
||||
pasteDialog: { browser: false, export: false, server: false },
|
||||
previousSelectedElementIds: { browser: true, export: false, server: false },
|
||||
resizingElement: { browser: false, export: false, server: false },
|
||||
scrolledOutside: { browser: true, export: false, server: false },
|
||||
scrollX: { browser: true, export: false, server: false },
|
||||
scrollY: { browser: true, export: false, server: false },
|
||||
selectedElementIds: { browser: true, export: false, server: false },
|
||||
selectedGroupIds: { browser: true, export: false, server: false },
|
||||
selectionElement: { browser: false, export: false, server: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||
showHelpDialog: { browser: false, export: false, server: false },
|
||||
showStats: { browser: true, export: false, server: false },
|
||||
startBoundElement: { browser: false, export: false, server: false },
|
||||
suggestedBindings: { browser: false, export: false, server: false },
|
||||
toastMessage: { browser: false, export: false, server: false },
|
||||
viewBackgroundColor: { browser: true, export: true, server: true },
|
||||
width: { browser: false, export: false, server: false },
|
||||
zenModeEnabled: { browser: true, export: false, server: false },
|
||||
zoom: { browser: true, export: false, server: false },
|
||||
viewModeEnabled: { browser: false, export: false, server: false },
|
||||
pendingImageElement: { browser: false, export: false, server: false },
|
||||
theme: { browser: true, export: false },
|
||||
collaborators: { browser: false, export: false },
|
||||
currentChartType: { browser: true, export: false },
|
||||
currentItemBackgroundColor: { browser: true, export: false },
|
||||
currentItemEndArrowhead: { browser: true, export: false },
|
||||
currentItemFillStyle: { browser: true, export: false },
|
||||
currentItemFontFamily: { browser: true, export: false },
|
||||
currentItemFontSize: { browser: true, export: false },
|
||||
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
||||
currentItemOpacity: { browser: true, export: false },
|
||||
currentItemRoughness: { browser: true, export: false },
|
||||
currentItemStartArrowhead: { browser: true, export: false },
|
||||
currentItemStrokeColor: { browser: true, export: false },
|
||||
currentItemStrokeSharpness: { browser: true, export: false },
|
||||
currentItemStrokeStyle: { browser: true, export: false },
|
||||
currentItemStrokeWidth: { browser: true, export: false },
|
||||
currentItemTextAlign: { browser: true, export: false },
|
||||
cursorButton: { browser: true, export: false },
|
||||
draggingElement: { browser: false, export: false },
|
||||
editingElement: { browser: false, export: false },
|
||||
editingGroupId: { browser: true, export: false },
|
||||
editingLinearElement: { browser: false, export: false },
|
||||
elementLocked: { browser: true, export: false },
|
||||
elementType: { browser: true, export: false },
|
||||
errorMessage: { browser: false, export: false },
|
||||
exportBackground: { browser: true, export: false },
|
||||
exportEmbedScene: { browser: true, export: false },
|
||||
exportWithDarkMode: { browser: true, export: false },
|
||||
fileHandle: { browser: false, export: false },
|
||||
gridSize: { browser: true, export: true },
|
||||
height: { browser: false, export: false },
|
||||
isBindingEnabled: { browser: false, export: false },
|
||||
isLibraryOpen: { browser: false, export: false },
|
||||
isLoading: { browser: false, export: false },
|
||||
isResizing: { browser: false, export: false },
|
||||
isRotating: { browser: false, export: false },
|
||||
lastPointerDownWith: { browser: true, export: false },
|
||||
multiElement: { browser: false, export: false },
|
||||
name: { browser: true, export: false },
|
||||
offsetLeft: { browser: false, export: false },
|
||||
offsetTop: { browser: false, export: false },
|
||||
openMenu: { browser: true, export: false },
|
||||
pasteDialog: { browser: false, export: false },
|
||||
previousSelectedElementIds: { browser: true, export: false },
|
||||
resizingElement: { browser: false, export: false },
|
||||
scrolledOutside: { browser: true, export: false },
|
||||
scrollX: { browser: true, export: false },
|
||||
scrollY: { browser: true, export: false },
|
||||
selectedElementIds: { browser: true, export: false },
|
||||
selectedGroupIds: { browser: true, export: false },
|
||||
selectionElement: { browser: false, export: false },
|
||||
shouldAddWatermark: { browser: true, export: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
||||
showHelpDialog: { browser: false, export: false },
|
||||
showStats: { browser: true, export: false },
|
||||
startBoundElement: { browser: false, export: false },
|
||||
suggestedBindings: { browser: false, export: false },
|
||||
toastMessage: { browser: false, export: false },
|
||||
viewBackgroundColor: { browser: true, export: true },
|
||||
width: { browser: false, export: false },
|
||||
zenModeEnabled: { browser: true, export: false },
|
||||
zoom: { browser: true, export: false },
|
||||
viewModeEnabled: { browser: false, export: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
ExportType extends "export" | "browser" | "server"
|
||||
>(
|
||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
||||
appState: Partial<AppState>,
|
||||
exportType: ExportType,
|
||||
) => {
|
||||
@ -186,10 +168,8 @@ const _clearAppStateForStorage = <
|
||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
||||
const propConfig = APP_STATE_STORAGE_CONF[key];
|
||||
if (propConfig?.[exportType]) {
|
||||
const nextValue = appState[key];
|
||||
|
||||
// https://github.com/microsoft/TypeScript/issues/31445
|
||||
(stateForExport as any)[key] = nextValue;
|
||||
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
||||
stateForExport[key] = appState[key];
|
||||
}
|
||||
}
|
||||
return stateForExport;
|
||||
@ -202,7 +182,3 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
|
||||
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
||||
return _clearAppStateForStorage(appState, "export");
|
||||
};
|
||||
|
||||
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
|
||||
return _clearAppStateForStorage(appState, "server");
|
||||
};
|
||||
|
@ -3,22 +3,20 @@ import {
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import { AppState, BinaryFiles } from "./types";
|
||||
import { AppState } 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 { canvasToBlob } from "./data/blob";
|
||||
import { EXPORT_DATA_TYPES } from "./constants";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
elements: ExcalidrawElement[];
|
||||
files: BinaryFiles | undefined;
|
||||
};
|
||||
|
||||
export interface ClipboardData {
|
||||
spreadsheet?: Spreadsheet;
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
files?: BinaryFiles;
|
||||
text?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
@ -40,7 +38,7 @@ export const probablySupportsClipboardBlob =
|
||||
|
||||
const clipboardContainsElements = (
|
||||
contents: any,
|
||||
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
|
||||
): contents is { elements: ExcalidrawElement[] } => {
|
||||
if (
|
||||
[
|
||||
EXPORT_DATA_TYPES.excalidraw,
|
||||
@ -56,18 +54,10 @@ const clipboardContainsElements = (
|
||||
export const copyToClipboard = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
const contents: ElementsClipboard = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements: selectedElements,
|
||||
files: selectedElements.reduce((acc, element) => {
|
||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||
acc[element.fileId] = files[element.fileId];
|
||||
}
|
||||
return acc;
|
||||
}, {} as BinaryFiles),
|
||||
elements: getSelectedElements(elements, appState),
|
||||
};
|
||||
const json = JSON.stringify(contents);
|
||||
CLIPBOARD = json;
|
||||
@ -149,10 +139,7 @@ export const parseClipboard = async (
|
||||
try {
|
||||
const systemClipboardData = JSON.parse(systemClipboard);
|
||||
if (clipboardContainsElements(systemClipboardData)) {
|
||||
return {
|
||||
elements: systemClipboardData.elements,
|
||||
files: systemClipboardData.files,
|
||||
};
|
||||
return { elements: systemClipboardData.elements };
|
||||
}
|
||||
return appClipboardData;
|
||||
} catch {
|
||||
@ -165,9 +152,10 @@ export const parseClipboard = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
||||
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
|
||||
const blob = await canvasToBlob(canvas);
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
|
||||
new window.ClipboardItem({ "image/png": blob }),
|
||||
]);
|
||||
};
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import {
|
||||
@ -9,8 +9,7 @@ import {
|
||||
canHaveArrowheads,
|
||||
getTargetElements,
|
||||
hasBackground,
|
||||
hasStrokeStyle,
|
||||
hasStrokeWidth,
|
||||
hasStroke,
|
||||
hasText,
|
||||
} from "../scene";
|
||||
import { SHAPES } from "../shapes";
|
||||
@ -18,7 +17,6 @@ import { AppState, Zoom } from "../types";
|
||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
@ -49,36 +47,16 @@ export const SelectedShapeActions = ({
|
||||
hasBackground(elementType) ||
|
||||
targetElements.some((element) => hasBackground(element.type));
|
||||
|
||||
let commonSelectedType: string | null = targetElements[0]?.type || null;
|
||||
|
||||
for (const element of targetElements) {
|
||||
if (element.type !== commonSelectedType) {
|
||||
commonSelectedType = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
{((hasStrokeColor(elementType) &&
|
||||
elementType !== "image" &&
|
||||
commonSelectedType !== "image") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
{renderAction("changeStrokeColor")}
|
||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
{(hasStrokeWidth(elementType) ||
|
||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||
renderAction("changeStrokeWidth")}
|
||||
|
||||
{(elementType === "freedraw" ||
|
||||
targetElements.some((element) => element.type === "freedraw")) &&
|
||||
renderAction("changeStrokeShape")}
|
||||
|
||||
{(hasStrokeStyle(elementType) ||
|
||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||
{(hasStroke(elementType) ||
|
||||
targetElements.some((element) => hasStroke(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeStrokeWidth")}
|
||||
{renderAction("changeStrokeStyle")}
|
||||
{renderAction("changeSloppiness")}
|
||||
</>
|
||||
@ -165,24 +143,31 @@ export const SelectedShapeActions = ({
|
||||
);
|
||||
};
|
||||
|
||||
const LIBRARY_ICON = (
|
||||
// fa-th-large
|
||||
<svg viewBox="0 0 512 512">
|
||||
<path d="M296 32h192c13.255 0 24 10.745 24 24v160c0 13.255-10.745 24-24 24H296c-13.255 0-24-10.745-24-24V56c0-13.255 10.745-24 24-24zm-80 0H24C10.745 32 0 42.745 0 56v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V56c0-13.255-10.745-24-24-24zM0 296v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H24c-13.255 0-24 10.745-24 24zm296 184h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H296c-13.255 0-24 10.745-24 24v160c0 13.255 10.745 24 24 24z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
canvas,
|
||||
elementType,
|
||||
setAppState,
|
||||
onImageAction,
|
||||
isLibraryOpen,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
elementType: ExcalidrawElement["type"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
isLibraryOpen: boolean;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter = key && (typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
|
||||
: `${index + 1}`;
|
||||
const letter = typeof key === "string" ? key : key[0];
|
||||
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
|
||||
index + 1
|
||||
}`;
|
||||
return (
|
||||
<ToolButton
|
||||
className="Shape"
|
||||
@ -196,20 +181,31 @@ export const ShapesSwitcher = ({
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={value}
|
||||
onChange={({ pointerType }) => {
|
||||
onChange={() => {
|
||||
setAppState({
|
||||
elementType: value,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(canvas, value);
|
||||
if (value === "image") {
|
||||
onImageAction({ pointerType });
|
||||
}
|
||||
setAppState({});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ToolButton
|
||||
className="Shape ToolIcon_type_button__library"
|
||||
type="button"
|
||||
icon={LIBRARY_ICON}
|
||||
name="editor-library"
|
||||
keyBindingLabel="9"
|
||||
aria-keyshortcuts="9"
|
||||
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
onClick={() => {
|
||||
setAppState({ isLibraryOpen: !isLibraryOpen });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -222,9 +218,12 @@ export const ZoomActions = ({
|
||||
}) => (
|
||||
<Stack.Col gap={1}>
|
||||
<Stack.Row gap={1} align="center">
|
||||
{renderAction("zoomOut")}
|
||||
{renderAction("zoomIn")}
|
||||
{renderAction("zoomOut")}
|
||||
{renderAction("resetZoom")}
|
||||
<div style={{ marginInlineStart: 4 }}>
|
||||
{(zoom.value * 100).toFixed(0)}%
|
||||
</div>
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
);
|
||||
|
@ -1,21 +0,0 @@
|
||||
.excalidraw {
|
||||
.ActiveFile {
|
||||
.ActiveFile__fileName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 9.3em;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1.15em;
|
||||
margin-inline-end: 0.3em;
|
||||
transform: scaleY(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
import Stack from "../components/Stack";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { save, file } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import "./ActiveFile.scss";
|
||||
|
||||
type ActiveFileProps = {
|
||||
fileName?: string;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
|
||||
<Stack.Row className="ActiveFile" gap={1} align="center">
|
||||
<span className="ActiveFile__fileName">
|
||||
{file}
|
||||
<span>{fileName}</span>
|
||||
</span>
|
||||
<ToolButton
|
||||
type="icon"
|
||||
icon={save}
|
||||
title={t("buttons.save")}
|
||||
aria-label={t("buttons.save")}
|
||||
onClick={onSave}
|
||||
data-testid="save-button"
|
||||
/>
|
||||
</Stack.Row>
|
||||
);
|
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { AppState } from "../types";
|
||||
import { DarkModeToggle } from "./DarkModeToggle";
|
||||
|
||||
export const BackgroundPickerAndDarkModeToggle = ({
|
||||
appState,
|
||||
@ -15,6 +16,15 @@ export const BackgroundPickerAndDarkModeToggle = ({
|
||||
}) => (
|
||||
<div style={{ display: "flex" }}>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
{showThemeBtn && actionManager.renderAction("toggleTheme")}
|
||||
{showThemeBtn && (
|
||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
||||
<DarkModeToggle
|
||||
value={appState.theme}
|
||||
onChange={(theme) => {
|
||||
setAppState({ theme });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const ButtonSelect = <T extends Object>({
|
||||
|
@ -1,57 +0,0 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.Card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
max-width: 290px;
|
||||
|
||||
margin: 1em;
|
||||
|
||||
text-align: center;
|
||||
|
||||
.Card-icon {
|
||||
font-size: 2.6em;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
padding: 1.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--card-color);
|
||||
color: $oc-white;
|
||||
|
||||
svg {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.Card-details {
|
||||
font-size: 0.96em;
|
||||
min-height: 90px;
|
||||
padding: 0 1em;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
& .Card-button.ToolIcon_type_button {
|
||||
height: 2.5rem;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.3em;
|
||||
background-color: var(--card-color);
|
||||
&:hover {
|
||||
background-color: var(--card-color-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--card-color-darkest);
|
||||
}
|
||||
.ToolIcon__label {
|
||||
color: $oc-white;
|
||||
}
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
import OpenColor from "open-color";
|
||||
|
||||
import "./Card.scss";
|
||||
|
||||
export const Card: React.FC<{
|
||||
color: keyof OpenColor;
|
||||
}> = ({ children, color }) => {
|
||||
return (
|
||||
<div
|
||||
className="Card"
|
||||
style={{
|
||||
["--card-color" as any]: OpenColor[color][7],
|
||||
["--card-color-darker" as any]: OpenColor[color][8],
|
||||
["--card-color-darkest" as any]: OpenColor[color][9],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,89 +0,0 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.Checkbox {
|
||||
margin: 4px 0.3em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
|
||||
box-shadow: 0 0 0 2px #{$oc-blue-4};
|
||||
}
|
||||
|
||||
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
|
||||
svg {
|
||||
display: block;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
.Checkbox-box {
|
||||
box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.Checkbox-box {
|
||||
background-color: fade-out($oc-blue-1, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-checked {
|
||||
.Checkbox-box {
|
||||
background-color: #{$oc-blue-1};
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
&:hover .Checkbox-box {
|
||||
background-color: #{$oc-blue-2};
|
||||
}
|
||||
}
|
||||
|
||||
.Checkbox-box {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
flex: 0 0 auto;
|
||||
|
||||
margin: 0 1em;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
box-shadow: 0 0 0 2px #{$oc-blue-7};
|
||||
background-color: transparent;
|
||||
border-radius: 4px;
|
||||
|
||||
color: #{$oc-blue-7};
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 3px #{$oc-blue-7};
|
||||
}
|
||||
|
||||
svg {
|
||||
display: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.Checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.excalidraw-tooltip-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { checkIcon } from "./icons";
|
||||
|
||||
import "./CheckboxItem.scss";
|
||||
|
||||
export const CheckboxItem: React.FC<{
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}> = ({ children, checked, onChange }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("Checkbox", { "is-checked": checked })}
|
||||
onClick={(event) => {
|
||||
onChange(!checked);
|
||||
((event.currentTarget as HTMLDivElement).querySelector(
|
||||
".Checkbox-box",
|
||||
) as HTMLButtonElement).focus();
|
||||
}}
|
||||
>
|
||||
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
||||
{checkIcon}
|
||||
</button>
|
||||
<div className="Checkbox-label">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,42 +0,0 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.clear-canvas {
|
||||
&-buttons {
|
||||
display: flex;
|
||||
padding: 0.2rem 0;
|
||||
justify-content: flex-end;
|
||||
|
||||
.ToolIcon__icon {
|
||||
min-width: 2.5rem;
|
||||
width: auto;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.ToolIcon_type_button {
|
||||
margin-left: 1.5rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&--confirm.ToolIcon_type_button {
|
||||
background-color: $oc-red-6;
|
||||
|
||||
&:hover {
|
||||
background-color: $oc-red-8;
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
color: $oc-white;
|
||||
}
|
||||
}
|
||||
|
||||
&--cancel.ToolIcon_type_button {
|
||||
background-color: $oc-gray-2;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { trash } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
import "./ClearCanvas.scss";
|
||||
|
||||
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const toggleDialog = () => {
|
||||
setShowDialog(!showDialog);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={trash}
|
||||
title={t("buttons.clearReset")}
|
||||
aria-label={t("buttons.clearReset")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={toggleDialog}
|
||||
data-testid="clear-canvas-button"
|
||||
/>
|
||||
|
||||
{showDialog && (
|
||||
<Dialog
|
||||
onCloseRequest={toggleDialog}
|
||||
title={t("clearCanvasDialog.title")}
|
||||
className="clear-canvas"
|
||||
small={true}
|
||||
>
|
||||
<>
|
||||
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
|
||||
<div className="clear-canvas-buttons">
|
||||
<ToolButton
|
||||
type="button"
|
||||
title={t("buttons.clear")}
|
||||
aria-label={t("buttons.clear")}
|
||||
label={t("buttons.clear")}
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
toggleDialog();
|
||||
}}
|
||||
data-testid="confirm-clear-canvas-button"
|
||||
className="clear-canvas--confirm"
|
||||
/>
|
||||
<ToolButton
|
||||
type="button"
|
||||
title={t("buttons.cancel")}
|
||||
aria-label={t("buttons.cancel")}
|
||||
label={t("buttons.cancel")}
|
||||
onClick={toggleDialog}
|
||||
data-testid="cancel-clear-canvas-button"
|
||||
className="clear-canvas--cancel"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClearCanvas;
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { t } from "../i18n";
|
||||
|
@ -160,7 +160,7 @@
|
||||
}
|
||||
|
||||
.color-picker-input {
|
||||
width: 11ch; /* length of `transparent` */
|
||||
width: 12ch; /* length of `transparent` + 1 */
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
background-color: var(--input-bg-color);
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { Popover } from "./Popover";
|
||||
import { isTransparent } from "../utils";
|
||||
|
||||
import "./ColorPicker.scss";
|
||||
import { isArrowKey, KEYS } from "../keys";
|
||||
@ -15,7 +14,7 @@ const isValidColor = (color: string) => {
|
||||
};
|
||||
|
||||
const getColor = (color: string): string | null => {
|
||||
if (isTransparent(color)) {
|
||||
if (color === "transparent") {
|
||||
return color;
|
||||
}
|
||||
|
||||
@ -116,7 +115,6 @@ const Picker = ({
|
||||
onClose();
|
||||
}
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
@ -138,41 +136,36 @@ const Picker = ({
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
{colors.map((_color, i) => {
|
||||
const _colorWithoutHash = _color.replace("#", "");
|
||||
return (
|
||||
<button
|
||||
className="color-picker-swatch"
|
||||
onClick={(event) => {
|
||||
(event.currentTarget as HTMLButtonElement).focus();
|
||||
onChange(_color);
|
||||
}}
|
||||
title={`${t(`colors.${_colorWithoutHash}`)}${
|
||||
!isTransparent(_color) ? ` (${_color})` : ""
|
||||
} — ${keyBindings[i].toUpperCase()}`}
|
||||
aria-label={t(`colors.${_colorWithoutHash}`)}
|
||||
aria-keyshortcuts={keyBindings[i]}
|
||||
style={{ color: _color }}
|
||||
key={_color}
|
||||
ref={(el) => {
|
||||
if (el && i === 0) {
|
||||
firstItem.current = el;
|
||||
}
|
||||
if (el && _color === color) {
|
||||
activeItem.current = el;
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
onChange(_color);
|
||||
}}
|
||||
>
|
||||
{isTransparent(_color) ? (
|
||||
<div className="color-picker-transparent"></div>
|
||||
) : undefined}
|
||||
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{colors.map((_color, i) => (
|
||||
<button
|
||||
className="color-picker-swatch"
|
||||
onClick={(event) => {
|
||||
(event.currentTarget as HTMLButtonElement).focus();
|
||||
onChange(_color);
|
||||
}}
|
||||
title={`${_color} — ${keyBindings[i].toUpperCase()}`}
|
||||
aria-label={_color}
|
||||
aria-keyshortcuts={keyBindings[i]}
|
||||
style={{ color: _color }}
|
||||
key={_color}
|
||||
ref={(el) => {
|
||||
if (el && i === 0) {
|
||||
firstItem.current = el;
|
||||
}
|
||||
if (el && _color === color) {
|
||||
activeItem.current = el;
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
onChange(_color);
|
||||
}}
|
||||
>
|
||||
{_color === "transparent" ? (
|
||||
<div className="color-picker-transparent"></div>
|
||||
) : undefined}
|
||||
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
||||
</button>
|
||||
))}
|
||||
{showInput && (
|
||||
<ColorInput
|
||||
color={color}
|
||||
@ -244,16 +237,13 @@ export const ColorPicker = ({
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
isActive,
|
||||
setActive,
|
||||
}: {
|
||||
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
setActive: (active: boolean) => void;
|
||||
}) => {
|
||||
const [isActive, setActive] = React.useState(false);
|
||||
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { Popover } from "./Popover";
|
||||
|
@ -1,32 +1,42 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { THEME } from "../constants";
|
||||
import { Theme } from "../element/types";
|
||||
|
||||
export type Appearence = "light" | "dark";
|
||||
|
||||
// We chose to use only explicit toggle and not a third option for system value,
|
||||
// but this could be added in the future.
|
||||
export const DarkModeToggle = (props: {
|
||||
value: Theme;
|
||||
onChange: (value: Theme) => void;
|
||||
value: Appearence;
|
||||
onChange: (value: Appearence) => void;
|
||||
title?: string;
|
||||
}) => {
|
||||
const title =
|
||||
props.title ||
|
||||
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
|
||||
const title = props.title
|
||||
? props.title
|
||||
: props.value === "dark"
|
||||
? t("buttons.lightMode")
|
||||
: t("buttons.darkMode");
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
type="icon"
|
||||
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={() =>
|
||||
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
|
||||
}
|
||||
<label
|
||||
className="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
|
||||
data-testid="toggle-dark-mode"
|
||||
/>
|
||||
title={title}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
|
||||
type="checkbox"
|
||||
onChange={(event) =>
|
||||
props.onChange(event.target.checked ? "dark" : "light")
|
||||
}
|
||||
checked={props.value === "dark"}
|
||||
aria-label={title}
|
||||
/>
|
||||
<div className="ToolIcon__icon">
|
||||
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,14 +1,13 @@
|
||||
import clsx from "clsx";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import { useExcalidrawContainer, useIsMobile } from "../components/App";
|
||||
import { 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";
|
||||
|
||||
export const Dialog = (props: {
|
||||
children: React.ReactNode;
|
||||
@ -17,11 +16,8 @@ export const Dialog = (props: {
|
||||
onCloseRequest(): void;
|
||||
title: React.ReactNode;
|
||||
autofocus?: boolean;
|
||||
theme?: AppState["theme"];
|
||||
}) => {
|
||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||
const [lastActiveElement] = useState(document.activeElement);
|
||||
const { id } = useExcalidrawContainer();
|
||||
|
||||
useEffect(() => {
|
||||
if (!islandNode) {
|
||||
@ -69,25 +65,19 @@ export const Dialog = (props: {
|
||||
return focusableElements ? Array.from(focusableElements) : [];
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
(lastActiveElement as HTMLElement).focus();
|
||||
props.onCloseRequest();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={clsx("Dialog", props.className)}
|
||||
labelledBy="dialog-title"
|
||||
maxWidth={props.small ? 550 : 800}
|
||||
onCloseRequest={onClose}
|
||||
theme={props.theme}
|
||||
onCloseRequest={props.onCloseRequest}
|
||||
>
|
||||
<Island ref={setIslandNode}>
|
||||
<h2 id={`${id}-dialog-title`} className="Dialog__title">
|
||||
<h2 id="dialog-title" className="Dialog__title">
|
||||
<span className="Dialog__titleContent">{props.title}</span>
|
||||
<button
|
||||
className="Modal__close"
|
||||
onClick={onClose}
|
||||
onClick={props.onCloseRequest}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{useIsMobile() ? back : close}
|
||||
|
@ -2,7 +2,6 @@ import React, { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
export const ErrorDialog = ({
|
||||
message,
|
||||
@ -12,7 +11,6 @@ export const ErrorDialog = ({
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(!!message);
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
@ -20,9 +18,7 @@ export const ErrorDialog = ({
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
// TODO: Fix the A11y issues so this is never needed since we should always focus on last active element
|
||||
excalidrawContainer?.focus();
|
||||
}, [onClose, excalidrawContainer]);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -28,6 +28,33 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ExportDialog__name {
|
||||
grid-column: project-name;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.TextInput {
|
||||
height: calc(1rem - 3px);
|
||||
width: 200px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
margin-left: 8px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&--readonly {
|
||||
background: none;
|
||||
border: none;
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
width: auto;
|
||||
max-width: 200px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
.ExportDialog {
|
||||
display: flex;
|
||||
@ -57,63 +84,4 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ExportDialog--json {
|
||||
.ExportDialog-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
justify-items: center;
|
||||
row-gap: 2em;
|
||||
|
||||
@media (max-width: 460px) {
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
.Card-details {
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.ProjectName {
|
||||
width: fit-content;
|
||||
margin: 1em auto;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
.TextInput {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ProjectName-label {
|
||||
margin: 0.625em 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button.ExportDialog-imageExportButton {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
margin: 0 0.2em;
|
||||
|
||||
border-radius: 1rem;
|
||||
background-color: var(--button-color);
|
||||
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.28),
|
||||
0 6px 10px 0 rgba(0, 0, 0, 0.14);
|
||||
|
||||
font-family: Cascadia;
|
||||
font-size: 1.8em;
|
||||
color: $oc-white;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-color-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--button-color-darkest);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 0.9em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
285
src/components/ExportDialog.tsx
Normal file
285
src/components/ExportDialog.tsx
Normal file
@ -0,0 +1,285 @@
|
||||
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 { useIsMobile } from "../components/App";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas, getExportSize } from "../scene/export";
|
||||
import { AppState } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import "./ExportDialog.scss";
|
||||
import { clipboard, exportFile, link } from "./icons";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
const scales = [1, 2, 3];
|
||||
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
|
||||
|
||||
const supportsContextFilters =
|
||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||
|
||||
export const ErrorCanvasPreview = () => {
|
||||
return (
|
||||
<div>
|
||||
<h3>{t("canvasError.cannotShowPreview")}</h3>
|
||||
<p>
|
||||
<span>{t("canvasError.canvasTooBig")}</span>
|
||||
</p>
|
||||
<em>({t("canvasError.canvasTooBigTip")})</em>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPreview = (
|
||||
content: HTMLCanvasElement | Error,
|
||||
previewNode: HTMLDivElement,
|
||||
) => {
|
||||
unmountComponentAtNode(previewNode);
|
||||
previewNode.innerHTML = "";
|
||||
if (content instanceof HTMLCanvasElement) {
|
||||
previewNode.appendChild(content);
|
||||
} else {
|
||||
render(<ErrorCanvasPreview />, previewNode);
|
||||
}
|
||||
};
|
||||
|
||||
export type ExportCB = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
scale?: number,
|
||||
) => void;
|
||||
|
||||
const ExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding = 10,
|
||||
actionManager,
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
onExportToClipboard,
|
||||
onExportToBackend,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
exportPadding?: number;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportToBackend?: ExportCB;
|
||||
onCloseRequest: () => void;
|
||||
}) => {
|
||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||
const [scale, setScale] = useState(defaultScale);
|
||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
shouldAddWatermark,
|
||||
} = appState;
|
||||
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState)
|
||||
: elements;
|
||||
|
||||
useEffect(() => {
|
||||
setExportSelected(someElementIsSelected);
|
||||
}, [someElementIsSelected]);
|
||||
|
||||
useEffect(() => {
|
||||
const previewNode = previewRef.current;
|
||||
if (!previewNode) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const canvas = exportToCanvas(exportedElements, appState, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
scale,
|
||||
shouldAddWatermark,
|
||||
});
|
||||
|
||||
// if converting to blob fails, there's some problem that will
|
||||
// likely prevent preview and export (e.g. canvas too big)
|
||||
canvasToBlob(canvas)
|
||||
.then(() => {
|
||||
renderPreview(canvas, previewNode);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
renderPreview(new CanvasError(), previewNode);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
renderPreview(new CanvasError(), previewNode);
|
||||
}
|
||||
}, [
|
||||
appState,
|
||||
exportedElements,
|
||||
exportBackground,
|
||||
exportPadding,
|
||||
viewBackgroundColor,
|
||||
scale,
|
||||
shouldAddWatermark,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="ExportDialog">
|
||||
<div className="ExportDialog__preview" ref={previewRef} />
|
||||
{supportsContextFilters &&
|
||||
actionManager.renderAction("exportWithDarkMode")}
|
||||
<Stack.Col gap={2} align="center">
|
||||
<div className="ExportDialog__actions">
|
||||
<Stack.Row gap={2}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
label="PNG"
|
||||
title={t("buttons.exportToPng")}
|
||||
aria-label={t("buttons.exportToPng")}
|
||||
onClick={() => onExportToPng(exportedElements, scale)}
|
||||
/>
|
||||
<ToolButton
|
||||
type="button"
|
||||
label="SVG"
|
||||
title={t("buttons.exportToSvg")}
|
||||
aria-label={t("buttons.exportToSvg")}
|
||||
onClick={() => onExportToSvg(exportedElements, scale)}
|
||||
/>
|
||||
{probablySupportsClipboardBlob && (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={clipboard}
|
||||
title={t("buttons.copyPngToClipboard")}
|
||||
aria-label={t("buttons.copyPngToClipboard")}
|
||||
onClick={() => onExportToClipboard(exportedElements, scale)}
|
||||
/>
|
||||
)}
|
||||
{onExportToBackend && (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={link}
|
||||
title={t("buttons.getShareableLink")}
|
||||
aria-label={t("buttons.getShareableLink")}
|
||||
onClick={() => onExportToBackend(exportedElements)}
|
||||
/>
|
||||
)}
|
||||
</Stack.Row>
|
||||
<div className="ExportDialog__name">
|
||||
{actionManager.renderAction("changeProjectName")}
|
||||
</div>
|
||||
<Stack.Row gap={2}>
|
||||
{scales.map((s) => {
|
||||
const [width, height] = getExportSize(
|
||||
exportedElements,
|
||||
exportPadding,
|
||||
shouldAddWatermark,
|
||||
s,
|
||||
);
|
||||
|
||||
const scaleButtonTitle = `${t(
|
||||
"buttons.scale",
|
||||
)} ${s}x (${width}x${height})`;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={s}
|
||||
size="s"
|
||||
type="radio"
|
||||
icon={`${s}x`}
|
||||
name="export-canvas-scale"
|
||||
title={scaleButtonTitle}
|
||||
aria-label={scaleButtonTitle}
|
||||
id="export-canvas-scale"
|
||||
checked={s === scale}
|
||||
onChange={() => setScale(s)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack.Row>
|
||||
</div>
|
||||
{actionManager.renderAction("changeExportBackground")}
|
||||
{someElementIsSelected && (
|
||||
<div>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportSelected}
|
||||
onChange={(event) =>
|
||||
setExportSelected(event.currentTarget.checked)
|
||||
}
|
||||
/>{" "}
|
||||
{t("labels.onlySelected")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{actionManager.renderAction("changeExportEmbedScene")}
|
||||
{actionManager.renderAction("changeShouldAddWatermark")}
|
||||
</Stack.Col>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding = 10,
|
||||
actionManager,
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
onExportToClipboard,
|
||||
onExportToBackend,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
exportPadding?: number;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportToBackend?: ExportCB;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
const triggerButton = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
triggerButton.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolButton
|
||||
onClick={() => {
|
||||
setModalIsShown(true);
|
||||
}}
|
||||
data-testid="export-button"
|
||||
icon={exportFile}
|
||||
type="button"
|
||||
aria-label={t("buttons.export")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
title={t("buttons.export")}
|
||||
ref={triggerButton}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||
<ExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
exportPadding={exportPadding}
|
||||
actionManager={actionManager}
|
||||
onExportToPng={onExportToPng}
|
||||
onExportToSvg={onExportToSvg}
|
||||
onExportToClipboard={onExportToClipboard}
|
||||
onExportToBackend={onExportToBackend}
|
||||
onCloseRequest={handleClose}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,5 +1,6 @@
|
||||
.excalidraw {
|
||||
.FixedSideContainer {
|
||||
--margin: 0.25rem;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
@ -9,9 +10,9 @@
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top {
|
||||
left: var(--space-factor);
|
||||
top: var(--space-factor);
|
||||
right: var(--space-factor);
|
||||
left: var(--margin);
|
||||
top: var(--margin);
|
||||
right: var(--margin);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@ -22,16 +23,16 @@
|
||||
|
||||
/* TODO: if these are used, make sure to implement RTL support
|
||||
.FixedSideContainer_side_left {
|
||||
left: var(--space-factor);
|
||||
top: var(--space-factor);
|
||||
bottom: var(--space-factor);
|
||||
left: var(--margin);
|
||||
top: var(--margin);
|
||||
bottom: var(--margin);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_right {
|
||||
right: var(--space-factor);
|
||||
top: var(--space-factor);
|
||||
bottom: var(--space-factor);
|
||||
right: var(--margin);
|
||||
top: var(--margin);
|
||||
bottom: var(--margin);
|
||||
z-index: 3;
|
||||
}
|
||||
*/
|
||||
|
@ -1,23 +1,15 @@
|
||||
import oc from "open-color";
|
||||
import React from "react";
|
||||
import { THEME } from "../../constants";
|
||||
import { Theme } from "../../element/types";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
||||
({ theme, dir }: { theme: Theme; dir: string }) => (
|
||||
({ theme }: { theme: "light" | "dark" }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 250 250"
|
||||
className="rtl-mirror"
|
||||
style={{
|
||||
marginTop: "calc(var(--space-factor) * -1)",
|
||||
[dir === "rtl"
|
||||
? "marginLeft"
|
||||
: "marginRight"]: "calc(var(--space-factor) * -1)",
|
||||
}}
|
||||
className="github-corner rtl-mirror"
|
||||
>
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
@ -27,18 +19,18 @@ export const GitHubCorner = React.memo(
|
||||
>
|
||||
<path
|
||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
|
||||
fill={theme === "light" ? oc.gray[6] : oc.gray[8]}
|
||||
/>
|
||||
<path
|
||||
className="octo-arm"
|
||||
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
|
||||
style={{ transformOrigin: "130px 106px" }}
|
||||
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
||||
fill={theme === "light" ? oc.white : oc.black}
|
||||
/>
|
||||
<path
|
||||
className="octo-body"
|
||||
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
|
||||
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
||||
fill={theme === "light" ? oc.white : oc.black}
|
||||
/>
|
||||
</a>
|
||||
</svg>
|
@ -153,19 +153,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.freedraw")}
|
||||
label={t("toolBar.draw")}
|
||||
shortcuts={["Shift+P", "7"]}
|
||||
/>
|
||||
<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("helpDialog.editSelectedShape")}
|
||||
shortcuts={[
|
||||
getShortcutKey("Enter"),
|
||||
t("helpDialog.doubleClick"),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.textNewLine")}
|
||||
shortcuts={[
|
||||
@ -240,14 +231,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("labels.viewMode")}
|
||||
shortcuts={[getShortcutKey("Alt+R")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.toggleTheme")}
|
||||
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("stats.title")}
|
||||
shortcuts={[getShortcutKey("Alt+/")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
</Column>
|
||||
<Column>
|
||||
@ -374,14 +357,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("labels.flipVertical")}
|
||||
shortcuts={[getShortcutKey("Shift+V")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.showStroke")}
|
||||
shortcuts={[getShortcutKey("S")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.showBackground")}
|
||||
shortcuts={[getShortcutKey("G")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
</Column>
|
||||
</Columns>
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { questionCircle } from "../components/icons";
|
||||
|
||||
type HelpIconProps = {
|
||||
|
@ -1,14 +1,11 @@
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import "./HintViewer.scss";
|
||||
import { AppState } from "../types";
|
||||
import {
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { getShortcutKey } from "../utils";
|
||||
|
||||
interface Hint {
|
||||
@ -26,7 +23,7 @@ const getHints = ({ appState, elements }: Hint) => {
|
||||
return t("hints.linearElementMulti");
|
||||
}
|
||||
|
||||
if (elementType === "freedraw") {
|
||||
if (elementType === "draw") {
|
||||
return t("hints.freeDraw");
|
||||
}
|
||||
|
||||
@ -34,10 +31,6 @@ const getHints = ({ appState, elements }: Hint) => {
|
||||
return t("hints.text");
|
||||
}
|
||||
|
||||
if (appState.elementType === "image" && appState.pendingImageElement) {
|
||||
return t("hints.placeImage");
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
if (
|
||||
isResizing &&
|
||||
@ -48,9 +41,7 @@ const getHints = ({ appState, elements }: Hint) => {
|
||||
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||
return t("hints.lockAngle");
|
||||
}
|
||||
return isImageElement(targetElement)
|
||||
? t("hints.resizeImage")
|
||||
: t("hints.resize");
|
||||
return t("hints.resize");
|
||||
}
|
||||
|
||||
if (isRotating && lastPointerDownWith === "mouse") {
|
||||
@ -66,14 +57,6 @@ const getHints = ({ appState, elements }: Hint) => {
|
||||
return t("hints.lineEditor_info");
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
||||
return t("hints.text_selected");
|
||||
}
|
||||
|
||||
if (appState.editingElement && isTextElement(appState.editingElement)) {
|
||||
return t("hints.text_editing");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
@ -88,7 +88,6 @@ function Picker<T>({
|
||||
onClose();
|
||||
}
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -1,273 +0,0 @@
|
||||
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 { useIsMobile } from "./App";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas } from "../scene/export";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { clipboard, exportImage } from "./icons";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import "./ExportDialog.scss";
|
||||
import OpenColor from "open-color";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
|
||||
const supportsContextFilters =
|
||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||
|
||||
export const ErrorCanvasPreview = () => {
|
||||
return (
|
||||
<div>
|
||||
<h3>{t("canvasError.cannotShowPreview")}</h3>
|
||||
<p>
|
||||
<span>{t("canvasError.canvasTooBig")}</span>
|
||||
</p>
|
||||
<em>({t("canvasError.canvasTooBigTip")})</em>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPreview = (
|
||||
content: HTMLCanvasElement | Error,
|
||||
previewNode: HTMLDivElement,
|
||||
) => {
|
||||
unmountComponentAtNode(previewNode);
|
||||
previewNode.innerHTML = "";
|
||||
if (content instanceof HTMLCanvasElement) {
|
||||
previewNode.appendChild(content);
|
||||
} else {
|
||||
render(<ErrorCanvasPreview />, previewNode);
|
||||
}
|
||||
};
|
||||
|
||||
export type ExportCB = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
scale?: number,
|
||||
) => void;
|
||||
|
||||
const ExportButton: React.FC<{
|
||||
color: keyof OpenColor;
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
shade?: number;
|
||||
}> = ({ children, title, onClick, color, shade = 6 }) => {
|
||||
return (
|
||||
<button
|
||||
className="ExportDialog-imageExportButton"
|
||||
style={{
|
||||
["--button-color" as any]: OpenColor[color][shade],
|
||||
["--button-color-darker" as any]: OpenColor[color][shade + 1],
|
||||
["--button-color-darkest" as any]: OpenColor[color][shade + 2],
|
||||
}}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ImageExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
actionManager,
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
onExportToClipboard,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onCloseRequest: () => void;
|
||||
}) => {
|
||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const { exportBackground, viewBackgroundColor } = appState;
|
||||
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState)
|
||||
: elements;
|
||||
|
||||
useEffect(() => {
|
||||
setExportSelected(someElementIsSelected);
|
||||
}, [someElementIsSelected]);
|
||||
|
||||
useEffect(() => {
|
||||
const previewNode = previewRef.current;
|
||||
if (!previewNode) {
|
||||
return;
|
||||
}
|
||||
exportToCanvas(exportedElements, appState, files, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
})
|
||||
.then((canvas) => {
|
||||
// if converting to blob fails, there's some problem that will
|
||||
// likely prevent preview and export (e.g. canvas too big)
|
||||
return canvasToBlob(canvas).then(() => {
|
||||
renderPreview(canvas, previewNode);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
renderPreview(new CanvasError(), previewNode);
|
||||
});
|
||||
}, [
|
||||
appState,
|
||||
files,
|
||||
exportedElements,
|
||||
exportBackground,
|
||||
exportPadding,
|
||||
viewBackgroundColor,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="ExportDialog">
|
||||
<div className="ExportDialog__preview" ref={previewRef} />
|
||||
{supportsContextFilters &&
|
||||
actionManager.renderAction("exportWithDarkMode")}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))",
|
||||
// dunno why this is needed, but when the items wrap it creates
|
||||
// an overflow
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{actionManager.renderAction("changeExportBackground")}
|
||||
{someElementIsSelected && (
|
||||
<CheckboxItem
|
||||
checked={exportSelected}
|
||||
onChange={(checked) => setExportSelected(checked)}
|
||||
>
|
||||
{t("labels.onlySelected")}
|
||||
</CheckboxItem>
|
||||
)}
|
||||
{actionManager.renderAction("changeExportEmbedScene")}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
|
||||
<Stack.Row gap={2}>
|
||||
{actionManager.renderAction("changeExportScale")}
|
||||
</Stack.Row>
|
||||
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: ".6em 0",
|
||||
}}
|
||||
>
|
||||
{!nativeFileSystemSupported &&
|
||||
actionManager.renderAction("changeProjectName")}
|
||||
</div>
|
||||
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
|
||||
<ExportButton
|
||||
color="indigo"
|
||||
title={t("buttons.exportToPng")}
|
||||
aria-label={t("buttons.exportToPng")}
|
||||
onClick={() => onExportToPng(exportedElements)}
|
||||
>
|
||||
PNG
|
||||
</ExportButton>
|
||||
<ExportButton
|
||||
color="red"
|
||||
title={t("buttons.exportToSvg")}
|
||||
aria-label={t("buttons.exportToSvg")}
|
||||
onClick={() => onExportToSvg(exportedElements)}
|
||||
>
|
||||
SVG
|
||||
</ExportButton>
|
||||
{probablySupportsClipboardBlob && (
|
||||
<ExportButton
|
||||
title={t("buttons.copyPngToClipboard")}
|
||||
onClick={() => onExportToClipboard(exportedElements)}
|
||||
color="gray"
|
||||
shade={7}
|
||||
>
|
||||
{clipboard}
|
||||
</ExportButton>
|
||||
)}
|
||||
</Stack.Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImageExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
actionManager,
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
onExportToClipboard,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolButton
|
||||
onClick={() => {
|
||||
setModalIsShown(true);
|
||||
}}
|
||||
data-testid="image-export-button"
|
||||
icon={exportImage}
|
||||
type="button"
|
||||
aria-label={t("buttons.exportImage")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
title={t("buttons.exportImage")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
|
||||
<ImageExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
exportPadding={exportPadding}
|
||||
actionManager={actionManager}
|
||||
onExportToPng={onExportToPng}
|
||||
onExportToSvg={onExportToSvg}
|
||||
onExportToClipboard={onExportToClipboard}
|
||||
onCloseRequest={handleClose}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,25 +1,30 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
||||
|
||||
interface Props {
|
||||
langCode: Language["code"];
|
||||
children: React.ReactElement;
|
||||
}
|
||||
interface State {
|
||||
isLoading: boolean;
|
||||
}
|
||||
export class InitializeApp extends React.Component<Props, State> {
|
||||
public state: { isLoading: boolean } = {
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
export const InitializeApp = (props: Props) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const updateLang = async () => {
|
||||
await setLanguage(currentLang);
|
||||
};
|
||||
async componentDidMount() {
|
||||
const currentLang =
|
||||
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
||||
updateLang();
|
||||
setLoading(false);
|
||||
}, [props.langCode]);
|
||||
languages.find((lang) => lang.code === this.props.langCode) ||
|
||||
defaultLang;
|
||||
await setLanguage(currentLang);
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
return loading ? <LoadingMessage /> : props.children;
|
||||
};
|
||||
public render() {
|
||||
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
.Island {
|
||||
--padding: 0;
|
||||
background-color: var(--island-bg-color);
|
||||
backdrop-filter: saturate(100%) blur(10px);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: 4px;
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
|
@ -1,135 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { ActionsManagerInterface } from "../actions/types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { actionSaveFileToDisk } from "../actions/actionExport";
|
||||
import { Card } from "./Card";
|
||||
|
||||
import "./ExportDialog.scss";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
|
||||
export type ExportCB = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
scale?: number,
|
||||
) => void;
|
||||
|
||||
const JSONExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
actionManager,
|
||||
exportOpts,
|
||||
canvas,
|
||||
}: {
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionsManagerInterface;
|
||||
onCloseRequest: () => void;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
}) => {
|
||||
const { onExportToBackend } = exportOpts;
|
||||
return (
|
||||
<div className="ExportDialog ExportDialog--json">
|
||||
<div className="ExportDialog-cards">
|
||||
{exportOpts.saveFileToDisk && (
|
||||
<Card color="lime">
|
||||
<div className="Card-icon">{exportToFileIcon}</div>
|
||||
<h2>{t("exportDialog.disk_title")}</h2>
|
||||
<div className="Card-details">
|
||||
{t("exportDialog.disk_details")}
|
||||
{!nativeFileSystemSupported &&
|
||||
actionManager.renderAction("changeProjectName")}
|
||||
</div>
|
||||
<ToolButton
|
||||
className="Card-button"
|
||||
type="button"
|
||||
title={t("exportDialog.disk_button")}
|
||||
aria-label={t("exportDialog.disk_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => {
|
||||
actionManager.executeAction(actionSaveFileToDisk);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
{onExportToBackend && (
|
||||
<Card color="pink">
|
||||
<div className="Card-icon">{link}</div>
|
||||
<h2>{t("exportDialog.link_title")}</h2>
|
||||
<div className="Card-details">{t("exportDialog.link_details")}</div>
|
||||
<ToolButton
|
||||
className="Card-button"
|
||||
type="button"
|
||||
title={t("exportDialog.link_button")}
|
||||
aria-label={t("exportDialog.link_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() =>
|
||||
onExportToBackend(elements, appState, files, canvas)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
{exportOpts.renderCustomUI &&
|
||||
exportOpts.renderCustomUI(elements, appState, files, canvas)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const JSONExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
actionManager,
|
||||
exportOpts,
|
||||
canvas,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
actionManager: ActionsManagerInterface;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolButton
|
||||
onClick={() => {
|
||||
setModalIsShown(true);
|
||||
}}
|
||||
data-testid="json-export-button"
|
||||
icon={exportFile}
|
||||
type="button"
|
||||
aria-label={t("buttons.export")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
title={t("buttons.export")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||
<JSONExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onCloseRequest={handleClose}
|
||||
exportOpts={exportOpts}
|
||||
canvas={canvas}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -40,17 +40,50 @@
|
||||
.layer-ui__wrapper {
|
||||
z-index: var(--zIndex-layerUI);
|
||||
|
||||
&__top-right {
|
||||
.encrypted-icon {
|
||||
position: relative;
|
||||
margin-inline-start: 15px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: var(--space-factor);
|
||||
color: $oc-green-9;
|
||||
|
||||
svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__github-corner {
|
||||
top: 0;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
bottom: 0;
|
||||
|
||||
&-right {
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
:root[dir="ltr"] & {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.zen-mode-transition {
|
||||
@ -72,15 +105,11 @@
|
||||
transform: translate(-999px, 0);
|
||||
}
|
||||
|
||||
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||
transform: translate(-76px, 0);
|
||||
:root[dir="ltr"] &.App-menu_bottom--transition-left {
|
||||
transform: translate(-92px, 0);
|
||||
}
|
||||
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||
transform: translate(76px, 0);
|
||||
}
|
||||
|
||||
&.layer-ui__wrapper__footer-left--transition-bottom {
|
||||
transform: translate(0, 92px);
|
||||
:root[dir="rtl"] &.App-menu_bottom--transition-left {
|
||||
transform: translate(92px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,27 +137,5 @@
|
||||
transition-delay: 0.8s;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__wrapper__footer-center {
|
||||
pointer-events: none;
|
||||
& > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
.layer-ui__wrapper__footer-left,
|
||||
.layer-ui__wrapper__footer-right,
|
||||
.disable-zen-mode--visible {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.layer-ui__wrapper__footer-left {
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
|
||||
.layer-ui__wrapper__footer-right {
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-inline-end: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import { ActionManager } from "../actions/manager";
|
||||
import { CLASSES } from "../constants";
|
||||
import { exportCanvas } from "../data";
|
||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||
import { Library } from "../data/library";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
@ -20,7 +21,6 @@ import {
|
||||
AppProps,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
LibraryItem,
|
||||
LibraryItems,
|
||||
} from "../types";
|
||||
@ -29,15 +29,16 @@ import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
||||
import { ExportCB, ExportDialog } from "./ExportDialog";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { GitHubCorner } from "./GitHubCorner";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import { exportFile, load, trash } from "./icons";
|
||||
import { exportFile, load, shield, trash } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import "./LayerUI.scss";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { LockIcon } from "./LockIcon";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
import { PasteChartDialog } from "./PasteChartDialog";
|
||||
import { Section } from "./Section";
|
||||
@ -46,15 +47,10 @@ import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { UserList } from "./UserList";
|
||||
import Library from "../data/library";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
@ -67,18 +63,15 @@ interface LayerUIProps {
|
||||
toggleZenMode: () => void;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
onExportToBackend?: (
|
||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
canvas: HTMLCanvasElement | null,
|
||||
) => void;
|
||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
}
|
||||
|
||||
const useOnClickOutside = (
|
||||
@ -110,36 +103,26 @@ const useOnClickOutside = (
|
||||
};
|
||||
|
||||
const LibraryMenuItems = ({
|
||||
libraryItems,
|
||||
library,
|
||||
onRemoveFromLibrary,
|
||||
onAddToLibrary,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
theme,
|
||||
setAppState,
|
||||
setLibraryItems,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
files,
|
||||
id,
|
||||
}: {
|
||||
libraryItems: LibraryItems;
|
||||
library: LibraryItems;
|
||||
pendingElements: LibraryItem;
|
||||
onRemoveFromLibrary: (index: number) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
onAddToLibrary: (elements: LibraryItem) => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
setLibraryItems: (library: LibraryItems) => void;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
|
||||
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
|
||||
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
|
||||
const rows = [];
|
||||
@ -157,7 +140,7 @@ const LibraryMenuItems = ({
|
||||
aria-label={t("buttons.load")}
|
||||
icon={load}
|
||||
onClick={() => {
|
||||
importLibraryFromJSON(library)
|
||||
importLibraryFromJSON()
|
||||
.then(() => {
|
||||
// Close and then open to get the libraries updated
|
||||
setAppState({ isLibraryOpen: false });
|
||||
@ -169,7 +152,7 @@ const LibraryMenuItems = ({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!!libraryItems.length && (
|
||||
{!!library.length && (
|
||||
<>
|
||||
<ToolButton
|
||||
key="export"
|
||||
@ -178,7 +161,7 @@ const LibraryMenuItems = ({
|
||||
aria-label={t("buttons.export")}
|
||||
icon={exportFile}
|
||||
onClick={() => {
|
||||
saveLibraryAsJSON(library)
|
||||
saveLibraryAsJSON()
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
@ -193,9 +176,8 @@ const LibraryMenuItems = ({
|
||||
icon={trash}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("alerts.resetLibrary"))) {
|
||||
library.resetLibrary();
|
||||
Library.resetLibrary();
|
||||
setLibraryItems([]);
|
||||
focusContainer();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -204,7 +186,7 @@ const LibraryMenuItems = ({
|
||||
<a
|
||||
href={`https://libraries.excalidraw.com?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
|
||||
}&referrer=${referrer}&useHash=true&token=${Library.csrfToken}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
@ -219,14 +201,13 @@ const LibraryMenuItems = ({
|
||||
const shouldAddPendingElements: boolean =
|
||||
pendingElements.length > 0 &&
|
||||
!addedPendingElements &&
|
||||
y + x >= libraryItems.length;
|
||||
y + x >= library.length;
|
||||
addedPendingElements = addedPendingElements || shouldAddPendingElements;
|
||||
|
||||
children.push(
|
||||
<Stack.Col key={x}>
|
||||
<LibraryUnit
|
||||
elements={libraryItems[y + x]}
|
||||
files={files}
|
||||
elements={library[y + x]}
|
||||
pendingElements={
|
||||
shouldAddPendingElements ? pendingElements : undefined
|
||||
}
|
||||
@ -234,7 +215,7 @@ const LibraryMenuItems = ({
|
||||
onClick={
|
||||
shouldAddPendingElements
|
||||
? onAddToLibrary.bind(null, pendingElements)
|
||||
: onInsertShape.bind(null, libraryItems[y + x])
|
||||
: onInsertShape.bind(null, library[y + x])
|
||||
}
|
||||
/>
|
||||
</Stack.Col>,
|
||||
@ -259,25 +240,15 @@ const LibraryMenu = ({
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
theme,
|
||||
setAppState,
|
||||
files,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
}: {
|
||||
pendingElements: LibraryItem;
|
||||
onClickOutside: (event: MouseEvent) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
onAddToLibrary: () => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useOnClickOutside(ref, (event) => {
|
||||
@ -294,16 +265,16 @@ const LibraryMenu = ({
|
||||
"preloading" | "loading" | "ready"
|
||||
>("preloading");
|
||||
|
||||
const loadingTimerRef = useRef<number | null>(null);
|
||||
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.race([
|
||||
new Promise((resolve) => {
|
||||
loadingTimerRef.current = window.setTimeout(() => {
|
||||
loadingTimerRef.current = setTimeout(() => {
|
||||
resolve("loading");
|
||||
}, 100);
|
||||
}),
|
||||
library.loadLibrary().then((items) => {
|
||||
Library.loadLibrary().then((items) => {
|
||||
setLibraryItems(items);
|
||||
setIsLoading("ready");
|
||||
}),
|
||||
@ -315,39 +286,24 @@ const LibraryMenu = ({
|
||||
return () => {
|
||||
clearTimeout(loadingTimerRef.current!);
|
||||
};
|
||||
}, [library]);
|
||||
}, []);
|
||||
|
||||
const removeFromLibrary = useCallback(
|
||||
async (indexToRemove) => {
|
||||
const items = await library.loadLibrary();
|
||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
[library, setAppState],
|
||||
);
|
||||
const removeFromLibrary = useCallback(async (indexToRemove) => {
|
||||
const items = await Library.loadLibrary();
|
||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
||||
Library.saveLibrary(nextItems);
|
||||
setLibraryItems(nextItems);
|
||||
}, []);
|
||||
|
||||
const addToLibrary = useCallback(
|
||||
async (elements: LibraryItem) => {
|
||||
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 items = await Library.loadLibrary();
|
||||
const nextItems = [...items, elements];
|
||||
onAddToLibrary();
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||
});
|
||||
Library.saveLibrary(nextItems);
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
[onAddToLibrary, library, setAppState],
|
||||
[onAddToLibrary],
|
||||
);
|
||||
|
||||
return loadingState === "preloading" ? null : (
|
||||
@ -358,7 +314,7 @@ const LibraryMenu = ({
|
||||
</div>
|
||||
) : (
|
||||
<LibraryMenuItems
|
||||
libraryItems={libraryItems}
|
||||
library={libraryItems}
|
||||
onRemoveFromLibrary={removeFromLibrary}
|
||||
onAddToLibrary={addToLibrary}
|
||||
onInsertShape={onInsertShape}
|
||||
@ -366,11 +322,6 @@ const LibraryMenu = ({
|
||||
setAppState={setAppState}
|
||||
setLibraryItems={setLibraryItems}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
theme={theme}
|
||||
files={files}
|
||||
id={id}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
@ -380,7 +331,6 @@ const LibraryMenu = ({
|
||||
const LayerUI = ({
|
||||
actionManager,
|
||||
appState,
|
||||
files,
|
||||
setAppState,
|
||||
canvas,
|
||||
elements,
|
||||
@ -392,86 +342,75 @@ const LayerUI = ({
|
||||
showThemeBtn,
|
||||
toggleZenMode,
|
||||
isCollaborating,
|
||||
renderTopRightUI,
|
||||
onExportToBackend,
|
||||
renderCustomFooter,
|
||||
viewModeEnabled,
|
||||
libraryReturnUrl,
|
||||
UIOptions,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
onImageAction,
|
||||
}: LayerUIProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const renderJSONExportDialog = () => {
|
||||
const renderEncryptedIcon = () => (
|
||||
<a
|
||||
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
|
||||
"zen-mode-visibility--hidden": zenModeEnabled,
|
||||
})}
|
||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("encrypted.link")}
|
||||
>
|
||||
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
|
||||
{shield}
|
||||
</Tooltip>
|
||||
</a>
|
||||
);
|
||||
|
||||
const renderExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<JSONExportDialog
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
exportOpts={UIOptions.canvasActions.export}
|
||||
canvas={canvas}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderImageExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.saveAsImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createExporter = (type: ExportType): ExportCB => async (
|
||||
exportedElements,
|
||||
scale,
|
||||
) => {
|
||||
const fileHandle = await exportCanvas(
|
||||
type,
|
||||
exportedElements,
|
||||
appState,
|
||||
files,
|
||||
{
|
||||
if (canvas) {
|
||||
await exportCanvas(type, exportedElements, appState, canvas, {
|
||||
exportBackground: appState.exportBackground,
|
||||
name: appState.name,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
},
|
||||
)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
|
||||
if (
|
||||
appState.exportEmbedScene &&
|
||||
fileHandle &&
|
||||
isImageFileHandle(fileHandle)
|
||||
) {
|
||||
setAppState({ fileHandle });
|
||||
scale,
|
||||
shouldAddWatermark: appState.shouldAddWatermark,
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ImageExportDialog
|
||||
<ExportDialog
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onExportToPng={createExporter("png")}
|
||||
onExportToSvg={createExporter("svg")}
|
||||
onExportToClipboard={createExporter("clipboard")}
|
||||
onExportToBackend={
|
||||
onExportToBackend
|
||||
? (elements) => {
|
||||
onExportToBackend &&
|
||||
onExportToBackend(elements, appState, canvas);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Separator = () => {
|
||||
return <div style={{ width: ".625em" }} />;
|
||||
};
|
||||
|
||||
const renderViewModeCanvasActions = () => {
|
||||
return (
|
||||
<Section
|
||||
@ -485,15 +424,15 @@ const LayerUI = ({
|
||||
<Island padding={2} style={{ zIndex: 1 }}>
|
||||
<Stack.Col gap={4}>
|
||||
<Stack.Row gap={1} justifyContent="space-between">
|
||||
{renderJSONExportDialog()}
|
||||
{renderImageExportDialog()}
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{renderExportDialog()}
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
</Island>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCanvasActions = () => (
|
||||
<Section
|
||||
heading="canvasActions"
|
||||
@ -506,12 +445,11 @@ const LayerUI = ({
|
||||
<Island padding={2} style={{ zIndex: 1 }}>
|
||||
<Stack.Col gap={4}>
|
||||
<Stack.Row gap={1} justifyContent="space-between">
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
<Separator />
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{renderJSONExportDialog()}
|
||||
{renderImageExportDialog()}
|
||||
<Separator />
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{renderExportDialog()}
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
@ -526,9 +464,6 @@ const LayerUI = ({
|
||||
setAppState={setAppState}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
{appState.fileHandle && (
|
||||
<>{actionManager.renderAction("saveToActiveFile")}</>
|
||||
)}
|
||||
</Stack.Col>
|
||||
</Island>
|
||||
</Section>
|
||||
@ -547,8 +482,7 @@ const LayerUI = ({
|
||||
style={{
|
||||
// we want to make sure this doesn't overflow so substracting 200
|
||||
// which is approximately height of zoom footer and top left menu items with some buffer
|
||||
// if active file name is displayed, subtracting 248 to account for its height
|
||||
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
|
||||
maxHeight: `${appState.height - 200}px`,
|
||||
}}
|
||||
>
|
||||
<SelectedShapeActions
|
||||
@ -583,11 +517,6 @@ const LayerUI = ({
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
theme={appState.theme}
|
||||
files={files}
|
||||
id={id}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
@ -614,12 +543,6 @@ const LayerUI = ({
|
||||
{(heading) => (
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row gap={1}>
|
||||
<LockButton
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
<Island
|
||||
padding={1}
|
||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
||||
@ -631,17 +554,15 @@ const LayerUI = ({
|
||||
canvas={canvas}
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
});
|
||||
}}
|
||||
isLibraryOpen={appState.isLibraryOpen}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
<LibraryButton
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
<LockIcon
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
@ -649,32 +570,24 @@ const LayerUI = ({
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||
{
|
||||
"transition-right": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
<UserList
|
||||
className={clsx("zen-mode-transition", {
|
||||
"transition-right": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<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>
|
||||
{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", clientId)}
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
</div>
|
||||
</FixedSideContainer>
|
||||
);
|
||||
@ -682,71 +595,61 @@ const LayerUI = ({
|
||||
|
||||
const renderBottomAppMenu = () => {
|
||||
return (
|
||||
<footer
|
||||
role="contentinfo"
|
||||
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
|
||||
<div
|
||||
className={clsx("App-menu App-menu_bottom zen-mode-transition", {
|
||||
"App-menu_bottom--transition-left": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-left zen-mode-transition",
|
||||
{
|
||||
"layer-ui__wrapper__footer-left--transition-left": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Stack.Col gap={2}>
|
||||
<Section heading="canvasActions">
|
||||
<Island padding={1}>
|
||||
<ZoomActions
|
||||
renderAction={actionManager.renderAction}
|
||||
zoom={appState.zoom}
|
||||
/>
|
||||
</Island>
|
||||
{!viewModeEnabled && (
|
||||
<div
|
||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("undo", { size: "small" })}
|
||||
{actionManager.renderAction("redo", { size: "small" })}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-center zen-mode-transition",
|
||||
{
|
||||
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{renderCustomFooter?.(false, appState)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-right zen-mode-transition",
|
||||
{
|
||||
"transition-right disable-pointerEvents": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{actionManager.renderAction("toggleShortcuts")}
|
||||
</div>
|
||||
<button
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||
})}
|
||||
onClick={toggleZenMode}
|
||||
>
|
||||
{t("buttons.exitZenMode")}
|
||||
</button>
|
||||
</footer>
|
||||
<Stack.Col gap={2}>
|
||||
<Section heading="canvasActions">
|
||||
<Island padding={1}>
|
||||
<ZoomActions
|
||||
renderAction={actionManager.renderAction}
|
||||
zoom={appState.zoom}
|
||||
/>
|
||||
</Island>
|
||||
{renderEncryptedIcon()}
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGitHubCorner = () => {
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__github-corner zen-mode-transition",
|
||||
{
|
||||
"transition-right": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<GitHubCorner theme={appState.theme} />
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
const renderFooter = () => (
|
||||
<footer role="contentinfo" className="layer-ui__wrapper__footer">
|
||||
<div
|
||||
className={clsx("zen-mode-transition", {
|
||||
"transition-right disable-pointerEvents": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{renderCustomFooter?.(false)}
|
||||
{actionManager.renderAction("toggleShortcuts")}
|
||||
</div>
|
||||
<button
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||
})}
|
||||
onClick={toggleZenMode}
|
||||
>
|
||||
{t("buttons.exitZenMode")}
|
||||
</button>
|
||||
</footer>
|
||||
);
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage />}
|
||||
@ -757,11 +660,7 @@ const LayerUI = ({
|
||||
/>
|
||||
)}
|
||||
{appState.showHelpDialog && (
|
||||
<HelpDialog
|
||||
onClose={() => {
|
||||
setAppState({ showHelpDialog: false });
|
||||
}}
|
||||
/>
|
||||
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
|
||||
)}
|
||||
{appState.pasteDialog.shown && (
|
||||
<PasteChartDialog
|
||||
@ -786,8 +685,7 @@ const LayerUI = ({
|
||||
elements={elements}
|
||||
actionManager={actionManager}
|
||||
libraryMenu={libraryMenu}
|
||||
renderJSONExportDialog={renderJSONExportDialog}
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
exportButton={renderExportDialog()}
|
||||
setAppState={setAppState}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={onLockToggle}
|
||||
@ -796,8 +694,6 @@ const LayerUI = ({
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
viewModeEnabled={viewModeEnabled}
|
||||
showThemeBtn={showThemeBtn}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@ -812,6 +708,8 @@ const LayerUI = ({
|
||||
{dialogs}
|
||||
{renderFixedSideContainer()}
|
||||
{renderBottomAppMenu()}
|
||||
{renderGitHubCorner()}
|
||||
{renderFooter()}
|
||||
{appState.scrolledOutside && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
|
@ -1,46 +0,0 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { t } from "../i18n";
|
||||
import { AppState } from "../types";
|
||||
import { capitalizeString } from "../utils";
|
||||
|
||||
const LIBRARY_ICON = (
|
||||
<svg viewBox="0 0 576 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M542.22 32.05c-54.8 3.11-163.72 14.43-230.96 55.59-4.64 2.84-7.27 7.89-7.27 13.17v363.87c0 11.55 12.63 18.85 23.28 13.49 69.18-34.82 169.23-44.32 218.7-46.92 16.89-.89 30.02-14.43 30.02-30.66V62.75c.01-17.71-15.35-31.74-33.77-30.7zM264.73 87.64C197.5 46.48 88.58 35.17 33.78 32.05 15.36 31.01 0 45.04 0 62.75V400.6c0 16.24 13.13 29.78 30.02 30.66 49.49 2.6 149.59 12.11 218.77 46.95 10.62 5.35 23.21-1.94 23.21-13.46V100.63c0-5.29-2.62-10.14-7.27-12.99z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LibraryButton: React.FC<{
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
}> = ({ appState, setAppState }) => {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
|
||||
`ToolIcon_size_medium`,
|
||||
{
|
||||
"zen-mode-visibility--hidden": appState.zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
title={`${capitalizeString(t("toolBar.library"))} — 0`}
|
||||
style={{ marginInlineStart: "var(--space-factor)" }}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
name="editor-library"
|
||||
onChange={(event) => {
|
||||
setAppState({ isLibraryOpen: event.target.checked });
|
||||
}}
|
||||
checked={appState.isLibraryOpen}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
aria-keyshortcuts="0"
|
||||
/>
|
||||
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
|
||||
</label>
|
||||
);
|
||||
};
|
@ -1,12 +1,12 @@
|
||||
import clsx from "clsx";
|
||||
import oc from "open-color";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { close } from "../components/icons";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { BinaryFiles, LibraryItem } from "../types";
|
||||
import { LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
|
||||
// fa-plus
|
||||
@ -21,44 +21,39 @@ const PLUS_ICON = (
|
||||
|
||||
export const LibraryUnit = ({
|
||||
elements,
|
||||
files,
|
||||
pendingElements,
|
||||
onRemoveFromLibrary,
|
||||
onClick,
|
||||
}: {
|
||||
elements?: LibraryItem;
|
||||
files: BinaryFiles;
|
||||
pendingElements?: LibraryItem;
|
||||
onRemoveFromLibrary: () => void;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) {
|
||||
const elementsToRender = elements || pendingElements;
|
||||
if (!elementsToRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const elementsToRender = elements || pendingElements;
|
||||
if (!elementsToRender) {
|
||||
return;
|
||||
const svg = exportToSvg(elementsToRender, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
shouldAddWatermark: false,
|
||||
});
|
||||
for (const child of ref.current!.children) {
|
||||
if (child.tagName !== "svg") {
|
||||
continue;
|
||||
}
|
||||
const svg = await exportToSvg(
|
||||
elementsToRender,
|
||||
{
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
files,
|
||||
);
|
||||
node.innerHTML = svg.outerHTML;
|
||||
})();
|
||||
ref.current!.removeChild(child);
|
||||
}
|
||||
ref.current!.appendChild(svg);
|
||||
|
||||
const current = ref.current!;
|
||||
return () => {
|
||||
node.innerHTML = "";
|
||||
current.removeChild(svg);
|
||||
};
|
||||
}, [elements, pendingElements, files]);
|
||||
}, [elements, pendingElements]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export const LoadingMessage = () => {
|
||||
|
@ -2,17 +2,20 @@ import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "./ToolButton";
|
||||
|
||||
type LockIconSize = "s" | "m";
|
||||
|
||||
type LockIconProps = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
size?: LockIconSize;
|
||||
zenModeEnabled?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
||||
const DEFAULT_SIZE: LockIconSize = "m";
|
||||
|
||||
const ICONS = {
|
||||
CHECKED: (
|
||||
@ -38,12 +41,12 @@ const ICONS = {
|
||||
),
|
||||
};
|
||||
|
||||
export const LockButton = (props: LockIconProps) => {
|
||||
export const LockIcon = (props: LockIconProps) => {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
|
||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||
`ToolIcon_size_${props.size || DEFAULT_SIZE}`,
|
||||
{
|
||||
"zen-mode-visibility--hidden": props.zenModeEnabled,
|
||||
},
|
||||
@ -54,6 +57,7 @@ export const LockButton = (props: LockIconProps) => {
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
name={props.name}
|
||||
id={props.id}
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
aria-label={props.title}
|
@ -13,16 +13,14 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { LockIcon } from "./LockIcon";
|
||||
import { UserList } from "./UserList";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
|
||||
type MobileMenuProps = {
|
||||
appState: AppState;
|
||||
actionManager: ActionManager;
|
||||
renderJSONExportDialog: () => React.ReactNode;
|
||||
renderImageExportDialog: () => React.ReactNode;
|
||||
exportButton: React.ReactNode;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
libraryMenu: JSX.Element | null;
|
||||
@ -30,14 +28,9 @@ type MobileMenuProps = {
|
||||
onLockToggle: () => void;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
isCollaborating: boolean;
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
showThemeBtn: boolean;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
@ -45,8 +38,7 @@ export const MobileMenu = ({
|
||||
elements,
|
||||
libraryMenu,
|
||||
actionManager,
|
||||
renderJSONExportDialog,
|
||||
renderImageExportDialog,
|
||||
exportButton,
|
||||
setAppState,
|
||||
onCollabButtonClick,
|
||||
onLockToggle,
|
||||
@ -55,8 +47,6 @@ export const MobileMenu = ({
|
||||
renderCustomFooter,
|
||||
viewModeEnabled,
|
||||
showThemeBtn,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
}: MobileMenuProps) => {
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
@ -72,21 +62,15 @@ export const MobileMenu = ({
|
||||
canvas={canvas}
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
});
|
||||
}}
|
||||
isLibraryOpen={appState.isLibraryOpen}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
<LockButton
|
||||
<LockIcon
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
<LibraryButton appState={appState} setAppState={setAppState} />
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
</Stack.Col>
|
||||
@ -123,17 +107,19 @@ export const MobileMenu = ({
|
||||
if (viewModeEnabled) {
|
||||
return (
|
||||
<>
|
||||
{renderJSONExportDialog()}
|
||||
{renderImageExportDialog()}
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{exportButton}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{renderJSONExportDialog()}
|
||||
{renderImageExportDialog()}
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{exportButton}
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
@ -169,7 +155,7 @@ export const MobileMenu = ({
|
||||
<div className="panelColumn">
|
||||
<Stack.Col gap={4}>
|
||||
{renderCanvasActions()}
|
||||
{renderCustomFooter?.(true, appState)}
|
||||
{renderCustomFooter?.(true)}
|
||||
{appState.collaborators.size > 0 && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
@ -181,9 +167,10 @@ export const MobileMenu = ({
|
||||
)
|
||||
.map(([clientId, client]) => (
|
||||
<React.Fragment key={clientId}>
|
||||
{actionManager.renderAction("goToCollaborator", {
|
||||
id: clientId,
|
||||
})}
|
||||
{actionManager.renderAction(
|
||||
"goToCollaborator",
|
||||
clientId,
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</UserList>
|
||||
|
@ -26,7 +26,8 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
background-color: transparentize($oc-black, 0.3);
|
||||
background-color: transparentize($oc-black, 0.7);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.Modal__content {
|
||||
@ -44,16 +45,13 @@
|
||||
|
||||
// for modals, reset blurry bg
|
||||
background: var(--island-bg-color);
|
||||
backdrop-filter: none;
|
||||
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
|
@ -4,9 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { KEYS } from "../keys";
|
||||
import { useExcalidrawContainer, useIsMobile } from "./App";
|
||||
import { AppState } from "../types";
|
||||
import { THEME } from "../constants";
|
||||
import { useIsMobile } from "../components/App";
|
||||
|
||||
export const Modal = (props: {
|
||||
className?: string;
|
||||
@ -14,10 +12,8 @@ export const Modal = (props: {
|
||||
maxWidth?: number;
|
||||
onCloseRequest(): void;
|
||||
labelledBy: string;
|
||||
theme?: AppState["theme"];
|
||||
}) => {
|
||||
const { theme = THEME.LIGHT } = props;
|
||||
const modalRoot = useBodyRoot(theme);
|
||||
const modalRoot = useBodyRoot();
|
||||
|
||||
if (!modalRoot) {
|
||||
return null;
|
||||
@ -26,7 +22,6 @@ export const Modal = (props: {
|
||||
const handleKeydown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
props.onCloseRequest();
|
||||
}
|
||||
};
|
||||
@ -43,7 +38,6 @@ export const Modal = (props: {
|
||||
<div
|
||||
className="Modal__content"
|
||||
style={{ "--max-width": `${props.maxWidth}px` }}
|
||||
tabIndex={0}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
@ -52,15 +46,13 @@ export const Modal = (props: {
|
||||
);
|
||||
};
|
||||
|
||||
const useBodyRoot = (theme: AppState["theme"]) => {
|
||||
const useBodyRoot = () => {
|
||||
const [div, setDiv] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isMobileRef = useRef(isMobile);
|
||||
isMobileRef.current = isMobile;
|
||||
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (div) {
|
||||
div.classList.toggle("excalidraw--mobile", isMobile);
|
||||
@ -68,9 +60,9 @@ const useBodyRoot = (theme: AppState["theme"]) => {
|
||||
}, [div, isMobile]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const isDarkTheme =
|
||||
!!excalidrawContainer?.classList.contains("theme--dark") ||
|
||||
theme === "dark";
|
||||
const isDarkTheme = !!document
|
||||
.querySelector(".excalidraw")
|
||||
?.classList.contains("theme--dark");
|
||||
const div = document.createElement("div");
|
||||
|
||||
div.classList.add("excalidraw", "excalidraw-modal-container");
|
||||
@ -87,7 +79,7 @@ const useBodyRoot = (theme: AppState["theme"]) => {
|
||||
return () => {
|
||||
document.body.removeChild(div);
|
||||
};
|
||||
}, [excalidrawContainer, theme]);
|
||||
}, []);
|
||||
|
||||
return div;
|
||||
};
|
||||
|
@ -34,25 +34,20 @@ const ChartPreviewBtn = (props: {
|
||||
0,
|
||||
);
|
||||
setChartElements(elements);
|
||||
let svg: SVGSVGElement;
|
||||
|
||||
const svg = exportToSvg(elements, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
shouldAddWatermark: false,
|
||||
});
|
||||
|
||||
const previewNode = previewRef.current!;
|
||||
|
||||
(async () => {
|
||||
svg = await exportToSvg(
|
||||
elements,
|
||||
{
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
null, // files
|
||||
);
|
||||
previewNode.appendChild(svg);
|
||||
|
||||
previewNode.appendChild(svg);
|
||||
|
||||
if (props.selected) {
|
||||
(previewNode.parentNode as HTMLDivElement).focus();
|
||||
}
|
||||
})();
|
||||
if (props.selected) {
|
||||
(previewNode.parentNode as HTMLDivElement).focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
previewNode.removeChild(svg);
|
||||
|
@ -1,25 +0,0 @@
|
||||
.ProjectName {
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.TextInput {
|
||||
height: calc(1rem - 3px);
|
||||
width: 200px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
margin-left: 8px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&--readonly {
|
||||
background: none;
|
||||
border: none;
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
width: auto;
|
||||
max-width: 200px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +1,6 @@
|
||||
import "./TextInput.scss";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { focusNearestParent } from "../utils";
|
||||
|
||||
import "./ProjectName.scss";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
import React, { Component } from "react";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
@ -13,19 +9,21 @@ type Props = {
|
||||
isNameEditable: boolean;
|
||||
};
|
||||
|
||||
export const ProjectName = (props: Props) => {
|
||||
const { id } = useExcalidrawContainer();
|
||||
const [fileName, setFileName] = useState<string>(props.value);
|
||||
|
||||
const handleBlur = (event: any) => {
|
||||
focusNearestParent(event.target);
|
||||
type State = {
|
||||
fileName: string;
|
||||
};
|
||||
export class ProjectName extends Component<Props, State> {
|
||||
state = {
|
||||
fileName: this.props.value,
|
||||
};
|
||||
private handleBlur = (event: any) => {
|
||||
const value = event.target.value;
|
||||
if (value !== props.value) {
|
||||
props.onChange(value);
|
||||
if (value !== this.props.value) {
|
||||
this.props.onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (event.nativeEvent.isComposing || event.keyCode === 229) {
|
||||
@ -35,25 +33,29 @@ export const ProjectName = (props: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ProjectName">
|
||||
<label className="ProjectName-label" htmlFor="filename">
|
||||
{`${props.label}${props.isNameEditable ? "" : ":"}`}
|
||||
</label>
|
||||
{props.isNameEditable ? (
|
||||
<input
|
||||
className="TextInput"
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
id={`${id}-filename`}
|
||||
value={fileName}
|
||||
onChange={(event) => setFileName(event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<span className="TextInput TextInput--readonly" id={`${id}-filename`}>
|
||||
{props.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
public render() {
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="file-name">
|
||||
{`${this.props.label}${this.props.isNameEditable ? "" : ":"}`}
|
||||
</label>
|
||||
{this.props.isNameEditable ? (
|
||||
<input
|
||||
className="TextInput"
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
id="file-name"
|
||||
value={this.state.fileName}
|
||||
onChange={(event) =>
|
||||
this.setState({ fileName: event.target.value })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="TextInput TextInput--readonly" id="file-name">
|
||||
{this.props.value}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
interface SectionProps extends React.HTMLProps<HTMLElement> {
|
||||
heading: string;
|
||||
@ -8,14 +7,13 @@ interface SectionProps extends React.HTMLProps<HTMLElement> {
|
||||
}
|
||||
|
||||
export const Section = ({ heading, children, ...props }: SectionProps) => {
|
||||
const { id } = useExcalidrawContainer();
|
||||
const header = (
|
||||
<h2 className="visually-hidden" id={`${id}-${heading}-title`}>
|
||||
<h2 className="visually-hidden" id={`${heading}-title`}>
|
||||
{t(`headings.${heading}`)}
|
||||
</h2>
|
||||
);
|
||||
return (
|
||||
<section {...props} aria-labelledby={`${id}-${heading}-title`}>
|
||||
<section {...props} aria-labelledby={`${heading}-title`}>
|
||||
{typeof children === "function" ? (
|
||||
children(header)
|
||||
) : (
|
||||
|
@ -1,48 +0,0 @@
|
||||
@import "open-color/open-color.scss";
|
||||
|
||||
$duration: 1.6s;
|
||||
|
||||
.excalidraw {
|
||||
.Spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
--spinner-color: var(--icon-fill-color);
|
||||
|
||||
svg {
|
||||
animation: rotate $duration linear infinite;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
circle {
|
||||
stroke: var(--spinner-color);
|
||||
animation: dash $duration linear 0s infinite;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dash {
|
||||
0% {
|
||||
stroke-dasharray: 1, 300;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 150, 300;
|
||||
stroke-dashoffset: -200;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 1, 300;
|
||||
stroke-dashoffset: -280;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import "./Spinner.scss";
|
||||
|
||||
const Spinner = ({
|
||||
size = "1em",
|
||||
circleWidth = 8,
|
||||
}: {
|
||||
size?: string | number;
|
||||
circleWidth?: number;
|
||||
}) => {
|
||||
return (
|
||||
<div className="Spinner">
|
||||
<svg viewBox="0 0 100 100" style={{ width: size, height: size }}>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={50 - circleWidth / 2}
|
||||
strokeWidth={circleWidth}
|
||||
fill="none"
|
||||
strokeMiterlimit="10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Spinner;
|
@ -6,7 +6,7 @@
|
||||
top: 64px;
|
||||
right: 12px;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
z-index: 999;
|
||||
|
||||
h3 {
|
||||
margin: 0 24px 8px 0;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import { TOAST_TIMEOUT } from "../constants";
|
||||
import "./Toast.scss";
|
||||
|
||||
|
@ -1,13 +1,9 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
import { AbortError } from "../errors";
|
||||
import Spinner from "./Spinner";
|
||||
import { PointerType } from "../element/types";
|
||||
|
||||
export type ToolButtonSize = "small" | "medium";
|
||||
type ToolIconSize = "s" | "m";
|
||||
|
||||
type ToolButtonBaseProps = {
|
||||
icon?: React.ReactNode;
|
||||
@ -18,7 +14,7 @@ type ToolButtonBaseProps = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
size?: ToolButtonSize;
|
||||
size?: ToolIconSize;
|
||||
keyBindingLabel?: string;
|
||||
showAriaLabel?: boolean;
|
||||
hidden?: boolean;
|
||||
@ -31,58 +27,23 @@ type ToolButtonProps =
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "button";
|
||||
children?: React.ReactNode;
|
||||
onClick?(event: React.MouseEvent): void;
|
||||
})
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "icon";
|
||||
children?: React.ReactNode;
|
||||
onClick?(): void;
|
||||
})
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "radio";
|
||||
|
||||
checked: boolean;
|
||||
onChange?(data: { pointerType: PointerType | null }): void;
|
||||
onChange?(): void;
|
||||
});
|
||||
|
||||
const DEFAULT_SIZE: ToolIconSize = "m";
|
||||
|
||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
const { id: excalId } = useExcalidrawContainer();
|
||||
const innerRef = React.useRef(null);
|
||||
React.useImperativeHandle(ref, () => innerRef.current);
|
||||
const sizeCn = `ToolIcon_size_${props.size}`;
|
||||
const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const onClick = async (event: React.MouseEvent) => {
|
||||
const ret = "onClick" in props && props.onClick?.(event);
|
||||
|
||||
if (ret && "then" in ret) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await ret;
|
||||
} catch (error) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
isMountedRef.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const lastPointerTypeRef = useRef<PointerType | null>(null);
|
||||
|
||||
if (props.type === "button" || props.type === "icon") {
|
||||
if (props.type === "button") {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
@ -95,7 +56,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
{
|
||||
ToolIcon: !props.hidden,
|
||||
"ToolIcon--selected": props.selected,
|
||||
"ToolIcon--plain": props.type === "icon",
|
||||
},
|
||||
)}
|
||||
data-testid={props["data-testid"]}
|
||||
@ -103,24 +63,19 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
title={props.title}
|
||||
aria-label={props["aria-label"]}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onClick={props.onClick}
|
||||
ref={innerRef}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{(props.icon || props.label) && (
|
||||
<div className="ToolIcon__icon" aria-hidden="true">
|
||||
{props.icon || props.label}
|
||||
{props.keyBindingLabel && (
|
||||
<span className="ToolIcon__keybinding">
|
||||
{props.keyBindingLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="ToolIcon__icon" aria-hidden="true">
|
||||
{props.icon || props.label}
|
||||
{props.keyBindingLabel && (
|
||||
<span className="ToolIcon__keybinding">
|
||||
{props.keyBindingLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{props.showAriaLabel && (
|
||||
<div className="ToolIcon__label">
|
||||
{props["aria-label"]} {isLoading && <Spinner />}
|
||||
</div>
|
||||
<div className="ToolIcon__label">{props["aria-label"]}</div>
|
||||
)}
|
||||
{props.children}
|
||||
</button>
|
||||
@ -128,18 +83,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<label
|
||||
className={clsx("ToolIcon", props.className)}
|
||||
title={props.title}
|
||||
onPointerDown={(event) => {
|
||||
lastPointerTypeRef.current = event.pointerType || null;
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
requestAnimationFrame(() => {
|
||||
lastPointerTypeRef.current = null;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className={clsx("ToolIcon", props.className)} title={props.title}>
|
||||
<input
|
||||
className={`ToolIcon_type_radio ${sizeCn}`}
|
||||
type="radio"
|
||||
@ -147,10 +91,8 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
aria-label={props["aria-label"]}
|
||||
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
||||
data-testid={props["data-testid"]}
|
||||
id={`${excalId}-${props.id}`}
|
||||
onChange={() => {
|
||||
props.onChange?.({ pointerType: lastPointerTypeRef.current });
|
||||
}}
|
||||
id={props.id}
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
ref={innerRef}
|
||||
/>
|
||||
@ -167,5 +109,4 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
ToolButton.defaultProps = {
|
||||
visible: true,
|
||||
className: "",
|
||||
size: "medium",
|
||||
};
|
||||
|
@ -8,26 +8,9 @@
|
||||
position: relative;
|
||||
font-family: Cascadia;
|
||||
cursor: pointer;
|
||||
background-color: var(--button-gray-1);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
border-radius: var(--space-factor);
|
||||
user-select: none;
|
||||
|
||||
background-color: var(--button-gray-1);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-gray-2);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon--plain {
|
||||
background-color: transparent;
|
||||
.ToolIcon__icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
@ -54,21 +37,15 @@
|
||||
}
|
||||
|
||||
.ToolIcon__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--icon-fill-color);
|
||||
font-family: var(--ui-font);
|
||||
margin: 0 0.8em;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.Spinner {
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon_size_small .ToolIcon__icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
.ToolIcon_size_s .ToolIcon__icon {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
@ -80,6 +57,14 @@
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-gray-1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-gray-2);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
}
|
||||
@ -92,14 +77,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-gray-2);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
|
||||
&--show {
|
||||
visibility: visible;
|
||||
}
|
||||
@ -117,9 +94,6 @@
|
||||
|
||||
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
|
||||
background-color: var(--button-gray-2);
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus + .ToolIcon__icon {
|
||||
@ -147,21 +121,12 @@
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
background-color: var(--button-gray-1);
|
||||
&:hover {
|
||||
background-color: var(--button-gray-2);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
|
||||
width: 2rem;
|
||||
height: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon.ToolIcon__lock {
|
||||
margin-inline-end: var(--space-factor);
|
||||
&.ToolIcon_type_floating {
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
@ -192,9 +157,10 @@
|
||||
// move the lock button out of the way on small viewports
|
||||
// it begins to collide with the GitHub icon before we switch to mobile mode
|
||||
@media (max-width: 760px) {
|
||||
.ToolIcon.ToolIcon_type_floating {
|
||||
.ToolIcon.ToolIcon__lock {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: -8px;
|
||||
|
||||
margin-left: 0;
|
||||
@ -219,13 +185,16 @@
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
.ToolIcon.ToolIcon__library {
|
||||
top: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon.ToolIcon__lock {
|
||||
margin-inline-end: 0;
|
||||
top: 60px;
|
||||
.TooltipIcon {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
margin-left: 5px;
|
||||
margin-top: 1px;
|
||||
|
||||
@include isMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,45 +1,58 @@
|
||||
@import "../css/variables.module";
|
||||
.excalidraw {
|
||||
.Tooltip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// container in body where the actual tooltip is appended to
|
||||
.excalidraw-tooltip {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
.Tooltip__label {
|
||||
--arrow-size: 4px;
|
||||
visibility: hidden;
|
||||
background: $oc-black;
|
||||
color: $oc-white;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
// extra pixel offset for unknown reasons
|
||||
left: calc(50% + var(--arrow-size) / 2 - 1px);
|
||||
transform: translateX(-50%);
|
||||
word-wrap: break-word;
|
||||
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
word-wrap: break-word;
|
||||
&::after {
|
||||
content: "";
|
||||
border: var(--arrow-size) solid transparent;
|
||||
position: absolute;
|
||||
left: calc(50% - var(--arrow-size));
|
||||
}
|
||||
|
||||
background: $oc-black;
|
||||
&--above {
|
||||
bottom: calc(100% + var(--arrow-size) + 3px);
|
||||
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $oc-white;
|
||||
&::after {
|
||||
border-top-color: $oc-black;
|
||||
top: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
display: none;
|
||||
&--below {
|
||||
top: calc(100% + var(--arrow-size) + 3px);
|
||||
|
||||
&.excalidraw-tooltip--visible {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// wraps the element we want to apply the tooltip to
|
||||
.excalidraw-tooltip-wrapper {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.excalidraw-tooltip-icon {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
margin-left: 5px;
|
||||
margin-top: 1px;
|
||||
display: flex;
|
||||
|
||||
@include isMobile {
|
||||
display: none;
|
||||
&::after {
|
||||
border-bottom-color: $oc-black;
|
||||
bottom: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Tooltip:hover .Tooltip__label {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.Tooltip__label:hover {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
@ -1,93 +1,31 @@
|
||||
import "./Tooltip.scss";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
const getTooltipDiv = () => {
|
||||
const existingDiv = document.querySelector<HTMLDivElement>(
|
||||
".excalidraw-tooltip",
|
||||
);
|
||||
if (existingDiv) {
|
||||
return existingDiv;
|
||||
}
|
||||
const div = document.createElement("div");
|
||||
document.body.appendChild(div);
|
||||
div.classList.add("excalidraw-tooltip");
|
||||
return div;
|
||||
};
|
||||
|
||||
const updateTooltip = (
|
||||
item: HTMLDivElement,
|
||||
tooltip: HTMLDivElement,
|
||||
label: string,
|
||||
long: boolean,
|
||||
) => {
|
||||
tooltip.classList.add("excalidraw-tooltip--visible");
|
||||
tooltip.style.minWidth = long ? "50ch" : "10ch";
|
||||
tooltip.style.maxWidth = long ? "50ch" : "15ch";
|
||||
|
||||
tooltip.textContent = label;
|
||||
|
||||
const {
|
||||
x: itemX,
|
||||
bottom: itemBottom,
|
||||
top: itemTop,
|
||||
width: itemWidth,
|
||||
} = item.getBoundingClientRect();
|
||||
|
||||
const {
|
||||
width: labelWidth,
|
||||
height: labelHeight,
|
||||
} = tooltip.getBoundingClientRect();
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
const margin = 5;
|
||||
|
||||
const left = itemX + itemWidth / 2 - labelWidth / 2;
|
||||
const offsetLeft =
|
||||
left + labelWidth >= viewportWidth ? left + labelWidth - viewportWidth : 0;
|
||||
|
||||
const top = itemBottom + margin;
|
||||
const offsetTop =
|
||||
top + labelHeight >= viewportHeight
|
||||
? itemBottom - itemTop + labelHeight + margin * 2
|
||||
: 0;
|
||||
|
||||
Object.assign(tooltip.style, {
|
||||
top: `${top - offsetTop}px`,
|
||||
left: `${left - offsetLeft}px`,
|
||||
});
|
||||
};
|
||||
import React from "react";
|
||||
|
||||
type TooltipProps = {
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
position?: "above" | "below";
|
||||
long?: boolean;
|
||||
};
|
||||
|
||||
export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
|
||||
useEffect(() => {
|
||||
return () =>
|
||||
getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="excalidraw-tooltip-wrapper"
|
||||
onPointerEnter={(event) =>
|
||||
updateTooltip(
|
||||
event.currentTarget as HTMLDivElement,
|
||||
getTooltipDiv(),
|
||||
label,
|
||||
long,
|
||||
)
|
||||
}
|
||||
onPointerLeave={() =>
|
||||
getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
|
||||
export const Tooltip = ({
|
||||
children,
|
||||
label,
|
||||
position = "below",
|
||||
long = false,
|
||||
}: TooltipProps) => (
|
||||
<div className="Tooltip">
|
||||
<span
|
||||
className={
|
||||
position === "above"
|
||||
? "Tooltip__label Tooltip__label--above"
|
||||
: "Tooltip__label Tooltip__label--below"
|
||||
}
|
||||
style={{ width: long ? "50ch" : "10ch" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
@ -2,8 +2,7 @@
|
||||
.UserList {
|
||||
pointer-events: none;
|
||||
/*github corner*/
|
||||
padding: var(--space-factor) var(--space-factor) var(--space-factor)
|
||||
var(--space-factor);
|
||||
padding: var(--space-factor) 40px var(--space-factor) var(--space-factor);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
import { FontFamily } from "./element/types";
|
||||
import cssVariables from "./css/variables.module.scss";
|
||||
import { AppProps } from "./types";
|
||||
import { FontFamilyValues } from "./element/types";
|
||||
|
||||
export const APP_NAME = "Excalidraw";
|
||||
|
||||
@ -14,7 +14,6 @@ export const CURSOR_TYPE = {
|
||||
TEXT: "text",
|
||||
CROSSHAIR: "crosshair",
|
||||
GRABBING: "grabbing",
|
||||
GRAB: "grab",
|
||||
POINTER: "pointer",
|
||||
MOVE: "move",
|
||||
AUTO: "",
|
||||
@ -35,7 +34,6 @@ export enum EVENT {
|
||||
MOUSE_MOVE = "mousemove",
|
||||
RESIZE = "resize",
|
||||
UNLOAD = "unload",
|
||||
FOCUS = "focus",
|
||||
BLUR = "blur",
|
||||
DRAG_OVER = "dragover",
|
||||
DROP = "drop",
|
||||
@ -65,20 +63,15 @@ export const CLASSES = {
|
||||
|
||||
// 1-based in case we ever do `if(element.fontFamily)`
|
||||
export const FONT_FAMILY = {
|
||||
Virgil: 1,
|
||||
Helvetica: 2,
|
||||
Cascadia: 3,
|
||||
};
|
||||
|
||||
export const THEME = {
|
||||
LIGHT: "light",
|
||||
DARK: "dark",
|
||||
};
|
||||
1: "Virgil",
|
||||
2: "Helvetica",
|
||||
3: "Cascadia",
|
||||
} as const;
|
||||
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||
export const DEFAULT_VERSION = "{version}";
|
||||
@ -90,13 +83,7 @@ export const GRID_SIZE = 20; // TODO make it configurable?
|
||||
export const MIME_TYPES = {
|
||||
excalidraw: "application/vnd.excalidraw+json",
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
json: "application/json",
|
||||
svg: "image/svg+xml",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
binary: "application/octet-stream",
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const EXPORT_DATA_TYPES = {
|
||||
excalidraw: "excalidraw",
|
||||
@ -104,20 +91,18 @@ export const EXPORT_DATA_TYPES = {
|
||||
excalidrawLibrary: "excalidrawlib",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_SOURCE = window.location.origin;
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||
} as const;
|
||||
|
||||
// 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 DETECT_POSITION_CHANGE_INTERVAL = 500;
|
||||
export const ZOOM_STEP = 0.1;
|
||||
|
||||
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
||||
@ -145,32 +130,14 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
canvasActions: {
|
||||
changeViewBackgroundColor: true,
|
||||
clearCanvas: true,
|
||||
export: { saveFileToDisk: true },
|
||||
export: true,
|
||||
loadScene: true,
|
||||
saveToActiveFile: true,
|
||||
saveAsScene: true,
|
||||
saveScene: true,
|
||||
theme: true,
|
||||
saveAsImage: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||
|
||||
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||
|
||||
export const EXPORT_SCALES = [1, 2, 3];
|
||||
export const DEFAULT_EXPORT_PADDING = 10; // px
|
||||
|
||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||
|
||||
export const ALLOWED_IMAGE_MIME_TYPES = [
|
||||
MIME_TYPES.png,
|
||||
MIME_TYPES.jpg,
|
||||
MIME_TYPES.svg,
|
||||
MIME_TYPES.gif,
|
||||
] as const;
|
||||
|
||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
@ -5,7 +5,6 @@
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap; /* added line */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.LoadingMessage {
|
||||
|
@ -19,10 +19,6 @@
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// serves 2 purposes:
|
||||
// 1. prevent selecting text outside the component when double-clicking or
|
||||
// dragging inside it (e.g. on canvas)
|
||||
@ -51,12 +47,11 @@
|
||||
image-rendering: -moz-crisp-edges; // FF
|
||||
|
||||
z-index: var(--zIndex-canvas);
|
||||
|
||||
// Remove the main canvas from document flow to avoid resizeObserver
|
||||
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
|
||||
}
|
||||
|
||||
&__canvas {
|
||||
#canvas {
|
||||
// Remove the main canvas from document flow to avoid resizeObserver
|
||||
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@ -333,8 +328,8 @@
|
||||
.App-menu_bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
grid-template-columns: min-content auto min-content;
|
||||
grid-gap: 15px;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-gap: 4px;
|
||||
align-items: flex-start;
|
||||
cursor: default;
|
||||
pointer-events: none !important;
|
||||
@ -359,6 +354,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_bottom > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.App-menu_bottom > *:first-child {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
@ -414,6 +413,24 @@
|
||||
&:active {
|
||||
background-color: var(--button-gray-2);
|
||||
}
|
||||
|
||||
&.dropdown-select--floating {
|
||||
position: absolute;
|
||||
margin: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-select__language.dropdown-select--floating {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 44px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.zIndexButton {
|
||||
@ -440,38 +457,27 @@
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
fill: $oc-gray-6;
|
||||
bottom: 14px;
|
||||
width: 1.5rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: none;
|
||||
color: var(--icon-fill-color);
|
||||
|
||||
svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.reset-zoom-button {
|
||||
padding: 0.2em;
|
||||
background: transparent;
|
||||
color: var(--text-primary-color);
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
:root[dir="ltr"] & {
|
||||
right: 14px;
|
||||
}
|
||||
|
||||
.undo-redo-buttons {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: 0.4em;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-inline-start: 0.6em;
|
||||
:root[dir="rtl"] & {
|
||||
left: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
@ -490,6 +496,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
.github-corner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.zen-mode-visibility {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
|
@ -14,12 +14,11 @@
|
||||
--focus-highlight-color: #{$oc-blue-2};
|
||||
--icon-fill-color: #{$oc-black};
|
||||
--icon-green-fill-color: #{$oc-green-9};
|
||||
--default-bg-color: #{$oc-white};
|
||||
--input-bg-color: #{$oc-white};
|
||||
--input-border-color: #{$oc-gray-3};
|
||||
--input-hover-bg-color: #{$oc-gray-1};
|
||||
--input-label-color: #{$oc-gray-7};
|
||||
--island-bg-color: rgba(255, 255, 255, 0.96);
|
||||
--island-bg-color: rgba(255, 255, 255, 0.9);
|
||||
--keybinding-color: #{$oc-gray-5};
|
||||
--link-color: #{$oc-blue-7};
|
||||
--overlay-bg-color: #{transparentize($oc-white, 0.12)};
|
||||
@ -57,12 +56,11 @@
|
||||
--focus-highlight-color: #{$oc-blue-6};
|
||||
--icon-fill-color: #{$oc-gray-4};
|
||||
--icon-green-fill-color: #{$oc-green-4};
|
||||
--default-bg-color: #121212;
|
||||
--input-bg-color: #121212;
|
||||
--input-border-color: #2e2e2e;
|
||||
--input-hover-bg-color: #181818;
|
||||
--input-label-color: #{$oc-gray-2};
|
||||
--island-bg-color: rgba(30, 30, 30, 0.98);
|
||||
--island-bg-color: #1e1e1e;
|
||||
--keybinding-color: #{$oc-gray-6};
|
||||
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
|
||||
--popup-bg-color: #2c2c2c;
|
||||
|
170
src/data/blob.ts
170
src/data/blob.ts
@ -1,40 +1,27 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
EXPORT_DATA_TYPES,
|
||||
MIME_TYPES,
|
||||
} from "../constants";
|
||||
import { EXPORT_DATA_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 } from "../types";
|
||||
import { FileSystemHandle } from "./filesystem";
|
||||
import { AppState } from "../types";
|
||||
import { isValidExcalidrawData } from "./json";
|
||||
import { restore } from "./restore";
|
||||
import { ImportedLibraryData } from "./types";
|
||||
import { LibraryData } from "./types";
|
||||
|
||||
const parseFileContents = async (blob: Blob | File) => {
|
||||
let contents: string;
|
||||
|
||||
if (blob.type === MIME_TYPES.png) {
|
||||
if (blob.type === "image/png") {
|
||||
try {
|
||||
return await (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
).decodePngMetadata(blob);
|
||||
} catch (error) {
|
||||
if (error.message === "INVALID") {
|
||||
throw new DOMException(
|
||||
t("alerts.imageDoesNotContainScene"),
|
||||
"EncodingError",
|
||||
);
|
||||
throw new Error(t("alerts.imageDoesNotContainScene"));
|
||||
} else {
|
||||
throw new DOMException(
|
||||
t("alerts.cannotRestoreFromImage"),
|
||||
"EncodingError",
|
||||
);
|
||||
throw new Error(t("alerts.cannotRestoreFromImage"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -51,7 +38,7 @@ const parseFileContents = async (blob: Blob | File) => {
|
||||
};
|
||||
});
|
||||
}
|
||||
if (blob.type === MIME_TYPES.svg) {
|
||||
if (blob.type === "image/svg+xml") {
|
||||
try {
|
||||
return await (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
@ -60,15 +47,9 @@ const parseFileContents = async (blob: Blob | File) => {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.message === "INVALID") {
|
||||
throw new DOMException(
|
||||
t("alerts.imageDoesNotContainScene"),
|
||||
"EncodingError",
|
||||
);
|
||||
throw new Error(t("alerts.imageDoesNotContainScene"));
|
||||
} else {
|
||||
throw new DOMException(
|
||||
t("alerts.cannotRestoreFromImage"),
|
||||
"EncodingError",
|
||||
);
|
||||
throw new Error(t("alerts.cannotRestoreFromImage"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -87,50 +68,21 @@ export const getMimeType = (blob: Blob | string): string => {
|
||||
name = blob.name || "";
|
||||
}
|
||||
if (/\.(excalidraw|json)$/.test(name)) {
|
||||
return MIME_TYPES.json;
|
||||
return "application/json";
|
||||
} else if (/\.png$/.test(name)) {
|
||||
return MIME_TYPES.png;
|
||||
return "image/png";
|
||||
} else if (/\.jpe?g$/.test(name)) {
|
||||
return MIME_TYPES.jpg;
|
||||
return "image/jpeg";
|
||||
} else if (/\.svg$/.test(name)) {
|
||||
return MIME_TYPES.svg;
|
||||
return "image/svg+xml";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export const getFileHandleType = (handle: FileSystemHandle | null) => {
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return handle.name.match(/\.(json|excalidraw|png|svg)$/)?.[1] || null;
|
||||
};
|
||||
|
||||
export const isImageFileHandleType = (
|
||||
type: string | null,
|
||||
): type is "png" | "svg" => {
|
||||
return type === "png" || type === "svg";
|
||||
};
|
||||
|
||||
export const isImageFileHandle = (handle: FileSystemHandle | null) => {
|
||||
const type = getFileHandleType(handle);
|
||||
return type === "png" || type === "svg";
|
||||
};
|
||||
|
||||
export const isSupportedImageFile = (
|
||||
blob: Blob | null | undefined,
|
||||
): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => {
|
||||
const { type } = blob || {};
|
||||
return (
|
||||
!!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
|
||||
);
|
||||
};
|
||||
|
||||
export const loadFromBlob = async (
|
||||
blob: Blob,
|
||||
/** @see restore.localAppState */
|
||||
localAppState: AppState | null,
|
||||
localElements: readonly ExcalidrawElement[] | null,
|
||||
) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
try {
|
||||
@ -143,16 +95,14 @@ export const loadFromBlob = async (
|
||||
elements: clearElementsForExport(data.elements || []),
|
||||
appState: {
|
||||
theme: localAppState?.theme,
|
||||
fileHandle: blob.handle || null,
|
||||
fileHandle: (!blob.type.startsWith("image/") && blob.handle) || null,
|
||||
...cleanAppStateForExport(data.appState || {}),
|
||||
...(localAppState
|
||||
? calculateScrollCenter(data.elements || [], localAppState, null)
|
||||
: {}),
|
||||
},
|
||||
files: data.files,
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
);
|
||||
|
||||
return result;
|
||||
@ -164,7 +114,7 @@ export const loadFromBlob = async (
|
||||
|
||||
export const loadLibraryFromBlob = async (blob: Blob) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
const data: ImportedLibraryData = JSON.parse(contents);
|
||||
const data: LibraryData = JSON.parse(contents);
|
||||
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
}
|
||||
@ -192,93 +142,3 @@ export const canvasToBlob = async (
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** generates SHA-1 digest from supplied file (if not supported, falls back
|
||||
to a 40-char base64 random id) */
|
||||
export const generateIdFromFile = async (file: File) => {
|
||||
let id: FileId;
|
||||
try {
|
||||
const hashBuffer = await window.crypto.subtle.digest(
|
||||
"SHA-1",
|
||||
await file.arrayBuffer(),
|
||||
);
|
||||
id =
|
||||
// convert buffer to byte array
|
||||
Array.from(new Uint8Array(hashBuffer))
|
||||
// convert to hex string
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("") as FileId;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
// length 40 to align with the HEX length of SHA-1 (which is 160 bit)
|
||||
id = nanoid(40) as FileId;
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataURL = reader.result as DataURL;
|
||||
resolve(dataURL);
|
||||
};
|
||||
reader.onerror = (error) => reject(error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
export const dataURLToFile = (dataURL: DataURL, filename = "") => {
|
||||
const dataIndexStart = dataURL.indexOf(",");
|
||||
const byteString = atob(dataURL.slice(dataIndexStart + 1));
|
||||
const mimeType = dataURL.slice(0, dataIndexStart).split(":")[1].split(";")[0];
|
||||
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
return new File([ab], filename, { type: mimeType });
|
||||
};
|
||||
|
||||
export const resizeImageFile = async (
|
||||
file: File,
|
||||
maxWidthOrHeight: number,
|
||||
): Promise<File> => {
|
||||
// SVG files shouldn't a can't be resized
|
||||
if (file.type === MIME_TYPES.svg) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const [pica, imageBlobReduce] = await Promise.all([
|
||||
import("pica").then((res) => res.default),
|
||||
// a wrapper for pica for better API
|
||||
import("image-blob-reduce").then((res) => res.default),
|
||||
]);
|
||||
|
||||
// CRA's minification settings break pica in WebWorkers, so let's disable
|
||||
// them for now
|
||||
// https://github.com/nodeca/image-blob-reduce/issues/21#issuecomment-757365513
|
||||
const reduce = imageBlobReduce({
|
||||
pica: pica({ features: ["js", "wasm"] }),
|
||||
});
|
||||
|
||||
const fileType = file.type;
|
||||
|
||||
if (!isSupportedImageFile(file)) {
|
||||
throw new Error(t("errors.unsupportedFileType"));
|
||||
}
|
||||
|
||||
return new File(
|
||||
[await reduce.toBlob(file, { max: maxWidthOrHeight })],
|
||||
file.name,
|
||||
{ type: fileType },
|
||||
);
|
||||
};
|
||||
|
||||
export const SVGStringToFile = (SVGString: string, filename: string = "") => {
|
||||
return new File([new TextEncoder().encode(SVGString)], filename, {
|
||||
type: MIME_TYPES.svg,
|
||||
}) as File & { type: typeof MIME_TYPES.svg };
|
||||
};
|
||||
|
@ -1,19 +1,16 @@
|
||||
import { deflate, inflate } from "pako";
|
||||
import { encryptData, decryptData } from "./encryption";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// byte (binary) strings
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// fast, Buffer-compatible implem
|
||||
export const toByteString = (
|
||||
data: string | Uint8Array | ArrayBuffer,
|
||||
): Promise<string> => {
|
||||
export const toByteString = (data: string | Uint8Array): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blob =
|
||||
typeof data === "string"
|
||||
? new Blob([new TextEncoder().encode(data)])
|
||||
: new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
|
||||
: new Blob([data]);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (!event.target || typeof event.target.result !== "string") {
|
||||
@ -47,14 +44,12 @@ const byteStringToString = (byteString: string) => {
|
||||
* due to reencoding
|
||||
*/
|
||||
export const stringToBase64 = async (str: string, isByteString = false) => {
|
||||
return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
|
||||
return isByteString ? btoa(str) : btoa(await toByteString(str));
|
||||
};
|
||||
|
||||
// async to align with stringToBase64
|
||||
export const base64ToString = async (base64: string, isByteString = false) => {
|
||||
return isByteString
|
||||
? window.atob(base64)
|
||||
: byteStringToString(window.atob(base64));
|
||||
return isByteString ? atob(base64) : byteStringToString(atob(base64));
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@ -119,261 +114,3 @@ export const decode = async (data: EncodedData): Promise<string> => {
|
||||
|
||||
return decoded;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// binary encoding
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
type FileEncodingInfo = {
|
||||
/* version 2 is the version we're shipping the initial image support with.
|
||||
version 1 was a PR version that a lot of people were using anyway.
|
||||
Thus, if there are issues we can check whether they're not using the
|
||||
unoffic version */
|
||||
version: 1 | 2;
|
||||
compression: "pako@1" | null;
|
||||
encryption: "AES-GCM" | null;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
const CONCAT_BUFFERS_VERSION = 1;
|
||||
/** how many bytes we use to encode how many bytes the next chunk has.
|
||||
* Corresponds to DataView setter methods (setUint32, setUint16, etc).
|
||||
*
|
||||
* NOTE ! values must not be changed, which would be backwards incompatible !
|
||||
*/
|
||||
const VERSION_DATAVIEW_BYTES = 4;
|
||||
const NEXT_CHUNK_SIZE_DATAVIEW_BYTES = 4;
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const DATA_VIEW_BITS_MAP = { 1: 8, 2: 16, 4: 32 } as const;
|
||||
|
||||
// getter
|
||||
function dataView(buffer: Uint8Array, bytes: 1 | 2 | 4, offset: number): number;
|
||||
// setter
|
||||
function dataView(
|
||||
buffer: Uint8Array,
|
||||
bytes: 1 | 2 | 4,
|
||||
offset: number,
|
||||
value: number,
|
||||
): Uint8Array;
|
||||
/**
|
||||
* abstraction over DataView that serves as a typed getter/setter in case
|
||||
* you're using constants for the byte size and want to ensure there's no
|
||||
* discrepenancy in the encoding across refactors.
|
||||
*
|
||||
* DataView serves for an endian-agnostic handling of numbers in ArrayBuffers.
|
||||
*/
|
||||
function dataView(
|
||||
buffer: Uint8Array,
|
||||
bytes: 1 | 2 | 4,
|
||||
offset: number,
|
||||
value?: number,
|
||||
): Uint8Array | number {
|
||||
if (value != null) {
|
||||
if (value > Math.pow(2, DATA_VIEW_BITS_MAP[bytes]) - 1) {
|
||||
throw new Error(
|
||||
`attempting to set value higher than the allocated bytes (value: ${value}, bytes: ${bytes})`,
|
||||
);
|
||||
}
|
||||
const method = `setUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
|
||||
new DataView(buffer.buffer)[method](offset, value);
|
||||
return buffer;
|
||||
}
|
||||
const method = `getUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
|
||||
return new DataView(buffer.buffer)[method](offset);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resulting concatenated buffer has this format:
|
||||
*
|
||||
* [
|
||||
* VERSION chunk (4 bytes)
|
||||
* LENGTH chunk 1 (4 bytes)
|
||||
* DATA chunk 1 (up to 2^32 bits)
|
||||
* LENGTH chunk 2 (4 bytes)
|
||||
* DATA chunk 2 (up to 2^32 bits)
|
||||
* ...
|
||||
* ]
|
||||
*
|
||||
* @param buffers each buffer (chunk) must be at most 2^32 bits large (~4GB)
|
||||
*/
|
||||
const concatBuffers = (...buffers: Uint8Array[]) => {
|
||||
const bufferView = new Uint8Array(
|
||||
VERSION_DATAVIEW_BYTES +
|
||||
NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length +
|
||||
buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0),
|
||||
);
|
||||
|
||||
let cursor = 0;
|
||||
|
||||
// as the first chunk we'll encode the version for backwards compatibility
|
||||
dataView(bufferView, VERSION_DATAVIEW_BYTES, cursor, CONCAT_BUFFERS_VERSION);
|
||||
cursor += VERSION_DATAVIEW_BYTES;
|
||||
|
||||
for (const buffer of buffers) {
|
||||
dataView(
|
||||
bufferView,
|
||||
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
|
||||
cursor,
|
||||
buffer.byteLength,
|
||||
);
|
||||
cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
|
||||
|
||||
bufferView.set(buffer, cursor);
|
||||
cursor += buffer.byteLength;
|
||||
}
|
||||
|
||||
return bufferView;
|
||||
};
|
||||
|
||||
/** can only be used on buffers created via `concatBuffers()` */
|
||||
const splitBuffers = (concatenatedBuffer: Uint8Array) => {
|
||||
const buffers = [];
|
||||
|
||||
let cursor = 0;
|
||||
|
||||
// first chunk is the version (ignored for now)
|
||||
cursor += VERSION_DATAVIEW_BYTES;
|
||||
|
||||
while (true) {
|
||||
const chunkSize = dataView(
|
||||
concatenatedBuffer,
|
||||
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
|
||||
cursor,
|
||||
);
|
||||
cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
|
||||
|
||||
buffers.push(concatenatedBuffer.slice(cursor, cursor + chunkSize));
|
||||
cursor += chunkSize;
|
||||
if (cursor >= concatenatedBuffer.byteLength) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return buffers;
|
||||
};
|
||||
|
||||
// helpers for (de)compressing data with JSON metadata including encryption
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** @private */
|
||||
const _encryptAndCompress = async (
|
||||
data: Uint8Array | string,
|
||||
encryptionKey: string,
|
||||
) => {
|
||||
const { encryptedBuffer, iv } = await encryptData(
|
||||
encryptionKey,
|
||||
deflate(data),
|
||||
);
|
||||
|
||||
return { iv, buffer: new Uint8Array(encryptedBuffer) };
|
||||
};
|
||||
|
||||
/**
|
||||
* The returned buffer has following format:
|
||||
* `[]` refers to a buffers wrapper (see `concatBuffers`)
|
||||
*
|
||||
* [
|
||||
* encodingMetadataBuffer,
|
||||
* iv,
|
||||
* [
|
||||
* contentsMetadataBuffer
|
||||
* contentsBuffer
|
||||
* ]
|
||||
* ]
|
||||
*/
|
||||
export const compressData = async <T extends Record<string, any> = never>(
|
||||
dataBuffer: Uint8Array,
|
||||
options: {
|
||||
encryptionKey: string;
|
||||
} & ([T] extends [never]
|
||||
? {
|
||||
metadata?: T;
|
||||
}
|
||||
: {
|
||||
metadata: T;
|
||||
}),
|
||||
): Promise<Uint8Array> => {
|
||||
const fileInfo: FileEncodingInfo = {
|
||||
version: 2,
|
||||
compression: "pako@1",
|
||||
encryption: "AES-GCM",
|
||||
};
|
||||
|
||||
const encodingMetadataBuffer = new TextEncoder().encode(
|
||||
JSON.stringify(fileInfo),
|
||||
);
|
||||
|
||||
const contentsMetadataBuffer = new TextEncoder().encode(
|
||||
JSON.stringify(options.metadata || null),
|
||||
);
|
||||
|
||||
const { iv, buffer } = await _encryptAndCompress(
|
||||
concatBuffers(contentsMetadataBuffer, dataBuffer),
|
||||
options.encryptionKey,
|
||||
);
|
||||
|
||||
return concatBuffers(encodingMetadataBuffer, iv, buffer);
|
||||
};
|
||||
|
||||
/** @private */
|
||||
const _decryptAndDecompress = async (
|
||||
iv: Uint8Array,
|
||||
decryptedBuffer: Uint8Array,
|
||||
decryptionKey: string,
|
||||
isCompressed: boolean,
|
||||
) => {
|
||||
decryptedBuffer = new Uint8Array(
|
||||
await decryptData(iv, decryptedBuffer, decryptionKey),
|
||||
);
|
||||
|
||||
if (isCompressed) {
|
||||
return inflate(decryptedBuffer);
|
||||
}
|
||||
|
||||
return decryptedBuffer;
|
||||
};
|
||||
|
||||
export const decompressData = async <T extends Record<string, any>>(
|
||||
bufferView: Uint8Array,
|
||||
options: { decryptionKey: string },
|
||||
) => {
|
||||
// first chunk is encoding metadata (ignored for now)
|
||||
const [encodingMetadataBuffer, iv, buffer] = splitBuffers(bufferView);
|
||||
|
||||
const encodingMetadata: FileEncodingInfo = JSON.parse(
|
||||
new TextDecoder().decode(encodingMetadataBuffer),
|
||||
);
|
||||
|
||||
try {
|
||||
const [contentsMetadataBuffer, contentsBuffer] = splitBuffers(
|
||||
await _decryptAndDecompress(
|
||||
iv,
|
||||
buffer,
|
||||
options.decryptionKey,
|
||||
!!encodingMetadata.compression,
|
||||
),
|
||||
);
|
||||
|
||||
const metadata = JSON.parse(
|
||||
new TextDecoder().decode(contentsMetadataBuffer),
|
||||
) as T;
|
||||
|
||||
return {
|
||||
/** metadata source is always JSON so we can decode it here */
|
||||
metadata,
|
||||
/** data can be anything so the caller must decode it */
|
||||
data: contentsBuffer,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error during decompressing and decrypting the file.`,
|
||||
encodingMetadata,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user