Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8946b2637f | |||
| a834a4fda0 | |||
| 7dbd0c5e0a | |||
| ba35eb8f8c | |||
| 163ad1f4c4 | |||
| 0f0244224d | |||
| 6eecadce60 | |||
| bc88cf5002 | |||
| 571be9c0fe | |||
| 5d925c7d3f | |||
| 45c520341f | |||
| c6ffc06541 | |||
| ff29780760 | |||
| 463857ad9a | |||
| be2da9539e | |||
| bb7829ef90 | |||
| 1104f6891e | |||
| a97e172070 | |||
| 39d45afc06 | |||
| 00c6940851 | |||
| 982cba2035 | |||
| 54739cd2df | |||
| 75aeaa6c38 | |||
| bea4a1e066 | |||
| e8b462cc31 | |||
| c86c176e10 | |||
| b09c11bb14 | |||
| 7199d13f48 | |||
| 7d1fddc144 | |||
| 5da3207633 | |||
| 8c9786e026 | |||
| f0f13ed694 | |||
| 850d8eb47e | |||
| f287f9c002 | |||
| 78df5bc852 | |||
| f0073c7e26 | |||
| fa7a313412 | |||
| 8b3f236cd8 | |||
| 621812d0eb | |||
| d607249205 | |||
| df28c3299f | |||
| b00a57b4be | |||
| 9277e839db | |||
| 0d5d60944f | |||
| 489a652d73 | |||
| 2b85d96121 | |||
| 6ce535d3a4 | |||
| da43cf5635 | |||
| 603ecfba34 | |||
| a589708737 | |||
| 4df401d012 | |||
| b2c4552416 | |||
| 5cae218f1b | |||
| 4be726d405 | |||
| 99623334d1 | |||
| 685abac81a | |||
| 9581c45522 | |||
| 0749d2c1f3 | |||
| 8787f3dc60 | |||
| 5fabc57277 | |||
| e7cbb859f0 | |||
| aa860251c7 | |||
| 380aaa30e6 | |||
| 2e61fec7a6 | |||
| 3c295559c7 | |||
| 55d3287abf | |||
| e3e967421e | |||
| 77aae63006 | |||
| ee64a7e264 | |||
| 097362662d | |||
| 038e9c13dd | |||
| bc8ba08ad0 | |||
| f861a9fdd0 | |||
| 62303b8a22 | |||
| 9cc741ab3a | |||
| 2d279cbb02 | |||
| 57ea4fdf9a | |||
| 44402f42bf | |||
| bdead4d164 | |||
| bfc0656475 | |||
| a33a3334f7 | |||
| 969d3c694a | |||
| 5cd921549a | |||
| 437afcbea4 | |||
| 6dee02e320 | |||
| 74a2f16501 | |||
| fd4460be37 | |||
| e82d0493cf | |||
| 083cb4c656 | |||
| d067365c1d | |||
| 273cac6b60 | |||
| b9337b8a36 | |||
| 0e0025921b | |||
| efc01ddab1 | |||
| 7bce22b114 | |||
| aab4965bbb | |||
| 486a9a3da8 | |||
| 2425c06082 | |||
| 79ea844901 | |||
| 6690215cd1 | |||
| 7f5e783fe8 | |||
| 9325109836 | |||
| 69b6fbb3f4 | |||
| 6b6002baae | |||
| 54dcb73701 | |||
| b595d3fcba | |||
| d0867d1c3b | |||
| 0d19e9210c | |||
| 4249de41d4 | |||
| 15f02ba191 | |||
| a2e1199907 | |||
| c08e9c4172 | |||
| abfc58eb24 | |||
| 035c7affff | |||
| c819b653bf | |||
| 60cea7a0c2 | |||
| d63b6a3469 | |||
| 0912fe1c93 | |||
| 360310de31 | |||
| 716c78e930 | |||
| ba48974351 | |||
| 6c3e4417e1 | |||
| bc0b6e1888 | |||
| 99a22e8445 | |||
| e6d9797167 | |||
| a1e8fdfb1b | |||
| 1cce63b07b | |||
| e9c2a09c21 | |||
| 55e0812680 | |||
| 0f32278a7e | |||
| 1bdb8da1c3 | |||
| 9c9787e0a0 | |||
| c2fe24c562 | |||
| 52faa52091 | |||
| dd12abc583 | |||
| abebf9aff8 |
@@ -5,3 +5,4 @@ package-lock.json
|
||||
firebase/
|
||||
dist/
|
||||
public/workbox
|
||||
src/packages/excalidraw/types
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
.env.test.local
|
||||
.envrc
|
||||
.eslintcache
|
||||
.history
|
||||
.idea
|
||||
.vercel
|
||||
.vscode
|
||||
.yarn
|
||||
*.log
|
||||
*.tgz
|
||||
build
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ ARG NODE_ENV=production
|
||||
COPY . .
|
||||
RUN yarn build:app:docker
|
||||
|
||||
FROM nginx:1.17-alpine
|
||||
FROM nginx:1.21-alpine
|
||||
|
||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ 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).
|
||||
@@ -93,7 +95,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)
|
||||
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
#### Clone the repo
|
||||
|
||||
@@ -2,5 +2,8 @@
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"storage": {
|
||||
"rules": "storage.rules"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -19,23 +19,28 @@
|
||||
]
|
||||
},
|
||||
"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",
|
||||
"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": "0.4.7",
|
||||
"perfect-freehand": "1.0.15",
|
||||
"png-chunk-text": "1.0.0",
|
||||
"png-chunks-encode": "1.0.0",
|
||||
"png-chunks-extract": "1.0.0",
|
||||
@@ -76,7 +81,7 @@
|
||||
},
|
||||
"jest": {
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)"
|
||||
],
|
||||
"resetMocks": false
|
||||
},
|
||||
|
||||
Binary file not shown.
@@ -13,6 +13,18 @@
|
||||
|
||||
<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"
|
||||
@@ -117,6 +129,7 @@
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"capture_links": "new_client",
|
||||
"capture_links": "new-client",
|
||||
"share_target": {
|
||||
"action": "/web-share-target",
|
||||
"method": "POST",
|
||||
|
||||
@@ -31,9 +31,11 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
||||
|
||||
const excalidrawPackageFiles = changedFiles.filter((file) => {
|
||||
return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
|
||||
return (
|
||||
(file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
|
||||
!filesToIgnoreRegex.test(file)
|
||||
);
|
||||
});
|
||||
|
||||
if (!excalidrawPackageFiles.length) {
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -46,6 +48,5 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||
// update readme
|
||||
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
|
||||
publish();
|
||||
});
|
||||
|
||||
@@ -38,6 +38,8 @@ const crowdinMap = {
|
||||
"zh-CN": "en-zhcn",
|
||||
"zh-TW": "en-zhtw",
|
||||
"lv-LV": "en-lv",
|
||||
"cs-CZ": "en-cs",
|
||||
"kk-KZ": "en-kk",
|
||||
};
|
||||
|
||||
const flags = {
|
||||
@@ -76,6 +78,8 @@ const flags = {
|
||||
"zh-CN": "🇨🇳",
|
||||
"zh-TW": "🇹🇼",
|
||||
"lv-LV": "🇱🇻",
|
||||
"cs-CZ": "🇨🇿",
|
||||
"kk-KZ": "🇰🇿",
|
||||
};
|
||||
|
||||
const languages = {
|
||||
@@ -114,6 +118,8 @@ const languages = {
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "繁體中文",
|
||||
"lv-LV": "Latviešu",
|
||||
"cs-CZ": "Česky",
|
||||
"kk-KZ": "Қазақ тілі",
|
||||
};
|
||||
|
||||
const percentages = fs.readFileSync(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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);
|
||||
@@ -0,0 +1,97 @@
|
||||
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;
|
||||
@@ -0,0 +1,27 @@
|
||||
const fs = require("fs");
|
||||
|
||||
const updateReadme = () => {
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||
|
||||
// remove note for unstable release
|
||||
data = data.replace(
|
||||
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
|
||||
"",
|
||||
);
|
||||
|
||||
// replace "excalidraw-next" with "excalidraw"
|
||||
data = data.replace(/excalidraw-next/g, "excalidraw");
|
||||
data = data.trim();
|
||||
|
||||
const demoIndex = data.indexOf("### Demo");
|
||||
const excalidrawNextNote =
|
||||
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
|
||||
// Add excalidraw next note to try out for unreleased changes
|
||||
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
|
||||
|
||||
// update readme
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
};
|
||||
|
||||
module.exports = updateReadme;
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import React from "react";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||
import { zoomIn, zoomOut } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { ZOOM_STEP } from "../constants";
|
||||
import { THEME, ZOOM_STEP } from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { 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";
|
||||
@@ -17,13 +13,17 @@ 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, viewBackgroundColor: value },
|
||||
commitToHistory: true,
|
||||
appState: { ...appState, ...value },
|
||||
commitToHistory: !!value.viewBackgroundColor,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => {
|
||||
@@ -33,7 +33,11 @@ export const actionChangeViewBackgroundColor = register({
|
||||
label={t("labels.canvasBackground")}
|
||||
type="canvasBackground"
|
||||
color={appState.viewBackgroundColor}
|
||||
onChange={(color) => updateData(color)}
|
||||
onChange={(color) => updateData({ viewBackgroundColor: color })}
|
||||
isActive={appState.openPopup === "canvasColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "canvasColorPicker" : null })
|
||||
}
|
||||
data-testid="canvas-background-picker"
|
||||
/>
|
||||
</div>
|
||||
@@ -43,40 +47,28 @@ export const actionChangeViewBackgroundColor = register({
|
||||
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
perform: (elements, appState: AppState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.imageCache.clear();
|
||||
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 }) => (
|
||||
<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"
|
||||
/>
|
||||
),
|
||||
|
||||
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
|
||||
});
|
||||
|
||||
export const actionZoomIn = register({
|
||||
@@ -105,6 +97,7 @@ export const actionZoomIn = register({
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@@ -139,6 +132,7 @@ export const actionZoomOut = register({
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@@ -165,16 +159,21 @@ export const actionResetZoom = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={resetZoom}
|
||||
title={t("buttons.resetZoom")}
|
||||
aria-label={t("buttons.resetZoom")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
/>
|
||||
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>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||
@@ -268,7 +267,8 @@ export const actionToggleTheme = register({
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
theme: value || (appState.theme === "light" ? "dark" : "light"),
|
||||
theme:
|
||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
|
||||
@@ -9,8 +9,8 @@ import { t } from "../i18n";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
perform: (elements, appState) => {
|
||||
copyToClipboard(getNonDeletedElements(elements), appState);
|
||||
perform: (elements, appState, _, app) => {
|
||||
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
@@ -50,6 +50,7 @@ export const actionCopyAsSvg = register({
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.files,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
@@ -88,6 +89,7 @@ export const actionCopyAsPng = register({
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.files,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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,4 +1,3 @@
|
||||
import React from "react";
|
||||
import {
|
||||
DistributeHorizontallyIcon,
|
||||
DistributeVerticallyIcon,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import React from "react";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { load, questionCircle, save, saveAs } from "../components/icons";
|
||||
import { load, questionCircle, 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, Appearence } from "../components/DarkModeToggle";
|
||||
import { DarkModeToggle } 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 { supported as fsSupported } from "browser-fs-access";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
import { getExportSize } from "../scene/export";
|
||||
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ActiveFile } from "../components/ActiveFile";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { Theme } from "../element/types";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
@@ -32,6 +39,54 @@ 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) => {
|
||||
@@ -65,43 +120,29 @@ export const actionChangeExportEmbedScene = register({
|
||||
>
|
||||
{t("labels.exportEmbedScene")}
|
||||
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
||||
<div className="Tooltip-icon">{questionCircle}</div>
|
||||
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
|
||||
</Tooltip>
|
||||
</CheckboxItem>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeShouldAddWatermark = register({
|
||||
name: "changeShouldAddWatermark",
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, shouldAddWatermark: value },
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<CheckboxItem
|
||||
checked={appState.shouldAddWatermark}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
{t("labels.addWatermark")}
|
||||
</CheckboxItem>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSaveScene = register({
|
||||
name: "saveScene",
|
||||
perform: async (elements, appState, value) => {
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
perform: async (elements, appState, value, app) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||
? await resaveAsImageWithScene(elements, appState, app.files)
|
||||
: await saveAsJSON(elements, appState, app.files);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
fileHandle,
|
||||
toastMessage: fileHandleExists
|
||||
? fileHandle.name
|
||||
? fileHandle?.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
@@ -119,26 +160,26 @@ export const actionSaveScene = register({
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="icon"
|
||||
icon={save}
|
||||
title={t("buttons.save")}
|
||||
aria-label={t("buttons.save")}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-button"
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ActiveFile
|
||||
onSave={() => updateData(null)}
|
||||
fileName={appState.fileHandle?.name}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSaveAsScene = register({
|
||||
name: "saveAsScene",
|
||||
perform: async (elements, appState, value) => {
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
perform: async (elements, appState, value, app) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(elements, {
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
});
|
||||
const { fileHandle } = await saveAsJSON(
|
||||
elements,
|
||||
{
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
},
|
||||
app.files,
|
||||
);
|
||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
@@ -156,7 +197,7 @@ export const actionSaveAsScene = register({
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
hidden={!fsSupported}
|
||||
hidden={!nativeFileSystemSupported}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-as-button"
|
||||
/>
|
||||
@@ -165,15 +206,17 @@ export const actionSaveAsScene = register({
|
||||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
perform: async (elements, appState) => {
|
||||
perform: async (elements, appState, _, app) => {
|
||||
try {
|
||||
const {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
} = await loadFromJSON(appState);
|
||||
files,
|
||||
} = await loadFromJSON(appState, elements);
|
||||
return {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
files,
|
||||
commitToHistory: true,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -183,6 +226,7 @@ export const actionLoadScene = register({
|
||||
return {
|
||||
elements,
|
||||
appState: { ...appState, errorMessage: error.message },
|
||||
files: app.files,
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
@@ -219,9 +263,9 @@ export const actionExportWithDarkMode = register({
|
||||
}}
|
||||
>
|
||||
<DarkModeToggle
|
||||
value={appState.exportWithDarkMode ? "dark" : "light"}
|
||||
onChange={(theme: Appearence) => {
|
||||
updateData(theme === "dark");
|
||||
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
|
||||
onChange={(theme: Theme) => {
|
||||
updateData(theme === THEME.DARK);
|
||||
}}
|
||||
title={t("labels.toggleExportColorScheme")}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
@@ -50,6 +49,11 @@ export const actionFinalize = register({
|
||||
}
|
||||
|
||||
let newElements = elements;
|
||||
|
||||
if (appState.pendingImageElement) {
|
||||
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
|
||||
}
|
||||
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
focusContainer();
|
||||
}
|
||||
@@ -153,6 +157,7 @@ export const actionFinalize = register({
|
||||
[multiPointElement.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
pendingImageElement: null,
|
||||
},
|
||||
commitToHistory: appState.elementType === "freedraw",
|
||||
};
|
||||
|
||||
@@ -93,13 +93,13 @@ const flipElements = (
|
||||
appState: AppState,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
): ExcalidrawElement[] => {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
flipElement(elements[i], appState);
|
||||
elements.forEach((element) => {
|
||||
flipElement(element, appState);
|
||||
// If vertical flip, rotate an extra 180
|
||||
if (flipDirection === "vertical") {
|
||||
rotateElement(elements[i], Math.PI);
|
||||
rotateElement(element, Math.PI);
|
||||
}
|
||||
}
|
||||
});
|
||||
return elements;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../utils";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Action, ActionResult } from "./types";
|
||||
import React from "react";
|
||||
import { undo, redo } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
@@ -69,12 +68,13 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key.toLowerCase() === KEYS.Z &&
|
||||
!event.shiftKey,
|
||||
PanelComponent: ({ updateData }) => (
|
||||
PanelComponent: ({ updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={undo}
|
||||
aria-label={t("buttons.undo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
commitToHistory: () => false,
|
||||
@@ -89,12 +89,13 @@ export const createRedoAction: ActionCreator = (history) => ({
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === KEYS.Z) ||
|
||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||
PanelComponent: ({ updateData }) => (
|
||||
PanelComponent: ({ updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={redo}
|
||||
aria-label={t("buttons.redo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
commitToHistory: () => false,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { menu, palette } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { getClientColors, getClientInitials } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
@@ -30,8 +29,8 @@ export const actionGoToCollaborator = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, id }) => {
|
||||
const clientId = id;
|
||||
PanelComponent: ({ appState, updateData, data }) => {
|
||||
const clientId: string | undefined = data?.id;
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { AppState } from "../../src/types";
|
||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
@@ -13,6 +12,13 @@ import {
|
||||
FillCrossHatchIcon,
|
||||
FillHachureIcon,
|
||||
FillSolidIcon,
|
||||
FontFamilyCodeIcon,
|
||||
FontFamilyHandDrawnIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FontSizeExtraLargeIcon,
|
||||
FontSizeLargeIcon,
|
||||
FontSizeMediumIcon,
|
||||
FontSizeSmallIcon,
|
||||
SloppinessArchitectIcon,
|
||||
SloppinessArtistIcon,
|
||||
SloppinessCartoonistIcon,
|
||||
@@ -20,18 +26,15 @@ import {
|
||||
StrokeStyleDottedIcon,
|
||||
StrokeStyleSolidIcon,
|
||||
StrokeWidthIcon,
|
||||
FontSizeSmallIcon,
|
||||
FontSizeMediumIcon,
|
||||
FontSizeLargeIcon,
|
||||
FontSizeExtraLargeIcon,
|
||||
FontFamilyHandDrawnIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FontFamilyCodeIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignCenterIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
} from "../components/icons";
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
} from "../constants";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
isTextElement,
|
||||
@@ -44,7 +47,7 @@ import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
FontFamily,
|
||||
FontFamilyValues,
|
||||
TextAlign,
|
||||
} from "../element/types";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
@@ -56,6 +59,7 @@ import {
|
||||
getTargetElements,
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { register } from "./register";
|
||||
|
||||
const changeProperty = (
|
||||
@@ -99,13 +103,20 @@ export const actionChangeStrokeColor = register({
|
||||
name: "changeStrokeColor",
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
strokeColor: value,
|
||||
...(value.currentItemStrokeColor && {
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
return hasStrokeColor(el.type)
|
||||
? newElementWith(el, {
|
||||
strokeColor: value.currentItemStrokeColor,
|
||||
})
|
||||
: el;
|
||||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemStrokeColor: value },
|
||||
commitToHistory: true,
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
commitToHistory: !!value.currentItemStrokeColor,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
@@ -120,7 +131,11 @@ export const actionChangeStrokeColor = register({
|
||||
(element) => element.strokeColor,
|
||||
appState.currentItemStrokeColor,
|
||||
)}
|
||||
onChange={updateData}
|
||||
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
||||
isActive={appState.openPopup === "strokeColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@@ -130,13 +145,18 @@ export const actionChangeBackgroundColor = register({
|
||||
name: "changeBackgroundColor",
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
backgroundColor: value,
|
||||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemBackgroundColor: value },
|
||||
commitToHistory: true,
|
||||
...(value.currentItemBackgroundColor && {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
backgroundColor: value.currentItemBackgroundColor,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
commitToHistory: !!value.currentItemBackgroundColor,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
@@ -151,7 +171,11 @@ export const actionChangeBackgroundColor = register({
|
||||
(element) => element.backgroundColor,
|
||||
appState.currentItemBackgroundColor,
|
||||
)}
|
||||
onChange={updateData}
|
||||
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
||||
isActive={appState.openPopup === "backgroundColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@@ -481,19 +505,23 @@ export const actionChangeFontFamily = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
const options: { value: FontFamily; text: string; icon: JSX.Element }[] = [
|
||||
const options: {
|
||||
value: FontFamilyValues;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
}[] = [
|
||||
{
|
||||
value: 1,
|
||||
value: FONT_FAMILY.Virgil,
|
||||
text: t("labels.handDrawn"),
|
||||
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
value: FONT_FAMILY.Helvetica,
|
||||
text: t("labels.normal"),
|
||||
icon: <FontFamilyNormalIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
value: FONT_FAMILY.Cascadia,
|
||||
text: t("labels.code"),
|
||||
icon: <FontFamilyCodeIcon theme={appState.theme} />,
|
||||
},
|
||||
@@ -502,7 +530,7 @@ export const actionChangeFontFamily = register({
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontFamily")}</legend>
|
||||
<ButtonIconSelect<FontFamily | false>
|
||||
<ButtonIconSelect<FontFamilyValues | false>
|
||||
group="font-family"
|
||||
options={options}
|
||||
value={getFormValue(
|
||||
|
||||
@@ -10,7 +10,6 @@ export const actionToggleViewMode = register({
|
||||
appState: {
|
||||
...appState,
|
||||
viewModeEnabled: !this.checked!(appState),
|
||||
selectedElementIds: {},
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
|
||||
@@ -34,8 +34,8 @@ export { actionFinalize } from "./actionFinalize";
|
||||
export {
|
||||
actionChangeProjectName,
|
||||
actionChangeExportBackground,
|
||||
actionSaveScene,
|
||||
actionSaveAsScene,
|
||||
actionSaveToActiveFile,
|
||||
actionSaveFileToDisk,
|
||||
actionLoadScene,
|
||||
} from "./actionExport";
|
||||
|
||||
|
||||
+9
-19
@@ -5,20 +5,11 @@ import {
|
||||
UpdaterFn,
|
||||
ActionName,
|
||||
ActionResult,
|
||||
PanelComponentProps,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppProps, AppState } from "../types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { MODES } from "../constants";
|
||||
import Library from "../data/library";
|
||||
|
||||
// This is the <App> component, but for now we don't care about anything but its
|
||||
// `canvas` state.
|
||||
type App = {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
focusContainer: () => void;
|
||||
props: AppProps;
|
||||
library: Library;
|
||||
};
|
||||
|
||||
export class ActionManager implements ActionsManagerInterface {
|
||||
actions = {} as ActionsManagerInterface["actions"];
|
||||
@@ -27,13 +18,13 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
|
||||
getAppState: () => Readonly<AppState>;
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||
app: App;
|
||||
app: AppClassProperties;
|
||||
|
||||
constructor(
|
||||
updater: UpdaterFn,
|
||||
getAppState: () => AppState,
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||
app: App,
|
||||
app: AppClassProperties,
|
||||
) {
|
||||
this.updater = (actionResult) => {
|
||||
if (actionResult && "then" in actionResult) {
|
||||
@@ -107,11 +98,10 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
);
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
/**
|
||||
* @param data additional data sent to the PanelComponent
|
||||
*/
|
||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
|
||||
if (
|
||||
@@ -139,8 +129,8 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
elements={this.getElementsIncludingDeleted()}
|
||||
appState={this.getAppState()}
|
||||
updateData={updateData}
|
||||
id={id}
|
||||
appProps={this.app.props}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+22
-19
@@ -1,7 +1,12 @@
|
||||
import React from "react";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import Library from "../data/library";
|
||||
import {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
} from "../types";
|
||||
import { ToolButtonSize } from "../components/ToolButton";
|
||||
|
||||
/** if false, the action should be prevented */
|
||||
export type ActionResult =
|
||||
@@ -11,22 +16,18 @@ export type ActionResult =
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
> | null;
|
||||
files?: BinaryFiles | null;
|
||||
commitToHistory: boolean;
|
||||
syncHistory?: boolean;
|
||||
replaceFiles?: boolean;
|
||||
}
|
||||
| false;
|
||||
|
||||
type AppAPI = {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
focusContainer(): void;
|
||||
library: Library;
|
||||
};
|
||||
|
||||
type ActionFn = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
formData: any,
|
||||
app: AppAPI,
|
||||
app: AppClassProperties,
|
||||
) => ActionResult | Promise<ActionResult>;
|
||||
|
||||
export type UpdaterFn = (res: ActionResult) => void;
|
||||
@@ -66,9 +67,9 @@ export type ActionName =
|
||||
| "changeProjectName"
|
||||
| "changeExportBackground"
|
||||
| "changeExportEmbedScene"
|
||||
| "changeShouldAddWatermark"
|
||||
| "saveScene"
|
||||
| "saveAsScene"
|
||||
| "changeExportScale"
|
||||
| "saveToActiveFile"
|
||||
| "saveFileToDisk"
|
||||
| "loadScene"
|
||||
| "duplicateSelection"
|
||||
| "deleteSelectedElements"
|
||||
@@ -102,15 +103,17 @@ export type ActionName =
|
||||
| "exportWithDarkMode"
|
||||
| "toggleTheme";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
updateData: (formData?: any) => void;
|
||||
appProps: ExcalidrawProps;
|
||||
data?: Partial<{ id: string; size: ToolButtonSize }>;
|
||||
};
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
PanelComponent?: React.FC<{
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
updateData: (formData?: any) => void;
|
||||
appProps: ExcalidrawProps;
|
||||
id?: string;
|
||||
}>;
|
||||
PanelComponent?: React.FC<PanelComponentProps>;
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
keyTest?: (
|
||||
|
||||
+92
-68
@@ -3,17 +3,23 @@ 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: "light",
|
||||
theme: THEME.LIGHT,
|
||||
collaborators: new Map(),
|
||||
currentChartType: "bar",
|
||||
currentItemBackgroundColor: "transparent",
|
||||
@@ -39,6 +45,7 @@ export const getDefaultAppState = (): Omit<
|
||||
elementType: "selection",
|
||||
errorMessage: null,
|
||||
exportBackground: true,
|
||||
exportScale: defaultExportScale,
|
||||
exportEmbedScene: false,
|
||||
exportWithDarkMode: false,
|
||||
fileHandle: null,
|
||||
@@ -52,6 +59,7 @@ export const getDefaultAppState = (): Omit<
|
||||
multiElement: null,
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
openMenu: null,
|
||||
openPopup: null,
|
||||
pasteDialog: { shown: false, data: null },
|
||||
previousSelectedElementIds: {},
|
||||
resizingElement: null,
|
||||
@@ -61,7 +69,6 @@ export const getDefaultAppState = (): Omit<
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
selectionElement: null,
|
||||
shouldAddWatermark: false,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showHelpDialog: false,
|
||||
showStats: false,
|
||||
@@ -72,6 +79,7 @@ export const getDefaultAppState = (): Omit<
|
||||
zenModeEnabled: false,
|
||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||
viewModeEnabled: false,
|
||||
pendingImageElement: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -85,77 +93,87 @@ 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 },
|
||||
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 },
|
||||
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 },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
||||
const _clearAppStateForStorage = <
|
||||
ExportType extends "export" | "browser" | "server"
|
||||
>(
|
||||
appState: Partial<AppState>,
|
||||
exportType: ExportType,
|
||||
) => {
|
||||
@@ -168,8 +186,10 @@ const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
||||
const propConfig = APP_STATE_STORAGE_CONF[key];
|
||||
if (propConfig?.[exportType]) {
|
||||
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
||||
stateForExport[key] = appState[key];
|
||||
const nextValue = appState[key];
|
||||
|
||||
// https://github.com/microsoft/TypeScript/issues/31445
|
||||
(stateForExport as any)[key] = nextValue;
|
||||
}
|
||||
}
|
||||
return stateForExport;
|
||||
@@ -182,3 +202,7 @@ 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");
|
||||
};
|
||||
|
||||
+20
-6
@@ -3,19 +3,22 @@ import {
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import { AppState } from "./types";
|
||||
import { AppState, BinaryFiles } from "./types";
|
||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { EXPORT_DATA_TYPES } from "./constants";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||
import { isInitializedImageElement } from "./element/typeChecks";
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -37,7 +40,7 @@ export const probablySupportsClipboardBlob =
|
||||
|
||||
const clipboardContainsElements = (
|
||||
contents: any,
|
||||
): contents is { elements: ExcalidrawElement[] } => {
|
||||
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
|
||||
if (
|
||||
[
|
||||
EXPORT_DATA_TYPES.excalidraw,
|
||||
@@ -53,10 +56,18 @@ 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: getSelectedElements(elements, appState),
|
||||
elements: selectedElements,
|
||||
files: selectedElements.reduce((acc, element) => {
|
||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||
acc[element.fileId] = files[element.fileId];
|
||||
}
|
||||
return acc;
|
||||
}, {} as BinaryFiles),
|
||||
};
|
||||
const json = JSON.stringify(contents);
|
||||
CLIPBOARD = json;
|
||||
@@ -138,7 +149,10 @@ export const parseClipboard = async (
|
||||
try {
|
||||
const systemClipboardData = JSON.parse(systemClipboard);
|
||||
if (clipboardContainsElements(systemClipboardData)) {
|
||||
return { elements: systemClipboardData.elements };
|
||||
return {
|
||||
elements: systemClipboardData.elements,
|
||||
files: systemClipboardData.files,
|
||||
};
|
||||
}
|
||||
return appClipboardData;
|
||||
} catch {
|
||||
@@ -153,7 +167,7 @@ export const parseClipboard = async (
|
||||
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({ "image/png": blob }),
|
||||
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
+27
-34
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ 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,
|
||||
@@ -48,9 +49,22 @@ 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">
|
||||
{renderAction("changeStrokeColor")}
|
||||
{((hasStrokeColor(elementType) &&
|
||||
elementType !== "image" &&
|
||||
commonSelectedType !== "image") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
@@ -151,31 +165,24 @@ 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,
|
||||
isLibraryOpen,
|
||||
onImageAction,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
elementType: ExcalidrawElement["type"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
isLibraryOpen: boolean;
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter = typeof key === "string" ? key : key[0];
|
||||
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
|
||||
index + 1
|
||||
}`;
|
||||
const letter = key && (typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
|
||||
: `${index + 1}`;
|
||||
return (
|
||||
<ToolButton
|
||||
className="Shape"
|
||||
@@ -189,31 +196,20 @@ export const ShapesSwitcher = ({
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={value}
|
||||
onChange={() => {
|
||||
onChange={({ pointerType }) => {
|
||||
setAppState({
|
||||
elementType: value,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(canvas, value);
|
||||
setAppState({});
|
||||
if (value === "image") {
|
||||
onImageAction({ pointerType });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<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 });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -226,12 +222,9 @@ export const ZoomActions = ({
|
||||
}) => (
|
||||
<Stack.Col gap={1}>
|
||||
<Stack.Row gap={1} align="center">
|
||||
{renderAction("zoomIn")}
|
||||
{renderAction("zoomOut")}
|
||||
{renderAction("zoomIn")}
|
||||
{renderAction("resetZoom")}
|
||||
<div style={{ marginInlineStart: 4 }}>
|
||||
{(zoom.value * 100).toFixed(0)}%
|
||||
</div>
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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>
|
||||
);
|
||||
+892
-272
File diff suppressed because it is too large
Load Diff
@@ -16,10 +16,5 @@ export const BackgroundPickerAndDarkModeToggle = ({
|
||||
<div style={{ display: "flex" }}>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
{showThemeBtn && actionManager.renderAction("toggleTheme")}
|
||||
{appState.fileHandle && (
|
||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
||||
{actionManager.renderAction("saveScene")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const ButtonSelect = <T extends Object>({
|
||||
|
||||
@@ -48,6 +48,10 @@
|
||||
.ToolIcon__label {
|
||||
color: $oc-white;
|
||||
}
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
.excalidraw {
|
||||
.Checkbox {
|
||||
margin: 3px 0.3em;
|
||||
margin: 4px 0.3em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover:not(.is-checked) .Checkbox-box {
|
||||
box-shadow: 0 0 0 2px #{$oc-blue-4};
|
||||
-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;
|
||||
@@ -77,7 +81,7 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.Tooltip-icon {
|
||||
.excalidraw-tooltip-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { checkIcon } from "./icons";
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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,4 +1,3 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { t } from "../i18n";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { Popover } from "./Popover";
|
||||
import { isTransparent } from "../utils";
|
||||
|
||||
import "./ColorPicker.scss";
|
||||
import { isArrowKey, KEYS } from "../keys";
|
||||
@@ -14,7 +15,7 @@ const isValidColor = (color: string) => {
|
||||
};
|
||||
|
||||
const getColor = (color: string): string | null => {
|
||||
if (color === "transparent") {
|
||||
if (isTransparent(color)) {
|
||||
return color;
|
||||
}
|
||||
|
||||
@@ -137,36 +138,41 @@ const Picker = ({
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{showInput && (
|
||||
<ColorInput
|
||||
color={color}
|
||||
@@ -238,13 +244,16 @@ 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,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { Popover } from "./Popover";
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
export type Appearence = "light" | "dark";
|
||||
import { THEME } from "../constants";
|
||||
import { Theme } from "../element/types";
|
||||
|
||||
// We chose to use only explicit toggle and not a third option for system value,
|
||||
// but this could be added in the future.
|
||||
export const DarkModeToggle = (props: {
|
||||
value: Appearence;
|
||||
onChange: (value: Appearence) => void;
|
||||
value: Theme;
|
||||
onChange: (value: Theme) => void;
|
||||
title?: string;
|
||||
}) => {
|
||||
const title =
|
||||
@@ -20,10 +19,12 @@ export const DarkModeToggle = (props: {
|
||||
return (
|
||||
<ToolButton
|
||||
type="icon"
|
||||
icon={props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
||||
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={() => props.onChange(props.value === "dark" ? "light" : "dark")}
|
||||
onClick={() =>
|
||||
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
|
||||
}
|
||||
data-testid="toggle-dark-mode"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import clsx from "clsx";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { useExcalidrawContainer, useIsMobile } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import "./Dialog.scss";
|
||||
import { back, close } from "./icons";
|
||||
@@ -21,6 +21,7 @@ export const Dialog = (props: {
|
||||
}) => {
|
||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||
const [lastActiveElement] = useState(document.activeElement);
|
||||
const { id } = useExcalidrawContainer();
|
||||
|
||||
useEffect(() => {
|
||||
if (!islandNode) {
|
||||
@@ -82,7 +83,7 @@ export const Dialog = (props: {
|
||||
theme={props.theme}
|
||||
>
|
||||
<Island ref={setIslandNode}>
|
||||
<h2 id="dialog-title" className="Dialog__title">
|
||||
<h2 id={`${id}-dialog-title`} className="Dialog__title">
|
||||
<span className="Dialog__titleContent">{props.title}</span>
|
||||
<button
|
||||
className="Modal__close"
|
||||
|
||||
@@ -12,7 +12,7 @@ export const ErrorDialog = ({
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(!!message);
|
||||
const excalidrawContainer = useExcalidrawContainer();
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
|
||||
@@ -97,7 +97,8 @@
|
||||
|
||||
border-radius: 1rem;
|
||||
background-color: var(--button-color);
|
||||
box-shadow: 0 3px 5px -1px rgb(0 0 0 / 28%), 0 6px 10px 0 rgb(0 0 0 / 14%);
|
||||
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;
|
||||
@@ -108,7 +109,7 @@
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--button-color-darkest);
|
||||
box-shadow: 0 3px 5px -1px rgb(0 0 0 / 20%), 0 6px 10px 0 rgb(0 0 0 / 14%);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
|
||||
@@ -157,6 +157,15 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
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={[
|
||||
@@ -365,6 +374,14 @@ 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,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { questionCircle } from "../components/icons";
|
||||
|
||||
type HelpIconProps = {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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 { isLinearElement } from "../element/typeChecks";
|
||||
import {
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getShortcutKey } from "../utils";
|
||||
|
||||
interface Hint {
|
||||
@@ -31,6 +34,10 @@ 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 &&
|
||||
@@ -41,7 +48,9 @@ const getHints = ({ appState, elements }: Hint) => {
|
||||
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||
return t("hints.lockAngle");
|
||||
}
|
||||
return t("hints.resize");
|
||||
return isImageElement(targetElement)
|
||||
? t("hints.resizeImage")
|
||||
: t("hints.resize");
|
||||
}
|
||||
|
||||
if (isRotating && lastPointerDownWith === "mouse") {
|
||||
@@ -57,6 +66,14 @@ 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;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,20 +8,17 @@ import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas, getExportSize } from "../scene/export";
|
||||
import { AppState } from "../types";
|
||||
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 { supported as fsSupported } from "browser-fs-access";
|
||||
import OpenColor from "open-color";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
|
||||
const scales = [1, 2, 3];
|
||||
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
|
||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
|
||||
const supportsContextFilters =
|
||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||
@@ -82,7 +79,8 @@ const ExportButton: React.FC<{
|
||||
const ImageExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding = 10,
|
||||
files,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
actionManager,
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
@@ -90,6 +88,7 @@ const ImageExportModal = ({
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToPng: ExportCB;
|
||||
@@ -98,14 +97,9 @@ const ImageExportModal = ({
|
||||
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 { exportBackground, viewBackgroundColor } = appState;
|
||||
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState)
|
||||
@@ -120,37 +114,29 @@ const ImageExportModal = ({
|
||||
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(() => {
|
||||
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);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
renderPreview(new CanvasError(), previewNode);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
renderPreview(new CanvasError(), previewNode);
|
||||
});
|
||||
}, [
|
||||
appState,
|
||||
files,
|
||||
exportedElements,
|
||||
exportBackground,
|
||||
exportPadding,
|
||||
viewBackgroundColor,
|
||||
scale,
|
||||
shouldAddWatermark,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -181,34 +167,8 @@ const ImageExportModal = ({
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
|
||||
<Stack.Row gap={2} justifyContent={"center"}>
|
||||
{scales.map((_scale) => {
|
||||
const [width, height] = getExportSize(
|
||||
exportedElements,
|
||||
exportPadding,
|
||||
shouldAddWatermark,
|
||||
_scale,
|
||||
);
|
||||
|
||||
const scaleButtonTitle = `${t(
|
||||
"buttons.scale",
|
||||
)} ${_scale}x (${width}x${height})`;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={_scale}
|
||||
size="s"
|
||||
type="radio"
|
||||
icon={`${_scale}x`}
|
||||
name="export-canvas-scale"
|
||||
title={scaleButtonTitle}
|
||||
aria-label={scaleButtonTitle}
|
||||
id="export-canvas-scale"
|
||||
checked={_scale === scale}
|
||||
onChange={() => setScale(_scale)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Stack.Row gap={2}>
|
||||
{actionManager.renderAction("changeExportScale")}
|
||||
</Stack.Row>
|
||||
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
|
||||
</div>
|
||||
@@ -220,14 +180,15 @@ const ImageExportModal = ({
|
||||
margin: ".6em 0",
|
||||
}}
|
||||
>
|
||||
{!fsSupported && actionManager.renderAction("changeProjectName")}
|
||||
{!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, scale)}
|
||||
onClick={() => onExportToPng(exportedElements)}
|
||||
>
|
||||
PNG
|
||||
</ExportButton>
|
||||
@@ -235,14 +196,14 @@ const ImageExportModal = ({
|
||||
color="red"
|
||||
title={t("buttons.exportToSvg")}
|
||||
aria-label={t("buttons.exportToSvg")}
|
||||
onClick={() => onExportToSvg(exportedElements, scale)}
|
||||
onClick={() => onExportToSvg(exportedElements)}
|
||||
>
|
||||
SVG
|
||||
</ExportButton>
|
||||
{probablySupportsClipboardBlob && (
|
||||
<ExportButton
|
||||
title={t("buttons.copyPngToClipboard")}
|
||||
onClick={() => onExportToClipboard(exportedElements, scale)}
|
||||
onClick={() => onExportToClipboard(exportedElements)}
|
||||
color="gray"
|
||||
shade={7}
|
||||
>
|
||||
@@ -257,7 +218,8 @@ const ImageExportModal = ({
|
||||
export const ImageExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding = 10,
|
||||
files,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
actionManager,
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
@@ -265,6 +227,7 @@ export const ImageExportDialog = ({
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToPng: ExportCB;
|
||||
@@ -295,6 +258,7 @@ export const ImageExportDialog = ({
|
||||
<ImageExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
exportPadding={exportPadding}
|
||||
actionManager={actionManager}
|
||||
onExportToPng={onExportToPng}
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } 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,
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
export const InitializeApp = (props: Props) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const updateLang = async () => {
|
||||
await setLanguage(currentLang);
|
||||
};
|
||||
const currentLang =
|
||||
languages.find((lang) => lang.code === this.props.langCode) ||
|
||||
defaultLang;
|
||||
await setLanguage(currentLang);
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
||||
updateLang();
|
||||
setLoading(false);
|
||||
}, [props.langCode]);
|
||||
|
||||
public render() {
|
||||
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
|
||||
}
|
||||
}
|
||||
return loading ? <LoadingMessage /> : props.children;
|
||||
};
|
||||
|
||||
@@ -3,15 +3,15 @@ import { ActionsManagerInterface } from "../actions/types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { AppState } from "../types";
|
||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { actionSaveAsScene } from "../actions/actionExport";
|
||||
import { actionSaveFileToDisk } from "../actions/actionExport";
|
||||
import { Card } from "./Card";
|
||||
|
||||
import "./ExportDialog.scss";
|
||||
import { supported as fsSupported } from "browser-fs-access";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
|
||||
export type ExportCB = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
@@ -21,36 +21,44 @@ export type ExportCB = (
|
||||
const JSONExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
actionManager,
|
||||
onExportToBackend,
|
||||
exportOpts,
|
||||
canvas,
|
||||
}: {
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToBackend?: ExportCB;
|
||||
onCloseRequest: () => void;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
}) => {
|
||||
const { onExportToBackend } = exportOpts;
|
||||
return (
|
||||
<div className="ExportDialog ExportDialog--json">
|
||||
<div className="ExportDialog-cards">
|
||||
<Card color="lime">
|
||||
<div className="Card-icon">{exportToFileIcon}</div>
|
||||
<h2>{t("exportDialog.disk_title")}</h2>
|
||||
<div className="Card-details">
|
||||
{t("exportDialog.disk_details")}
|
||||
{!fsSupported && 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(actionSaveAsScene);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
{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>
|
||||
@@ -62,10 +70,14 @@ const JSONExportModal = ({
|
||||
title={t("exportDialog.link_button")}
|
||||
aria-label={t("exportDialog.link_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => onExportToBackend(elements)}
|
||||
onClick={() =>
|
||||
onExportToBackend(elements, appState, files, canvas)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
{exportOpts.renderCustomUI &&
|
||||
exportOpts.renderCustomUI(elements, appState, files, canvas)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -74,13 +86,17 @@ const JSONExportModal = ({
|
||||
export const JSONExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
actionManager,
|
||||
onExportToBackend,
|
||||
exportOpts,
|
||||
canvas,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToBackend?: ExportCB;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
|
||||
@@ -106,9 +122,11 @@ export const JSONExportDialog = ({
|
||||
<JSONExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onExportToBackend={onExportToBackend}
|
||||
onCloseRequest={handleClose}
|
||||
exportOpts={exportOpts}
|
||||
canvas={canvas}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
@@ -73,10 +73,10 @@
|
||||
}
|
||||
|
||||
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||
transform: translate(-92px, 0);
|
||||
transform: translate(-76px, 0);
|
||||
}
|
||||
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||
transform: translate(92px, 0);
|
||||
transform: translate(76px, 0);
|
||||
}
|
||||
|
||||
&.layer-ui__wrapper__footer-left--transition-bottom {
|
||||
@@ -116,8 +116,19 @@
|
||||
}
|
||||
}
|
||||
.layer-ui__wrapper__footer-left,
|
||||
.layer-ui__wrapper__footer-right {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+92
-35
@@ -20,6 +20,7 @@ import {
|
||||
AppProps,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
LibraryItem,
|
||||
LibraryItems,
|
||||
} from "../types";
|
||||
@@ -36,7 +37,7 @@ import { Island } from "./Island";
|
||||
import "./LayerUI.scss";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { LockIcon } from "./LockIcon";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
import { PasteChartDialog } from "./PasteChartDialog";
|
||||
import { Section } from "./Section";
|
||||
@@ -47,10 +48,13 @@ 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[];
|
||||
@@ -63,12 +67,10 @@ interface LayerUIProps {
|
||||
toggleZenMode: () => void;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
onExportToBackend?: (
|
||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
canvas: HTMLCanvasElement | null,
|
||||
) => void;
|
||||
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
) => JSX.Element | null;
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
@@ -76,6 +78,7 @@ interface LayerUIProps {
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
}
|
||||
|
||||
const useOnClickOutside = (
|
||||
@@ -112,11 +115,13 @@ const LibraryMenuItems = ({
|
||||
onAddToLibrary,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
theme,
|
||||
setAppState,
|
||||
setLibraryItems,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
files,
|
||||
id,
|
||||
}: {
|
||||
libraryItems: LibraryItems;
|
||||
@@ -124,6 +129,8 @@ const LibraryMenuItems = ({
|
||||
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"];
|
||||
@@ -197,7 +204,7 @@ const LibraryMenuItems = ({
|
||||
<a
|
||||
href={`https://libraries.excalidraw.com?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}`}
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
@@ -219,6 +226,7 @@ const LibraryMenuItems = ({
|
||||
<Stack.Col key={x}>
|
||||
<LibraryUnit
|
||||
elements={libraryItems[y + x]}
|
||||
files={files}
|
||||
pendingElements={
|
||||
shouldAddPendingElements ? pendingElements : undefined
|
||||
}
|
||||
@@ -251,7 +259,9 @@ const LibraryMenu = ({
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
theme,
|
||||
setAppState,
|
||||
files,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
@@ -261,6 +271,8 @@ const LibraryMenu = ({
|
||||
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;
|
||||
@@ -282,12 +294,12 @@ const LibraryMenu = ({
|
||||
"preloading" | "loading" | "ready"
|
||||
>("preloading");
|
||||
|
||||
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const loadingTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.race([
|
||||
new Promise((resolve) => {
|
||||
loadingTimerRef.current = setTimeout(() => {
|
||||
loadingTimerRef.current = window.setTimeout(() => {
|
||||
resolve("loading");
|
||||
}, 100);
|
||||
}),
|
||||
@@ -320,6 +332,12 @@ const LibraryMenu = ({
|
||||
|
||||
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 nextItems = [...items, elements];
|
||||
onAddToLibrary();
|
||||
@@ -350,6 +368,8 @@ const LibraryMenu = ({
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
theme={theme}
|
||||
files={files}
|
||||
id={id}
|
||||
/>
|
||||
)}
|
||||
@@ -360,6 +380,7 @@ const LibraryMenu = ({
|
||||
const LayerUI = ({
|
||||
actionManager,
|
||||
appState,
|
||||
files,
|
||||
setAppState,
|
||||
canvas,
|
||||
elements,
|
||||
@@ -371,7 +392,6 @@ const LayerUI = ({
|
||||
showThemeBtn,
|
||||
toggleZenMode,
|
||||
isCollaborating,
|
||||
onExportToBackend,
|
||||
renderTopRightUI,
|
||||
renderCustomFooter,
|
||||
viewModeEnabled,
|
||||
@@ -380,6 +400,7 @@ const LayerUI = ({
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
onImageAction,
|
||||
}: LayerUIProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -392,46 +413,53 @@ const LayerUI = ({
|
||||
<JSONExportDialog
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onExportToBackend={
|
||||
onExportToBackend
|
||||
? (elements) => {
|
||||
onExportToBackend &&
|
||||
onExportToBackend(elements, appState, canvas);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
exportOpts={UIOptions.canvasActions.export}
|
||||
canvas={canvas}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderImageExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
if (!UIOptions.canvasActions.saveAsImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createExporter = (type: ExportType): ExportCB => async (
|
||||
exportedElements,
|
||||
scale,
|
||||
) => {
|
||||
await exportCanvas(type, exportedElements, appState, {
|
||||
exportBackground: appState.exportBackground,
|
||||
name: appState.name,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
scale,
|
||||
shouldAddWatermark: appState.shouldAddWatermark,
|
||||
})
|
||||
const fileHandle = await exportCanvas(
|
||||
type,
|
||||
exportedElements,
|
||||
appState,
|
||||
files,
|
||||
{
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ImageExportDialog
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onExportToPng={createExporter("png")}
|
||||
onExportToSvg={createExporter("svg")}
|
||||
@@ -465,6 +493,7 @@ const LayerUI = ({
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCanvasActions = () => (
|
||||
<Section
|
||||
heading="canvasActions"
|
||||
@@ -497,6 +526,9 @@ const LayerUI = ({
|
||||
setAppState={setAppState}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
{appState.fileHandle && (
|
||||
<>{actionManager.renderAction("saveToActiveFile")}</>
|
||||
)}
|
||||
</Stack.Col>
|
||||
</Island>
|
||||
</Section>
|
||||
@@ -515,7 +547,8 @@ 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
|
||||
maxHeight: `${appState.height - 200}px`,
|
||||
// if active file name is displayed, subtracting 248 to account for its height
|
||||
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
|
||||
}}
|
||||
>
|
||||
<SelectedShapeActions
|
||||
@@ -552,6 +585,8 @@ const LayerUI = ({
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
theme={appState.theme}
|
||||
files={files}
|
||||
id={id}
|
||||
/>
|
||||
) : null;
|
||||
@@ -579,6 +614,12 @@ 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 })}
|
||||
@@ -590,15 +631,17 @@ const LayerUI = ({
|
||||
canvas={canvas}
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
isLibraryOpen={appState.isLibraryOpen}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
<LockIcon
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
<LibraryButton
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
@@ -624,7 +667,9 @@ const LayerUI = ({
|
||||
label={client.username || "Unknown user"}
|
||||
key={clientId}
|
||||
>
|
||||
{actionManager.renderAction("goToCollaborator", clientId)}
|
||||
{actionManager.renderAction("goToCollaborator", {
|
||||
id: clientId,
|
||||
})}
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
@@ -657,6 +702,16 @@ const LayerUI = ({
|
||||
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>
|
||||
@@ -741,6 +796,8 @@ const LayerUI = ({
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
viewModeEnabled={viewModeEnabled}
|
||||
showThemeBtn={showThemeBtn}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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 React, { useEffect, useRef, useState } from "react";
|
||||
import { 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 { LibraryItem } from "../types";
|
||||
import { BinaryFiles, LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
|
||||
// fa-plus
|
||||
@@ -21,39 +21,44 @@ 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 elementsToRender = elements || pendingElements;
|
||||
if (!elementsToRender) {
|
||||
const node = ref.current;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const svg = exportToSvg(elementsToRender, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
shouldAddWatermark: false,
|
||||
});
|
||||
for (const child of ref.current!.children) {
|
||||
if (child.tagName !== "svg") {
|
||||
continue;
|
||||
}
|
||||
ref.current!.removeChild(child);
|
||||
}
|
||||
ref.current!.appendChild(svg);
|
||||
|
||||
const current = ref.current!;
|
||||
(async () => {
|
||||
const elementsToRender = elements || pendingElements;
|
||||
if (!elementsToRender) {
|
||||
return;
|
||||
}
|
||||
const svg = await exportToSvg(
|
||||
elementsToRender,
|
||||
{
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
files,
|
||||
);
|
||||
node.innerHTML = svg.outerHTML;
|
||||
})();
|
||||
|
||||
return () => {
|
||||
current.removeChild(svg);
|
||||
node.innerHTML = "";
|
||||
};
|
||||
}, [elements, pendingElements]);
|
||||
}, [elements, pendingElements, files]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export const LoadingMessage = () => {
|
||||
|
||||
@@ -2,20 +2,17 @@ import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type LockIconSize = "s" | "m";
|
||||
import { ToolButtonSize } from "./ToolButton";
|
||||
|
||||
type LockIconProps = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
size?: LockIconSize;
|
||||
zenModeEnabled?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: LockIconSize = "m";
|
||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
||||
|
||||
const ICONS = {
|
||||
CHECKED: (
|
||||
@@ -41,12 +38,12 @@ const ICONS = {
|
||||
),
|
||||
};
|
||||
|
||||
export const LockIcon = (props: LockIconProps) => {
|
||||
export const LockButton = (props: LockIconProps) => {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
|
||||
`ToolIcon_size_${props.size || DEFAULT_SIZE}`,
|
||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||
{
|
||||
"zen-mode-visibility--hidden": props.zenModeEnabled,
|
||||
},
|
||||
@@ -57,7 +54,6 @@ export const LockIcon = (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,9 +13,10 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||
import { LockIcon } from "./LockIcon";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { UserList } from "./UserList";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
|
||||
type MobileMenuProps = {
|
||||
appState: AppState;
|
||||
@@ -32,6 +33,11 @@ type MobileMenuProps = {
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
showThemeBtn: boolean;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
@@ -49,6 +55,8 @@ export const MobileMenu = ({
|
||||
renderCustomFooter,
|
||||
viewModeEnabled,
|
||||
showThemeBtn,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
}: MobileMenuProps) => {
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
@@ -64,15 +72,21 @@ export const MobileMenu = ({
|
||||
canvas={canvas}
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
isLibraryOpen={appState.isLibraryOpen}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
<LockIcon
|
||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
<LockButton
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
<LibraryButton appState={appState} setAppState={setAppState} />
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
</Stack.Col>
|
||||
@@ -167,10 +181,9 @@ export const MobileMenu = ({
|
||||
)
|
||||
.map(([clientId, client]) => (
|
||||
<React.Fragment key={clientId}>
|
||||
{actionManager.renderAction(
|
||||
"goToCollaborator",
|
||||
clientId,
|
||||
)}
|
||||
{actionManager.renderAction("goToCollaborator", {
|
||||
id: clientId,
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</UserList>
|
||||
|
||||
@@ -6,6 +6,7 @@ import clsx from "clsx";
|
||||
import { KEYS } from "../keys";
|
||||
import { useExcalidrawContainer, useIsMobile } from "./App";
|
||||
import { AppState } from "../types";
|
||||
import { THEME } from "../constants";
|
||||
|
||||
export const Modal = (props: {
|
||||
className?: string;
|
||||
@@ -15,7 +16,7 @@ export const Modal = (props: {
|
||||
labelledBy: string;
|
||||
theme?: AppState["theme"];
|
||||
}) => {
|
||||
const { theme = "light" } = props;
|
||||
const { theme = THEME.LIGHT } = props;
|
||||
const modalRoot = useBodyRoot(theme);
|
||||
|
||||
if (!modalRoot) {
|
||||
@@ -58,7 +59,7 @@ const useBodyRoot = (theme: AppState["theme"]) => {
|
||||
const isMobileRef = useRef(isMobile);
|
||||
isMobileRef.current = isMobile;
|
||||
|
||||
const excalidrawContainer = useExcalidrawContainer();
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (div) {
|
||||
|
||||
@@ -34,20 +34,25 @@ const ChartPreviewBtn = (props: {
|
||||
0,
|
||||
);
|
||||
setChartElements(elements);
|
||||
|
||||
const svg = exportToSvg(elements, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
shouldAddWatermark: false,
|
||||
});
|
||||
|
||||
let svg: SVGSVGElement;
|
||||
const previewNode = previewRef.current!;
|
||||
|
||||
previewNode.appendChild(svg);
|
||||
(async () => {
|
||||
svg = await exportToSvg(
|
||||
elements,
|
||||
{
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
null, // files
|
||||
);
|
||||
|
||||
if (props.selected) {
|
||||
(previewNode.parentNode as HTMLDivElement).focus();
|
||||
}
|
||||
previewNode.appendChild(svg);
|
||||
|
||||
if (props.selected) {
|
||||
(previewNode.parentNode as HTMLDivElement).focus();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
previewNode.removeChild(svg);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import "./TextInput.scss";
|
||||
|
||||
import React, { Component } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { focusNearestParent } from "../utils";
|
||||
|
||||
import "./ProjectName.scss";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
@@ -12,22 +13,19 @@ type Props = {
|
||||
isNameEditable: boolean;
|
||||
};
|
||||
|
||||
type State = {
|
||||
fileName: string;
|
||||
};
|
||||
export class ProjectName extends Component<Props, State> {
|
||||
state = {
|
||||
fileName: this.props.value,
|
||||
};
|
||||
private handleBlur = (event: any) => {
|
||||
export const ProjectName = (props: Props) => {
|
||||
const { id } = useExcalidrawContainer();
|
||||
const [fileName, setFileName] = useState<string>(props.value);
|
||||
|
||||
const handleBlur = (event: any) => {
|
||||
focusNearestParent(event.target);
|
||||
const value = event.target.value;
|
||||
if (value !== this.props.value) {
|
||||
this.props.onChange(value);
|
||||
if (value !== props.value) {
|
||||
props.onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (event.nativeEvent.isComposing || event.keyCode === 229) {
|
||||
@@ -37,29 +35,25 @@ export class ProjectName extends Component<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div className="ProjectName">
|
||||
<label className="ProjectName-label" htmlFor="filename">
|
||||
{`${this.props.label}${this.props.isNameEditable ? "" : ":"}`}
|
||||
</label>
|
||||
{this.props.isNameEditable ? (
|
||||
<input
|
||||
className="TextInput"
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
id="filename"
|
||||
value={this.state.fileName}
|
||||
onChange={(event) =>
|
||||
this.setState({ fileName: event.target.value })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="TextInput TextInput--readonly" id="filename">
|
||||
{this.props.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
interface SectionProps extends React.HTMLProps<HTMLElement> {
|
||||
heading: string;
|
||||
@@ -7,13 +8,14 @@ interface SectionProps extends React.HTMLProps<HTMLElement> {
|
||||
}
|
||||
|
||||
export const Section = ({ heading, children, ...props }: SectionProps) => {
|
||||
const { id } = useExcalidrawContainer();
|
||||
const header = (
|
||||
<h2 className="visually-hidden" id={`${heading}-title`}>
|
||||
<h2 className="visually-hidden" id={`${id}-${heading}-title`}>
|
||||
{t(`headings.${heading}`)}
|
||||
</h2>
|
||||
);
|
||||
return (
|
||||
<section {...props} aria-labelledby={`${heading}-title`}>
|
||||
<section {...props} aria-labelledby={`${id}-${heading}-title`}>
|
||||
{typeof children === "function" ? (
|
||||
children(header)
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { TOAST_TIMEOUT } from "../constants";
|
||||
import "./Toast.scss";
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
import { AbortError } from "../errors";
|
||||
import Spinner from "./Spinner";
|
||||
import { PointerType } from "../element/types";
|
||||
|
||||
type ToolIconSize = "s" | "m";
|
||||
export type ToolButtonSize = "small" | "medium";
|
||||
|
||||
type ToolButtonBaseProps = {
|
||||
icon?: React.ReactNode;
|
||||
@@ -14,7 +18,7 @@ type ToolButtonBaseProps = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
size?: ToolIconSize;
|
||||
size?: ToolButtonSize;
|
||||
keyBindingLabel?: string;
|
||||
showAriaLabel?: boolean;
|
||||
hidden?: boolean;
|
||||
@@ -27,7 +31,7 @@ type ToolButtonProps =
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "button";
|
||||
children?: React.ReactNode;
|
||||
onClick?(): void;
|
||||
onClick?(event: React.MouseEvent): void;
|
||||
})
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "icon";
|
||||
@@ -37,15 +41,46 @@ type ToolButtonProps =
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "radio";
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
onChange?(data: { pointerType: PointerType | null }): 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 || DEFAULT_SIZE}`;
|
||||
const sizeCn = `ToolIcon_size_${props.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") {
|
||||
return (
|
||||
@@ -68,8 +103,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
title={props.title}
|
||||
aria-label={props["aria-label"]}
|
||||
type="button"
|
||||
onClick={props.onClick}
|
||||
onClick={onClick}
|
||||
ref={innerRef}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{(props.icon || props.label) && (
|
||||
<div className="ToolIcon__icon" aria-hidden="true">
|
||||
@@ -82,7 +118,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
</div>
|
||||
)}
|
||||
{props.showAriaLabel && (
|
||||
<div className="ToolIcon__label">{props["aria-label"]}</div>
|
||||
<div className="ToolIcon__label">
|
||||
{props["aria-label"]} {isLoading && <Spinner />}
|
||||
</div>
|
||||
)}
|
||||
{props.children}
|
||||
</button>
|
||||
@@ -90,7 +128,18 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={clsx("ToolIcon", props.className)} title={props.title}>
|
||||
<label
|
||||
className={clsx("ToolIcon", props.className)}
|
||||
title={props.title}
|
||||
onPointerDown={(event) => {
|
||||
lastPointerTypeRef.current = event.pointerType || null;
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
requestAnimationFrame(() => {
|
||||
lastPointerTypeRef.current = null;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className={`ToolIcon_type_radio ${sizeCn}`}
|
||||
type="radio"
|
||||
@@ -98,8 +147,10 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
aria-label={props["aria-label"]}
|
||||
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
||||
data-testid={props["data-testid"]}
|
||||
id={props.id}
|
||||
onChange={props.onChange}
|
||||
id={`${excalId}-${props.id}`}
|
||||
onChange={() => {
|
||||
props.onChange?.({ pointerType: lastPointerTypeRef.current });
|
||||
}}
|
||||
checked={props.checked}
|
||||
ref={innerRef}
|
||||
/>
|
||||
@@ -116,4 +167,5 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
ToolButton.defaultProps = {
|
||||
visible: true,
|
||||
className: "",
|
||||
size: "medium",
|
||||
};
|
||||
|
||||
@@ -8,10 +8,18 @@
|
||||
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 {
|
||||
@@ -46,15 +54,21 @@
|
||||
}
|
||||
|
||||
.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_s .ToolIcon__icon {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
.ToolIcon_size_small .ToolIcon__icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
@@ -66,14 +80,6 @@
|
||||
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);
|
||||
}
|
||||
@@ -86,6 +92,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-gray-2);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
|
||||
&--show {
|
||||
visibility: visible;
|
||||
}
|
||||
@@ -103,6 +117,9 @@
|
||||
|
||||
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
|
||||
background-color: var(--button-gray-2);
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus + .ToolIcon__icon {
|
||||
@@ -130,12 +147,21 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -166,10 +192,9 @@
|
||||
// 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__lock {
|
||||
.ToolIcon.ToolIcon_type_floating {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: -8px;
|
||||
|
||||
margin-left: 0;
|
||||
@@ -194,6 +219,14 @@
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
.ToolIcon.ToolIcon__library {
|
||||
top: 100px;
|
||||
}
|
||||
|
||||
.ToolIcon.ToolIcon__lock {
|
||||
margin-inline-end: 0;
|
||||
top: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.unlocked-icon {
|
||||
|
||||
+16
-10
@@ -1,4 +1,6 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
// container in body where the actual tooltip is appended to
|
||||
.excalidraw-tooltip {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
@@ -24,16 +26,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
.Tooltip-icon {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
margin-left: 5px;
|
||||
margin-top: 1px;
|
||||
display: flex;
|
||||
// wraps the element we want to apply the tooltip to
|
||||
.excalidraw-tooltip-wrapper {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
display: none;
|
||||
}
|
||||
.excalidraw-tooltip-icon {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
margin-left: 5px;
|
||||
margin-top: 1px;
|
||||
display: flex;
|
||||
|
||||
@include isMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="excalidraw-tooltip-wrapper"
|
||||
onPointerEnter={(event) =>
|
||||
updateTooltip(
|
||||
event.currentTarget as HTMLDivElement,
|
||||
|
||||
+411
-435
File diff suppressed because it is too large
Load Diff
+40
-10
@@ -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,6 +14,7 @@ export const CURSOR_TYPE = {
|
||||
TEXT: "text",
|
||||
CROSSHAIR: "crosshair",
|
||||
GRABBING: "grabbing",
|
||||
GRAB: "grab",
|
||||
POINTER: "pointer",
|
||||
MOVE: "move",
|
||||
AUTO: "",
|
||||
@@ -34,6 +35,7 @@ export enum EVENT {
|
||||
MOUSE_MOVE = "mousemove",
|
||||
RESIZE = "resize",
|
||||
UNLOAD = "unload",
|
||||
FOCUS = "focus",
|
||||
BLUR = "blur",
|
||||
DRAG_OVER = "dragover",
|
||||
DROP = "drop",
|
||||
@@ -63,15 +65,20 @@ export const CLASSES = {
|
||||
|
||||
// 1-based in case we ever do `if(element.fontFamily)`
|
||||
export const FONT_FAMILY = {
|
||||
1: "Virgil",
|
||||
2: "Helvetica",
|
||||
3: "Cascadia",
|
||||
} as const;
|
||||
Virgil: 1,
|
||||
Helvetica: 2,
|
||||
Cascadia: 3,
|
||||
};
|
||||
|
||||
export const THEME = {
|
||||
LIGHT: "light",
|
||||
DARK: "dark",
|
||||
};
|
||||
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||
export const DEFAULT_VERSION = "{version}";
|
||||
@@ -83,7 +90,13 @@ 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",
|
||||
@@ -98,6 +111,7 @@ export const STORAGE_KEYS = {
|
||||
} 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;
|
||||
@@ -131,11 +145,11 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
canvasActions: {
|
||||
changeViewBackgroundColor: true,
|
||||
clearCanvas: true,
|
||||
export: true,
|
||||
export: { saveFileToDisk: true },
|
||||
loadScene: true,
|
||||
saveAsScene: true,
|
||||
saveScene: true,
|
||||
saveToActiveFile: true,
|
||||
theme: true,
|
||||
saveAsImage: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -144,3 +158,19 @@ 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";
|
||||
|
||||
+23
-26
@@ -51,11 +51,12 @@
|
||||
image-rendering: -moz-crisp-edges; // FF
|
||||
|
||||
z-index: var(--zIndex-canvas);
|
||||
}
|
||||
|
||||
#canvas {
|
||||
// Remove the main canvas from document flow to avoid resizeObserver
|
||||
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
|
||||
}
|
||||
|
||||
&__canvas {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@@ -413,22 +414,6 @@
|
||||
&:active {
|
||||
background-color: var(--button-gray-2);
|
||||
}
|
||||
|
||||
&.dropdown-select--floating {
|
||||
margin: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-select__language.dropdown-select--floating {
|
||||
bottom: 10px;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 44px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.zIndexButton {
|
||||
@@ -455,26 +440,38 @@
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
fill: $oc-gray-6;
|
||||
width: 1.5rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-top: 5px;
|
||||
background: none;
|
||||
color: var(--icon-fill-color);
|
||||
|
||||
svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
margin-right: 14px;
|
||||
}
|
||||
.reset-zoom-button {
|
||||
padding: 0.2em;
|
||||
background: transparent;
|
||||
color: var(--text-primary-color);
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
margin-left: 14px;
|
||||
}
|
||||
.undo-redo-buttons {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: 0.4em;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-inline-start: 0.6em;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
|
||||
+153
-13
@@ -1,10 +1,17 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { EXPORT_DATA_TYPES } from "../constants";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
EXPORT_DATA_TYPES,
|
||||
MIME_TYPES,
|
||||
} from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement, FileId } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { AppState, DataURL } from "../types";
|
||||
import { FileSystemHandle } from "./filesystem";
|
||||
import { isValidExcalidrawData } from "./json";
|
||||
import { restore } from "./restore";
|
||||
import { ImportedLibraryData } from "./types";
|
||||
@@ -12,16 +19,22 @@ import { ImportedLibraryData } from "./types";
|
||||
const parseFileContents = async (blob: Blob | File) => {
|
||||
let contents: string;
|
||||
|
||||
if (blob.type === "image/png") {
|
||||
if (blob.type === MIME_TYPES.png) {
|
||||
try {
|
||||
return await (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
).decodePngMetadata(blob);
|
||||
} catch (error) {
|
||||
if (error.message === "INVALID") {
|
||||
throw new Error(t("alerts.imageDoesNotContainScene"));
|
||||
throw new DOMException(
|
||||
t("alerts.imageDoesNotContainScene"),
|
||||
"EncodingError",
|
||||
);
|
||||
} else {
|
||||
throw new Error(t("alerts.cannotRestoreFromImage"));
|
||||
throw new DOMException(
|
||||
t("alerts.cannotRestoreFromImage"),
|
||||
"EncodingError",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -38,7 +51,7 @@ const parseFileContents = async (blob: Blob | File) => {
|
||||
};
|
||||
});
|
||||
}
|
||||
if (blob.type === "image/svg+xml") {
|
||||
if (blob.type === MIME_TYPES.svg) {
|
||||
try {
|
||||
return await (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
@@ -47,9 +60,15 @@ const parseFileContents = async (blob: Blob | File) => {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.message === "INVALID") {
|
||||
throw new Error(t("alerts.imageDoesNotContainScene"));
|
||||
throw new DOMException(
|
||||
t("alerts.imageDoesNotContainScene"),
|
||||
"EncodingError",
|
||||
);
|
||||
} else {
|
||||
throw new Error(t("alerts.cannotRestoreFromImage"));
|
||||
throw new DOMException(
|
||||
t("alerts.cannotRestoreFromImage"),
|
||||
"EncodingError",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,21 +87,50 @@ export const getMimeType = (blob: Blob | string): string => {
|
||||
name = blob.name || "";
|
||||
}
|
||||
if (/\.(excalidraw|json)$/.test(name)) {
|
||||
return "application/json";
|
||||
return MIME_TYPES.json;
|
||||
} else if (/\.png$/.test(name)) {
|
||||
return "image/png";
|
||||
return MIME_TYPES.png;
|
||||
} else if (/\.jpe?g$/.test(name)) {
|
||||
return "image/jpeg";
|
||||
return MIME_TYPES.jpg;
|
||||
} else if (/\.svg$/.test(name)) {
|
||||
return "image/svg+xml";
|
||||
return MIME_TYPES.svg;
|
||||
}
|
||||
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 {
|
||||
@@ -95,14 +143,16 @@ export const loadFromBlob = async (
|
||||
elements: clearElementsForExport(data.elements || []),
|
||||
appState: {
|
||||
theme: localAppState?.theme,
|
||||
fileHandle: (!blob.type.startsWith("image/") && blob.handle) || null,
|
||||
fileHandle: blob.handle || null,
|
||||
...cleanAppStateForExport(data.appState || {}),
|
||||
...(localAppState
|
||||
? calculateScrollCenter(data.elements || [], localAppState, null)
|
||||
: {}),
|
||||
},
|
||||
files: data.files,
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
);
|
||||
|
||||
return result;
|
||||
@@ -142,3 +192,93 @@ 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 };
|
||||
};
|
||||
|
||||
+267
-4
@@ -1,16 +1,19 @@
|
||||
import { deflate, inflate } from "pako";
|
||||
import { encryptData, decryptData } from "./encryption";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// byte (binary) strings
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// fast, Buffer-compatible implem
|
||||
export const toByteString = (data: string | Uint8Array): Promise<string> => {
|
||||
export const toByteString = (
|
||||
data: string | Uint8Array | ArrayBuffer,
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blob =
|
||||
typeof data === "string"
|
||||
? new Blob([new TextEncoder().encode(data)])
|
||||
: new Blob([data]);
|
||||
: new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (!event.target || typeof event.target.result !== "string") {
|
||||
@@ -44,12 +47,14 @@ const byteStringToString = (byteString: string) => {
|
||||
* due to reencoding
|
||||
*/
|
||||
export const stringToBase64 = async (str: string, isByteString = false) => {
|
||||
return isByteString ? btoa(str) : btoa(await toByteString(str));
|
||||
return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
|
||||
};
|
||||
|
||||
// async to align with stringToBase64
|
||||
export const base64ToString = async (base64: string, isByteString = false) => {
|
||||
return isByteString ? atob(base64) : byteStringToString(atob(base64));
|
||||
return isByteString
|
||||
? window.atob(base64)
|
||||
: byteStringToString(window.atob(base64));
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -114,3 +119,261 @@ 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;
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
export const IV_LENGTH_BYTES = 12;
|
||||
|
||||
export const createIV = () => {
|
||||
const arr = new Uint8Array(IV_LENGTH_BYTES);
|
||||
return window.crypto.getRandomValues(arr);
|
||||
};
|
||||
|
||||
export const generateEncryptionKey = async () => {
|
||||
const key = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: 128,
|
||||
},
|
||||
true, // extractable
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
return (await window.crypto.subtle.exportKey("jwk", key)).k;
|
||||
};
|
||||
|
||||
export const getImportedKey = (key: string, usage: KeyUsage) =>
|
||||
window.crypto.subtle.importKey(
|
||||
"jwk",
|
||||
{
|
||||
alg: "A128GCM",
|
||||
ext: true,
|
||||
k: key,
|
||||
key_ops: ["encrypt", "decrypt"],
|
||||
kty: "oct",
|
||||
},
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: 128,
|
||||
},
|
||||
false, // extractable
|
||||
[usage],
|
||||
);
|
||||
|
||||
export const encryptData = async (
|
||||
key: string,
|
||||
data: Uint8Array | ArrayBuffer | Blob | File | string,
|
||||
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
|
||||
const importedKey = await getImportedKey(key, "encrypt");
|
||||
const iv = createIV();
|
||||
const buffer: ArrayBuffer | Uint8Array =
|
||||
typeof data === "string"
|
||||
? new TextEncoder().encode(data)
|
||||
: data instanceof Uint8Array
|
||||
? data
|
||||
: data instanceof Blob
|
||||
? await data.arrayBuffer()
|
||||
: data;
|
||||
|
||||
const encryptedBuffer = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv,
|
||||
},
|
||||
importedKey,
|
||||
buffer as ArrayBuffer | Uint8Array,
|
||||
);
|
||||
|
||||
return { encryptedBuffer, iv };
|
||||
};
|
||||
|
||||
export const decryptData = async (
|
||||
iv: Uint8Array,
|
||||
encrypted: Uint8Array | ArrayBuffer,
|
||||
privateKey: string,
|
||||
): Promise<ArrayBuffer> => {
|
||||
const key = await getImportedKey(privateKey, "decrypt");
|
||||
return window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv,
|
||||
},
|
||||
key,
|
||||
encrypted,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
FileWithHandle,
|
||||
fileOpen as _fileOpen,
|
||||
fileSave as _fileSave,
|
||||
FileSystemHandle,
|
||||
supported as nativeFileSystemSupported,
|
||||
} from "@dwelle/browser-fs-access";
|
||||
import { EVENT, MIME_TYPES } from "../constants";
|
||||
import { AbortError } from "../errors";
|
||||
import { debounce } from "../utils";
|
||||
|
||||
type FILE_EXTENSION =
|
||||
| "gif"
|
||||
| "jpg"
|
||||
| "png"
|
||||
| "svg"
|
||||
| "json"
|
||||
| "excalidraw"
|
||||
| "excalidrawlib";
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||
|
||||
export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
extensions?: FILE_EXTENSION[];
|
||||
description?: string;
|
||||
multiple?: M;
|
||||
}): Promise<
|
||||
M extends false | undefined ? FileWithHandle : FileWithHandle[]
|
||||
> => {
|
||||
// an unsafe TS hack, alas not much we can do AFAIK
|
||||
type RetType = M extends false | undefined
|
||||
? FileWithHandle
|
||||
: FileWithHandle[];
|
||||
|
||||
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
||||
mimeTypes.push(MIME_TYPES[type]);
|
||||
|
||||
return mimeTypes;
|
||||
}, [] as string[]);
|
||||
|
||||
const extensions = opts.extensions?.reduce((acc, ext) => {
|
||||
if (ext === "jpg") {
|
||||
return acc.concat(".jpg", ".jpeg");
|
||||
}
|
||||
return acc.concat(`.${ext}`);
|
||||
}, [] as string[]);
|
||||
|
||||
return _fileOpen({
|
||||
description: opts.description,
|
||||
extensions,
|
||||
mimeTypes,
|
||||
multiple: opts.multiple ?? false,
|
||||
legacySetup: (resolve, reject, input) => {
|
||||
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||
const focusHandler = () => {
|
||||
checkForFile();
|
||||
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
||||
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||
scheduleRejection();
|
||||
};
|
||||
const checkForFile = () => {
|
||||
// this hack might not work when expecting multiple files
|
||||
if (input.files?.length) {
|
||||
const ret = opts.multiple ? [...input.files] : input.files[0];
|
||||
resolve(ret as RetType);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
window.addEventListener(EVENT.FOCUS, focusHandler);
|
||||
});
|
||||
const interval = window.setInterval(() => {
|
||||
checkForFile();
|
||||
}, INPUT_CHANGE_INTERVAL_MS);
|
||||
return (rejectPromise) => {
|
||||
clearInterval(interval);
|
||||
scheduleRejection.cancel();
|
||||
window.removeEventListener(EVENT.FOCUS, focusHandler);
|
||||
document.removeEventListener(EVENT.KEYUP, scheduleRejection);
|
||||
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||
if (rejectPromise) {
|
||||
// so that something is shown in console if we need to debug this
|
||||
console.warn("Opening the file was canceled (legacy-fs).");
|
||||
rejectPromise(new AbortError());
|
||||
}
|
||||
};
|
||||
},
|
||||
}) as Promise<RetType>;
|
||||
};
|
||||
|
||||
export const fileSave = (
|
||||
blob: Blob,
|
||||
opts: {
|
||||
/** supply without the extension */
|
||||
name: string;
|
||||
/** file extension */
|
||||
extension: FILE_EXTENSION;
|
||||
description?: string;
|
||||
/** existing FileSystemHandle */
|
||||
fileHandle?: FileSystemHandle | null;
|
||||
},
|
||||
) => {
|
||||
return _fileSave(
|
||||
blob,
|
||||
{
|
||||
fileName: `${opts.name}.${opts.extension}`,
|
||||
description: opts.description,
|
||||
extensions: [`.${opts.extension}`],
|
||||
},
|
||||
opts.fileHandle,
|
||||
);
|
||||
};
|
||||
|
||||
export type { FileSystemHandle };
|
||||
export { nativeFileSystemSupported };
|
||||
+1
-1
@@ -57,7 +57,7 @@ export const encodePngMetadata = async ({
|
||||
// insert metadata before last chunk (iEND)
|
||||
chunks.splice(-1, 0, metadataChunk);
|
||||
|
||||
return new Blob([encodePng(chunks)], { type: "image/png" });
|
||||
return new Blob([encodePng(chunks)], { type: MIME_TYPES.png });
|
||||
};
|
||||
|
||||
export const decodePngMetadata = async (blob: Blob) => {
|
||||
|
||||
+34
-37
@@ -1,14 +1,15 @@
|
||||
import { fileSave } from "browser-fs-access";
|
||||
import {
|
||||
copyBlobToClipboardAsPng,
|
||||
copyTextToSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { DEFAULT_EXPORT_PADDING, MIME_TYPES } from "../constants";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppState } from "../types";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { canvasToBlob } from "./blob";
|
||||
import { fileSave, FileSystemHandle } from "./filesystem";
|
||||
import { serializeAsJSON } from "./json";
|
||||
|
||||
export { loadFromBlob } from "./blob";
|
||||
@@ -18,60 +19,56 @@ export const exportCanvas = async (
|
||||
type: ExportType,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
{
|
||||
exportBackground,
|
||||
exportPadding = 10,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
viewBackgroundColor,
|
||||
name,
|
||||
scale = 1,
|
||||
shouldAddWatermark,
|
||||
fileHandle = null,
|
||||
}: {
|
||||
exportBackground: boolean;
|
||||
exportPadding?: number;
|
||||
viewBackgroundColor: string;
|
||||
name: string;
|
||||
scale?: number;
|
||||
shouldAddWatermark: boolean;
|
||||
fileHandle?: FileSystemHandle | null;
|
||||
},
|
||||
) => {
|
||||
if (elements.length === 0) {
|
||||
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||
}
|
||||
if (type === "svg" || type === "clipboard-svg") {
|
||||
const tempSvg = exportToSvg(elements, {
|
||||
exportBackground,
|
||||
exportWithDarkMode: appState.exportWithDarkMode,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
scale,
|
||||
shouldAddWatermark,
|
||||
metadata:
|
||||
appState.exportEmbedScene && type === "svg"
|
||||
? await (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
).encodeSvgMetadata({
|
||||
text: serializeAsJSON(elements, appState),
|
||||
})
|
||||
: undefined,
|
||||
});
|
||||
const tempSvg = await exportToSvg(
|
||||
elements,
|
||||
{
|
||||
exportBackground,
|
||||
exportWithDarkMode: appState.exportWithDarkMode,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
exportScale: appState.exportScale,
|
||||
exportEmbedScene: appState.exportEmbedScene && type === "svg",
|
||||
},
|
||||
files,
|
||||
);
|
||||
if (type === "svg") {
|
||||
await fileSave(new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }), {
|
||||
fileName: `${name}.svg`,
|
||||
extensions: [".svg"],
|
||||
});
|
||||
return;
|
||||
return await fileSave(
|
||||
new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }),
|
||||
{
|
||||
name,
|
||||
extension: "svg",
|
||||
fileHandle,
|
||||
},
|
||||
);
|
||||
} else if (type === "clipboard-svg") {
|
||||
copyTextToSystemClipboard(tempSvg.outerHTML);
|
||||
await copyTextToSystemClipboard(tempSvg.outerHTML);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const tempCanvas = exportToCanvas(elements, appState, {
|
||||
const tempCanvas = await exportToCanvas(elements, appState, files, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
scale,
|
||||
shouldAddWatermark,
|
||||
});
|
||||
tempCanvas.style.display = "none";
|
||||
document.body.appendChild(tempCanvas);
|
||||
@@ -79,19 +76,19 @@ export const exportCanvas = async (
|
||||
tempCanvas.remove();
|
||||
|
||||
if (type === "png") {
|
||||
const fileName = `${name}.png`;
|
||||
if (appState.exportEmbedScene) {
|
||||
blob = await (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
).encodePngMetadata({
|
||||
blob,
|
||||
metadata: serializeAsJSON(elements, appState),
|
||||
metadata: serializeAsJSON(elements, appState, files, "local"),
|
||||
});
|
||||
}
|
||||
|
||||
await fileSave(blob, {
|
||||
fileName,
|
||||
extensions: [".png"],
|
||||
return await fileSave(blob, {
|
||||
name,
|
||||
extension: "png",
|
||||
fileHandle,
|
||||
});
|
||||
} else if (type === "clipboard") {
|
||||
try {
|
||||
|
||||
+68
-38
@@ -1,10 +1,10 @@
|
||||
import { fileOpen, fileSave } from "browser-fs-access";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { fileOpen, fileSave } from "./filesystem";
|
||||
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
|
||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { clearElementsForDatabase, clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { loadFromBlob } from "./blob";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { isImageFileHandle, loadFromBlob } from "./blob";
|
||||
|
||||
import {
|
||||
ExportedDataState,
|
||||
@@ -13,16 +13,50 @@ import {
|
||||
} from "./types";
|
||||
import Library from "./library";
|
||||
|
||||
/**
|
||||
* Strips out files which are only referenced by deleted elements
|
||||
*/
|
||||
const filterOutDeletedFiles = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
const nextFiles: BinaryFiles = {};
|
||||
for (const element of elements) {
|
||||
if (
|
||||
!element.isDeleted &&
|
||||
"fileId" in element &&
|
||||
element.fileId &&
|
||||
files[element.fileId]
|
||||
) {
|
||||
nextFiles[element.fileId] = files[element.fileId];
|
||||
}
|
||||
}
|
||||
return nextFiles;
|
||||
};
|
||||
|
||||
export const serializeAsJSON = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
appState: Partial<AppState>,
|
||||
files: BinaryFiles,
|
||||
type: "local" | "database",
|
||||
): string => {
|
||||
const data: ExportedDataState = {
|
||||
type: EXPORT_DATA_TYPES.excalidraw,
|
||||
version: 2,
|
||||
source: EXPORT_SOURCE,
|
||||
elements: clearElementsForExport(elements),
|
||||
appState: cleanAppStateForExport(appState),
|
||||
elements:
|
||||
type === "local"
|
||||
? clearElementsForExport(elements)
|
||||
: clearElementsForDatabase(elements),
|
||||
appState:
|
||||
type === "local"
|
||||
? cleanAppStateForExport(appState)
|
||||
: clearAppStateForDatabase(appState),
|
||||
files:
|
||||
type === "local"
|
||||
? filterOutDeletedFiles(elements, files)
|
||||
: // will be stripped from JSON
|
||||
undefined,
|
||||
};
|
||||
|
||||
return JSON.stringify(data, null, 2);
|
||||
@@ -31,40 +65,35 @@ export const serializeAsJSON = (
|
||||
export const saveAsJSON = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
const serialized = serializeAsJSON(elements, appState);
|
||||
const serialized = serializeAsJSON(elements, appState, files, "local");
|
||||
const blob = new Blob([serialized], {
|
||||
type: MIME_TYPES.excalidraw,
|
||||
});
|
||||
|
||||
const fileHandle = await fileSave(
|
||||
blob,
|
||||
{
|
||||
fileName: `${appState.name}.excalidraw`,
|
||||
description: "Excalidraw file",
|
||||
extensions: [".excalidraw"],
|
||||
},
|
||||
appState.fileHandle,
|
||||
);
|
||||
const fileHandle = await fileSave(blob, {
|
||||
name: appState.name,
|
||||
extension: "excalidraw",
|
||||
description: "Excalidraw file",
|
||||
fileHandle: isImageFileHandle(appState.fileHandle)
|
||||
? null
|
||||
: appState.fileHandle,
|
||||
});
|
||||
return { fileHandle };
|
||||
};
|
||||
|
||||
export const loadFromJSON = async (localAppState: AppState) => {
|
||||
export const loadFromJSON = async (
|
||||
localAppState: AppState,
|
||||
localElements: readonly ExcalidrawElement[] | null,
|
||||
) => {
|
||||
const blob = await fileOpen({
|
||||
description: "Excalidraw files",
|
||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||
/*
|
||||
extensions: [".json", ".excalidraw", ".png", ".svg"],
|
||||
mimeTypes: [
|
||||
MIME_TYPES.excalidraw,
|
||||
"application/json",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
],
|
||||
*/
|
||||
// extensions: ["json", "excalidraw", "png", "svg"],
|
||||
});
|
||||
return loadFromBlob(blob, localAppState);
|
||||
return loadFromBlob(blob, localAppState, localElements);
|
||||
};
|
||||
|
||||
export const isValidExcalidrawData = (data?: {
|
||||
@@ -98,15 +127,16 @@ export const saveLibraryAsJSON = async (library: Library) => {
|
||||
library: libraryItems,
|
||||
};
|
||||
const serialized = JSON.stringify(data, null, 2);
|
||||
const fileName = "library.excalidrawlib";
|
||||
const blob = new Blob([serialized], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
});
|
||||
await fileSave(blob, {
|
||||
fileName,
|
||||
description: "Excalidraw library file",
|
||||
extensions: [".excalidrawlib"],
|
||||
});
|
||||
await fileSave(
|
||||
new Blob([serialized], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
}),
|
||||
{
|
||||
name: "library",
|
||||
extension: "excalidrawlib",
|
||||
description: "Excalidraw library file",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const importLibraryFromJSON = async (library: Library) => {
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { loadLibraryFromBlob } from "./blob";
|
||||
import { LibraryItems, LibraryItem } from "../types";
|
||||
import { restoreElements } from "./restore";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import App from "../components/App";
|
||||
import type App from "../components/App";
|
||||
|
||||
class Library {
|
||||
private libraryCache: LibraryItems | null = null;
|
||||
@@ -18,7 +18,7 @@ class Library {
|
||||
};
|
||||
|
||||
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
|
||||
const elements = getNonDeletedElements(restoreElements(libraryItem));
|
||||
const elements = getNonDeletedElements(restoreElements(libraryItem, null));
|
||||
return elements.length ? elements : null;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { exportCanvas } from ".";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { getFileHandleType, isImageFileHandleType } from "./blob";
|
||||
|
||||
export const resaveAsImageWithScene = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
const { exportBackground, viewBackgroundColor, name, fileHandle } = appState;
|
||||
|
||||
const fileHandleType = getFileHandleType(fileHandle);
|
||||
|
||||
if (!fileHandle || !isImageFileHandleType(fileHandleType)) {
|
||||
throw new Error(
|
||||
"fileHandle should exist and should be of type svg or png when resaving",
|
||||
);
|
||||
}
|
||||
appState = {
|
||||
...appState,
|
||||
exportEmbedScene: true,
|
||||
};
|
||||
|
||||
await exportCanvas(
|
||||
fileHandleType,
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
files,
|
||||
{
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
name,
|
||||
fileHandle,
|
||||
},
|
||||
);
|
||||
|
||||
return { fileHandle };
|
||||
};
|
||||
+51
-26
@@ -1,21 +1,26 @@
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FontFamily,
|
||||
ExcalidrawSelectionElement,
|
||||
FontFamilyValues,
|
||||
} from "../element/types";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { AppState, BinaryFiles, NormalizedZoomValue } from "../types";
|
||||
import { ImportedDataState } from "./types";
|
||||
import { isInvisiblySmallElement, getNormalizedDimensions } from "../element";
|
||||
import {
|
||||
getElementMap,
|
||||
getNormalizedDimensions,
|
||||
isInvisiblySmallElement,
|
||||
} from "../element";
|
||||
import { isLinearElementType } from "../element/typeChecks";
|
||||
import { randomId } from "../random";
|
||||
import {
|
||||
FONT_FAMILY,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
FONT_FAMILY,
|
||||
} from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { bumpVersion } from "../element/mutateElement";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
@@ -32,6 +37,7 @@ export const AllowedExcalidrawElementTypes: Record<
|
||||
diamond: true,
|
||||
ellipse: true,
|
||||
line: true,
|
||||
image: true,
|
||||
arrow: true,
|
||||
freedraw: true,
|
||||
};
|
||||
@@ -39,29 +45,33 @@ export const AllowedExcalidrawElementTypes: Record<
|
||||
export type RestoredDataState = {
|
||||
elements: ExcalidrawElement[];
|
||||
appState: RestoredAppState;
|
||||
files: BinaryFiles;
|
||||
};
|
||||
|
||||
const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
|
||||
for (const [id, fontFamilyString] of Object.entries(FONT_FAMILY)) {
|
||||
if (fontFamilyString.includes(fontFamilyName)) {
|
||||
return parseInt(id) as FontFamily;
|
||||
}
|
||||
const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
||||
if (Object.keys(FONT_FAMILY).includes(fontFamilyName)) {
|
||||
return FONT_FAMILY[
|
||||
fontFamilyName as keyof typeof FONT_FAMILY
|
||||
] as FontFamilyValues;
|
||||
}
|
||||
return DEFAULT_FONT_FAMILY;
|
||||
};
|
||||
|
||||
const restoreElementWithProperties = <
|
||||
T extends ExcalidrawElement,
|
||||
K extends keyof Omit<
|
||||
Required<T>,
|
||||
Exclude<keyof ExcalidrawElement, "type" | "x" | "y">
|
||||
>
|
||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>
|
||||
>(
|
||||
element: Required<T>,
|
||||
extra: Pick<T, K>,
|
||||
extra: Pick<
|
||||
T,
|
||||
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
||||
// @ts-ignore TS complains here but type checks the call sites fine.
|
||||
keyof K
|
||||
> &
|
||||
Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
|
||||
): T => {
|
||||
const base: Pick<T, keyof ExcalidrawElement> = {
|
||||
type: (extra as Partial<T>).type || element.type,
|
||||
type: extra.type || element.type,
|
||||
// all elements must have version > 0 so getSceneVersion() will pick up
|
||||
// newly added elements
|
||||
version: element.version || 1,
|
||||
@@ -74,8 +84,8 @@ const restoreElementWithProperties = <
|
||||
roughness: element.roughness ?? 1,
|
||||
opacity: element.opacity == null ? 100 : element.opacity,
|
||||
angle: element.angle || 0,
|
||||
x: (extra as Partial<T>).x ?? element.x ?? 0,
|
||||
y: (extra as Partial<T>).y ?? element.y ?? 0,
|
||||
x: extra.x ?? element.x ?? 0,
|
||||
y: extra.y ?? element.y ?? 0,
|
||||
strokeColor: element.strokeColor,
|
||||
backgroundColor: element.backgroundColor,
|
||||
width: element.width || 0,
|
||||
@@ -97,7 +107,7 @@ const restoreElementWithProperties = <
|
||||
|
||||
const restoreElement = (
|
||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
): typeof element => {
|
||||
): typeof element | null => {
|
||||
switch (element.type) {
|
||||
case "text":
|
||||
let fontSize = element.fontSize;
|
||||
@@ -126,6 +136,12 @@ const restoreElement = (
|
||||
pressures: element.pressures,
|
||||
});
|
||||
}
|
||||
case "image":
|
||||
return restoreElementWithProperties(element, {
|
||||
status: element.status || "pending",
|
||||
fileId: element.fileId,
|
||||
scale: element.scale || [1, 1],
|
||||
});
|
||||
case "line":
|
||||
// @ts-ignore LEGACY type
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
@@ -181,13 +197,20 @@ const restoreElement = (
|
||||
|
||||
export const restoreElements = (
|
||||
elements: ImportedDataState["elements"],
|
||||
/** NOTE doesn't serve for reconciliation */
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
): ExcalidrawElement[] => {
|
||||
const localElementsMap = localElements ? getElementMap(localElements) : null;
|
||||
return (elements || []).reduce((elements, element) => {
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
// and causing issues if retained
|
||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||
const migratedElement = restoreElement(element);
|
||||
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
||||
if (migratedElement) {
|
||||
const localElement = localElementsMap?.[element.id];
|
||||
if (localElement && localElement.version > migratedElement.version) {
|
||||
migratedElement = bumpVersion(migratedElement, localElement.version);
|
||||
}
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
}
|
||||
@@ -197,25 +220,25 @@ export const restoreElements = (
|
||||
|
||||
export const restoreAppState = (
|
||||
appState: ImportedDataState["appState"],
|
||||
localAppState: Partial<AppState> | null,
|
||||
localAppState: Partial<AppState> | null | undefined,
|
||||
): RestoredAppState => {
|
||||
appState = appState || {};
|
||||
|
||||
const defaultAppState = getDefaultAppState();
|
||||
const nextAppState = {} as typeof defaultAppState;
|
||||
|
||||
for (const [key, val] of Object.entries(defaultAppState) as [
|
||||
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
|
||||
keyof typeof defaultAppState,
|
||||
any,
|
||||
][]) {
|
||||
const restoredValue = appState[key];
|
||||
const suppliedValue = appState[key];
|
||||
const localValue = localAppState ? localAppState[key] : undefined;
|
||||
(nextAppState as any)[key] =
|
||||
restoredValue !== undefined
|
||||
? restoredValue
|
||||
suppliedValue !== undefined
|
||||
? suppliedValue
|
||||
: localValue !== undefined
|
||||
? localValue
|
||||
: val;
|
||||
: defaultValue;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -243,9 +266,11 @@ export const restore = (
|
||||
* Supply `null` if you can't get access to it.
|
||||
*/
|
||||
localAppState: Partial<AppState> | null | undefined,
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
): RestoredDataState => {
|
||||
return {
|
||||
elements: restoreElements(data?.elements),
|
||||
elements: restoreElements(data?.elements, localElements),
|
||||
appState: restoreAppState(data?.appState, localAppState || null),
|
||||
files: data?.files || {},
|
||||
};
|
||||
};
|
||||
|
||||
+3
-1
@@ -1,5 +1,5 @@
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, LibraryItems } from "../types";
|
||||
import { AppState, BinaryFiles, LibraryItems } from "../types";
|
||||
import type { cleanAppStateForExport } from "../appState";
|
||||
|
||||
export interface ExportedDataState {
|
||||
@@ -8,6 +8,7 @@ export interface ExportedDataState {
|
||||
source: string;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: ReturnType<typeof cleanAppStateForExport>;
|
||||
files: BinaryFiles | undefined;
|
||||
}
|
||||
|
||||
export interface ImportedDataState {
|
||||
@@ -18,6 +19,7 @@ export interface ImportedDataState {
|
||||
appState?: Readonly<Partial<AppState>> | null;
|
||||
scrollToContent?: boolean;
|
||||
libraryItems?: LibraryItems;
|
||||
files?: BinaryFiles;
|
||||
}
|
||||
|
||||
export interface ExportedLibraryData {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ExcalidrawEllipseElement,
|
||||
NonDeleted,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawImageElement,
|
||||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||
@@ -30,6 +31,7 @@ import { Point } from "../types";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { isImageElement } from "./typeChecks";
|
||||
|
||||
const isElementDraggableFromInside = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
@@ -47,8 +49,7 @@ const isElementDraggableFromInside = (
|
||||
if (element.type === "line") {
|
||||
return isDraggableFromInside && isPathALoop(element.points);
|
||||
}
|
||||
|
||||
return isDraggableFromInside;
|
||||
return isDraggableFromInside || isImageElement(element);
|
||||
};
|
||||
|
||||
export const hitTest = (
|
||||
@@ -161,6 +162,7 @@ type HitTestArgs = {
|
||||
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
switch (args.element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
@@ -195,6 +197,7 @@ export const distanceToBindableElement = (
|
||||
): number => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
return distanceToRectangle(element, point);
|
||||
case "diamond":
|
||||
@@ -224,7 +227,8 @@ const distanceToRectangle = (
|
||||
element:
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement,
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||
@@ -328,15 +332,15 @@ const hitTestFreeDrawElement = (
|
||||
let P: readonly [number, number];
|
||||
|
||||
// For freedraw dots
|
||||
if (element.points.length === 2) {
|
||||
return (
|
||||
distance2d(A[0], A[1], x, y) < threshold ||
|
||||
distance2d(B[0], B[1], x, y) < threshold
|
||||
);
|
||||
if (
|
||||
distance2d(A[0], A[1], x, y) < threshold ||
|
||||
distance2d(B[0], B[1], x, y) < threshold
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For freedraw lines
|
||||
for (let i = 1; i < element.points.length - 1; i++) {
|
||||
for (let i = 0; i < element.points.length; i++) {
|
||||
const delta = [B[0] - A[0], B[1] - A[1]];
|
||||
const length = Math.hypot(delta[1], delta[0]);
|
||||
|
||||
@@ -486,6 +490,7 @@ export const determineFocusDistance = (
|
||||
const nabs = Math.abs(n);
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
return c / (hwidth * (nabs + q * mabs));
|
||||
case "diamond":
|
||||
@@ -516,6 +521,7 @@ export const determineFocusPoint = (
|
||||
let point;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||
@@ -565,6 +571,7 @@ const getSortedElementLineIntersections = (
|
||||
let intersections: GA.Point[];
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
const corners = getCorners(element);
|
||||
@@ -598,6 +605,7 @@ const getSortedElementLineIntersections = (
|
||||
const getCorners = (
|
||||
element:
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
scale: number = 1,
|
||||
@@ -606,6 +614,7 @@ const getCorners = (
|
||||
const hy = (scale * element.height) / 2;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
return [
|
||||
GA.point(hx, hy),
|
||||
@@ -747,6 +756,7 @@ export const findFocusPointForEllipse = (
|
||||
export const findFocusPointForRectangulars = (
|
||||
element:
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
// Between -1 and 1 for how far away should the focus point be relative
|
||||
|
||||
+19
-12
@@ -5,7 +5,7 @@ import { mutateElement } from "./mutateElement";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import Scene from "../scene/Scene";
|
||||
import { NonDeletedExcalidrawElement } from "./types";
|
||||
import { PointerDownState } from "../components/App";
|
||||
import { PointerDownState } from "../types";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
@@ -62,25 +62,32 @@ export const dragNewElement = (
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
isResizeWithSidesSameLength: boolean,
|
||||
isResizeCenterPoint: boolean,
|
||||
shouldMaintainAspectRatio: boolean,
|
||||
shouldResizeFromCenter: boolean,
|
||||
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
||||
true */
|
||||
widthAspectRatio?: number | null,
|
||||
) => {
|
||||
if (isResizeWithSidesSameLength) {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
width,
|
||||
y < originY ? -height : height,
|
||||
));
|
||||
if (shouldMaintainAspectRatio) {
|
||||
if (widthAspectRatio) {
|
||||
height = width / widthAspectRatio;
|
||||
} else {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
width,
|
||||
y < originY ? -height : height,
|
||||
));
|
||||
|
||||
if (height < 0) {
|
||||
height = -height;
|
||||
if (height < 0) {
|
||||
height = -height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let newX = x < originX ? originX - width : originX;
|
||||
let newY = y < originY ? originY - height : originY;
|
||||
|
||||
if (isResizeCenterPoint) {
|
||||
if (shouldResizeFromCenter) {
|
||||
width += width;
|
||||
height += height;
|
||||
newX = originX - width / 2;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExcalidrawImageElement & related helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { MIME_TYPES, SVG_NS } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { AppClassProperties, DataURL, BinaryFiles } from "../types";
|
||||
import { isInitializedImageElement } from "./typeChecks";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
InitializedExcalidrawImageElement,
|
||||
} from "./types";
|
||||
|
||||
export const loadHTMLImageElement = (dataURL: DataURL) => {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = (error) => {
|
||||
reject(error);
|
||||
};
|
||||
image.src = dataURL;
|
||||
});
|
||||
};
|
||||
|
||||
/** NOTE: updates cache even if already populated with given image. Thus,
|
||||
* you should filter out the images upstream if you want to optimize this. */
|
||||
export const updateImageCache = async ({
|
||||
fileIds,
|
||||
files,
|
||||
imageCache,
|
||||
}: {
|
||||
fileIds: FileId[];
|
||||
files: BinaryFiles;
|
||||
imageCache: AppClassProperties["imageCache"];
|
||||
}) => {
|
||||
const updatedFiles = new Map<FileId, true>();
|
||||
const erroredFiles = new Map<FileId, true>();
|
||||
|
||||
await Promise.all(
|
||||
fileIds.reduce((promises, fileId) => {
|
||||
const fileData = files[fileId as string];
|
||||
if (fileData && !updatedFiles.has(fileId)) {
|
||||
updatedFiles.set(fileId, true);
|
||||
return promises.concat(
|
||||
(async () => {
|
||||
try {
|
||||
if (fileData.mimeType === MIME_TYPES.binary) {
|
||||
throw new Error("Only images can be added to ImageCache");
|
||||
}
|
||||
|
||||
const imagePromise = loadHTMLImageElement(fileData.dataURL);
|
||||
const data = {
|
||||
image: imagePromise,
|
||||
mimeType: fileData.mimeType,
|
||||
} as const;
|
||||
// store the promise immediately to indicate there's an in-progress
|
||||
// initialization
|
||||
imageCache.set(fileId, data);
|
||||
|
||||
const image = await imagePromise;
|
||||
|
||||
imageCache.set(fileId, { ...data, image });
|
||||
} catch (error) {
|
||||
erroredFiles.set(fileId, true);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
return promises;
|
||||
}, [] as Promise<any>[]),
|
||||
);
|
||||
|
||||
return {
|
||||
imageCache,
|
||||
/** includes errored files because they cache was updated nonetheless */
|
||||
updatedFiles,
|
||||
/** files that failed when creating HTMLImageElement */
|
||||
erroredFiles,
|
||||
};
|
||||
};
|
||||
|
||||
export const getInitializedImageElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) =>
|
||||
elements.filter((element) =>
|
||||
isInitializedImageElement(element),
|
||||
) as InitializedExcalidrawImageElement[];
|
||||
|
||||
export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
|
||||
// lower-casing due to XML/HTML convention differences
|
||||
// https://johnresig.com/blog/nodename-case-sensitivity
|
||||
return node?.nodeName.toLowerCase() === "svg";
|
||||
};
|
||||
|
||||
export const normalizeSVG = async (SVGString: string) => {
|
||||
const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
|
||||
const svg = doc.querySelector("svg");
|
||||
const errorNode = doc.querySelector("parsererror");
|
||||
if (errorNode || !isHTMLSVGElement(svg)) {
|
||||
throw new Error(t("errors.invalidSVGString"));
|
||||
} else {
|
||||
if (!svg.hasAttribute("xmlns")) {
|
||||
svg.setAttribute("xmlns", SVG_NS);
|
||||
}
|
||||
|
||||
return svg.outerHTML;
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
newTextElement,
|
||||
updateTextElement,
|
||||
newLinearElement,
|
||||
newImageElement,
|
||||
duplicateElement,
|
||||
} from "./newElement";
|
||||
export {
|
||||
@@ -93,6 +94,10 @@ const _clearElements = (
|
||||
: element,
|
||||
);
|
||||
|
||||
export const clearElementsForDatabase = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export const clearElementsForExport = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
@@ -17,12 +17,13 @@ type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
) => {
|
||||
informMutation = true,
|
||||
): TElement => {
|
||||
let didChange = false;
|
||||
|
||||
// casting to any because can't use `in` operator
|
||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||
const { points } = updates as any;
|
||||
const { points, fileId } = updates as any;
|
||||
|
||||
if (typeof points !== "undefined") {
|
||||
updates = { ...getSizeFromPoints(points), ...updates };
|
||||
@@ -33,13 +34,23 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
if (typeof value !== "undefined") {
|
||||
if (
|
||||
(element as any)[key] === value &&
|
||||
// if object, always update in case its deep prop was mutated
|
||||
(typeof value !== "object" || value === null || key === "groupIds")
|
||||
// if object, always update because its attrs could have changed
|
||||
// (except for specific keys we handle below)
|
||||
(typeof value !== "object" ||
|
||||
value === null ||
|
||||
key === "groupIds" ||
|
||||
key === "scale")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "points") {
|
||||
if (key === "scale") {
|
||||
const prevScale = (element as any)[key];
|
||||
const nextScale = value;
|
||||
if (prevScale[0] === nextScale[0] && prevScale[1] === nextScale[1]) {
|
||||
continue;
|
||||
}
|
||||
} else if (key === "points") {
|
||||
const prevPoints = (element as any)[key];
|
||||
const nextPoints = value;
|
||||
if (prevPoints.length === nextPoints.length) {
|
||||
@@ -66,14 +77,14 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
didChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!didChange) {
|
||||
return;
|
||||
return element;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updates.height !== "undefined" ||
|
||||
typeof updates.width !== "undefined" ||
|
||||
typeof fileId != "undefined" ||
|
||||
typeof points !== "undefined"
|
||||
) {
|
||||
invalidateShapeForElement(element);
|
||||
@@ -81,7 +92,12 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
|
||||
element.version++;
|
||||
element.versionNonce = randomInteger();
|
||||
Scene.getScene(element)?.informMutation();
|
||||
|
||||
if (informMutation) {
|
||||
Scene.getScene(element)?.informMutation();
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
@@ -94,8 +110,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
if (typeof value !== "undefined") {
|
||||
if (
|
||||
(element as any)[key] === value &&
|
||||
// if object, always update in case its deep prop was mutated
|
||||
(typeof value !== "object" || value === null || key === "groupIds")
|
||||
// if object, always update because its attrs could have changed
|
||||
(typeof value !== "object" || value === null)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -120,8 +136,11 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
*
|
||||
* NOTE: does not trigger re-render.
|
||||
*/
|
||||
export const bumpVersion = (element: Mutable<ExcalidrawElement>) => {
|
||||
element.version = element.version + 1;
|
||||
export const bumpVersion = (
|
||||
element: Mutable<ExcalidrawElement>,
|
||||
version?: ExcalidrawElement["version"],
|
||||
) => {
|
||||
element.version = (version ?? element.version) + 1;
|
||||
element.versionNonce = randomInteger();
|
||||
return element;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { duplicateElement } from "./newElement";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { FONT_FAMILY } from "../constants";
|
||||
|
||||
const isPrimitive = (val: any) => {
|
||||
const type = typeof val;
|
||||
@@ -79,7 +80,7 @@ it("clones text element", () => {
|
||||
opacity: 100,
|
||||
text: "hello",
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
fontFamily: FONT_FAMILY.Virgil,
|
||||
textAlign: "left",
|
||||
verticalAlign: "top",
|
||||
});
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawGenericElement,
|
||||
NonDeleted,
|
||||
TextAlign,
|
||||
FontFamily,
|
||||
GroupId,
|
||||
VerticalAlign,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
FontFamilyValues,
|
||||
} from "../element/types";
|
||||
import { measureText, getFontString } from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
@@ -109,7 +110,7 @@ export const newTextElement = (
|
||||
opts: {
|
||||
text: string;
|
||||
fontSize: number;
|
||||
fontFamily: FontFamily;
|
||||
fontFamily: FontFamilyValues;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
} & ElementConstructorOpts,
|
||||
@@ -248,6 +249,22 @@ export const newLinearElement = (
|
||||
};
|
||||
};
|
||||
|
||||
export const newImageElement = (
|
||||
opts: {
|
||||
type: ExcalidrawImageElement["type"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawImageElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawImageElement>("image", opts),
|
||||
// in the future we'll support changing stroke color for some SVG elements,
|
||||
// and `transparent` will likely mean "use original colors of the image"
|
||||
strokeColor: "transparent",
|
||||
status: "pending",
|
||||
fileId: null,
|
||||
scale: [1, 1],
|
||||
};
|
||||
};
|
||||
|
||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
|
||||
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
|
||||
//
|
||||
@@ -307,7 +324,19 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
overrides?: Partial<TElement>,
|
||||
): TElement => {
|
||||
let copy: TElement = deepCopyElement(element);
|
||||
copy.id = process.env.NODE_ENV === "test" ? `${copy.id}_copy` : randomId();
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
copy.id = `${copy.id}_copy`;
|
||||
// `window.h` may not be defined in some unit tests
|
||||
if (
|
||||
window.h?.app
|
||||
?.getSceneElementsIncludingDeleted()
|
||||
.find((el) => el.id === copy.id)
|
||||
) {
|
||||
copy.id += "_copy";
|
||||
}
|
||||
} else {
|
||||
copy.id = randomId();
|
||||
}
|
||||
copy.seed = randomInteger();
|
||||
copy.groupIds = getNewGroupIdsForDuplication(
|
||||
copy.groupIds,
|
||||
|
||||
@@ -32,8 +32,7 @@ import {
|
||||
MaybeTransformHandleType,
|
||||
TransformHandleDirection,
|
||||
} from "./transformHandles";
|
||||
import { PointerDownState } from "../components/App";
|
||||
import { Point } from "../types";
|
||||
import { Point, PointerDownState } from "../types";
|
||||
|
||||
export const normalizeAngle = (angle: number): number => {
|
||||
if (angle >= 2 * Math.PI) {
|
||||
@@ -48,9 +47,9 @@ export const transformElements = (
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||
resizeArrowDirection: "origin" | "end",
|
||||
isRotateWithDiscreteAngle: boolean,
|
||||
isResizeCenterPoint: boolean,
|
||||
shouldKeepSidesRatio: boolean,
|
||||
shouldRotateWithDiscreteAngle: boolean,
|
||||
shouldResizeFromCenter: boolean,
|
||||
shouldMaintainAspectRatio: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
centerX: number,
|
||||
@@ -63,7 +62,7 @@ export const transformElements = (
|
||||
element,
|
||||
pointerX,
|
||||
pointerY,
|
||||
isRotateWithDiscreteAngle,
|
||||
shouldRotateWithDiscreteAngle,
|
||||
);
|
||||
updateBoundElements(element);
|
||||
} else if (
|
||||
@@ -77,7 +76,7 @@ export const transformElements = (
|
||||
reshapeSingleTwoPointElement(
|
||||
element,
|
||||
resizeArrowDirection,
|
||||
isRotateWithDiscreteAngle,
|
||||
shouldRotateWithDiscreteAngle,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
@@ -91,7 +90,7 @@ export const transformElements = (
|
||||
resizeSingleTextElement(
|
||||
element,
|
||||
transformHandleType,
|
||||
isResizeCenterPoint,
|
||||
shouldResizeFromCenter,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
@@ -99,10 +98,10 @@ export const transformElements = (
|
||||
} else if (transformHandleType) {
|
||||
resizeSingleElement(
|
||||
pointerDownState.originalElements.get(element.id) as typeof element,
|
||||
shouldKeepSidesRatio,
|
||||
shouldMaintainAspectRatio,
|
||||
element,
|
||||
transformHandleType,
|
||||
isResizeCenterPoint,
|
||||
shouldResizeFromCenter,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
@@ -116,7 +115,7 @@ export const transformElements = (
|
||||
selectedElements,
|
||||
pointerX,
|
||||
pointerY,
|
||||
isRotateWithDiscreteAngle,
|
||||
shouldRotateWithDiscreteAngle,
|
||||
centerX,
|
||||
centerY,
|
||||
);
|
||||
@@ -143,13 +142,13 @@ const rotateSingleElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
isRotateWithDiscreteAngle: boolean,
|
||||
shouldRotateWithDiscreteAngle: boolean,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
||||
if (isRotateWithDiscreteAngle) {
|
||||
if (shouldRotateWithDiscreteAngle) {
|
||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
@@ -188,7 +187,7 @@ const getPerfectElementSizeWithRotation = (
|
||||
export const reshapeSingleTwoPointElement = (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
resizeArrowDirection: "origin" | "end",
|
||||
isRotateWithDiscreteAngle: boolean,
|
||||
shouldRotateWithDiscreteAngle: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
@@ -213,7 +212,7 @@ export const reshapeSingleTwoPointElement = (
|
||||
element.x + element.points[1][0] - rotatedX,
|
||||
element.y + element.points[1][1] - rotatedY,
|
||||
];
|
||||
if (isRotateWithDiscreteAngle) {
|
||||
if (shouldRotateWithDiscreteAngle) {
|
||||
[width, height] = getPerfectElementSizeWithRotation(
|
||||
element.type,
|
||||
width,
|
||||
@@ -282,28 +281,28 @@ const measureFontSizeFromWH = (
|
||||
|
||||
const getSidesForTransformHandle = (
|
||||
transformHandleType: TransformHandleType,
|
||||
isResizeFromCenter: boolean,
|
||||
shouldResizeFromCenter: boolean,
|
||||
) => {
|
||||
return {
|
||||
n:
|
||||
/^(n|ne|nw)$/.test(transformHandleType) ||
|
||||
(isResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
|
||||
(shouldResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
|
||||
s:
|
||||
/^(s|se|sw)$/.test(transformHandleType) ||
|
||||
(isResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
|
||||
(shouldResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
|
||||
w:
|
||||
/^(w|nw|sw)$/.test(transformHandleType) ||
|
||||
(isResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
|
||||
(shouldResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
|
||||
e:
|
||||
/^(e|ne|se)$/.test(transformHandleType) ||
|
||||
(isResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
|
||||
(shouldResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
|
||||
};
|
||||
};
|
||||
|
||||
const resizeSingleTextElement = (
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
transformHandleType: "nw" | "ne" | "sw" | "se",
|
||||
isResizeFromCenter: boolean,
|
||||
shouldResizeFromCenter: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
@@ -362,7 +361,7 @@ const resizeSingleTextElement = (
|
||||
const deltaX2 = (x2 - nextX2) / 2;
|
||||
const deltaY2 = (y2 - nextY2) / 2;
|
||||
const [nextElementX, nextElementY] = adjustXYWithRotation(
|
||||
getSidesForTransformHandle(transformHandleType, isResizeFromCenter),
|
||||
getSidesForTransformHandle(transformHandleType, shouldResizeFromCenter),
|
||||
element.x,
|
||||
element.y,
|
||||
element.angle,
|
||||
@@ -384,10 +383,10 @@ const resizeSingleTextElement = (
|
||||
|
||||
export const resizeSingleElement = (
|
||||
stateAtResizeStart: NonDeletedExcalidrawElement,
|
||||
shouldKeepSidesRatio: boolean,
|
||||
shouldMaintainAspectRatio: boolean,
|
||||
element: NonDeletedExcalidrawElement,
|
||||
transformHandleDirection: TransformHandleDirection,
|
||||
isResizeFromCenter: boolean,
|
||||
shouldResizeFromCenter: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
@@ -445,13 +444,13 @@ export const resizeSingleElement = (
|
||||
let eleNewHeight = element.height * scaleY;
|
||||
|
||||
// adjust dimensions for resizing from center
|
||||
if (isResizeFromCenter) {
|
||||
if (shouldResizeFromCenter) {
|
||||
eleNewWidth = 2 * eleNewWidth - eleInitialWidth;
|
||||
eleNewHeight = 2 * eleNewHeight - eleInitialHeight;
|
||||
}
|
||||
|
||||
// adjust dimensions to keep sides ratio
|
||||
if (shouldKeepSidesRatio) {
|
||||
if (shouldMaintainAspectRatio) {
|
||||
const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth;
|
||||
const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight;
|
||||
if (transformHandleDirection.length === 1) {
|
||||
@@ -496,7 +495,7 @@ export const resizeSingleElement = (
|
||||
}
|
||||
|
||||
// Keeps opposite handle fixed during resize
|
||||
if (shouldKeepSidesRatio) {
|
||||
if (shouldMaintainAspectRatio) {
|
||||
if (["s", "n"].includes(transformHandleDirection)) {
|
||||
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
|
||||
}
|
||||
@@ -524,7 +523,7 @@ export const resizeSingleElement = (
|
||||
}
|
||||
}
|
||||
|
||||
if (isResizeFromCenter) {
|
||||
if (shouldResizeFromCenter) {
|
||||
newTopLeft[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2;
|
||||
newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2;
|
||||
}
|
||||
@@ -559,6 +558,18 @@ export const resizeSingleElement = (
|
||||
...rescaledPoints,
|
||||
};
|
||||
|
||||
if ("scale" in element && "scale" in stateAtResizeStart) {
|
||||
mutateElement(element, {
|
||||
scale: [
|
||||
// defaulting because scaleX/Y can be 0/-0
|
||||
(Math.sign(scaleX) || stateAtResizeStart.scale[0]) *
|
||||
stateAtResizeStart.scale[0],
|
||||
(Math.sign(scaleY) || stateAtResizeStart.scale[1]) *
|
||||
stateAtResizeStart.scale[1],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
resizedElement.width !== 0 &&
|
||||
resizedElement.height !== 0 &&
|
||||
@@ -693,13 +704,13 @@ const rotateMultipleElements = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
isRotateWithDiscreteAngle: boolean,
|
||||
shouldRotateWithDiscreteAngle: boolean,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
) => {
|
||||
let centerAngle =
|
||||
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
|
||||
if (isRotateWithDiscreteAngle) {
|
||||
if (shouldRotateWithDiscreteAngle) {
|
||||
centerAngle += SHIFT_LOCKING_ANGLE / 2;
|
||||
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user