mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
114 Commits
mobile_tex
...
random_use
Author | SHA1 | Date | |
---|---|---|---|
88691b1c3c | |||
146c510faa | |||
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 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,9 +5,11 @@
|
||||
.env.test.local
|
||||
.envrc
|
||||
.eslintcache
|
||||
.history
|
||||
.idea
|
||||
.vercel
|
||||
.vscode
|
||||
.yarn
|
||||
*.log
|
||||
*.tgz
|
||||
build
|
||||
|
@ -10,7 +10,7 @@ ARG NODE_ENV=production
|
||||
COPY . .
|
||||
RUN yarn build:app:docker
|
||||
|
||||
FROM nginx:1.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"
|
||||
}
|
||||
}
|
||||
|
12
firebase-project/storage.rules
Normal file
12
firebase-project/storage.rules
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -19,15 +19,17 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@dwelle/browser-fs-access": "0.21.1",
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@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/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",
|
||||
"firebase": "8.3.3",
|
||||
"i18next-browser-languagedetector": "6.1.0",
|
||||
@ -35,7 +37,7 @@
|
||||
"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 +78,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",
|
||||
|
@ -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(
|
||||
|
39
scripts/release.js
Normal file
39
scripts/release.js
Normal file
@ -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);
|
97
scripts/updateChangelog.js
Normal file
97
scripts/updateChangelog.js
Normal file
@ -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;
|
27
scripts/updateReadme.js
Normal file
27
scripts/updateReadme.js
Normal file
@ -0,0 +1,27 @@
|
||||
const fs = require("fs");
|
||||
|
||||
const updateReadme = () => {
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||
|
||||
// remove note for unstable release
|
||||
data = data.replace(
|
||||
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
|
||||
"",
|
||||
);
|
||||
|
||||
// replace "excalidraw-next" with "excalidraw"
|
||||
data = data.replace(/excalidraw-next/g, "excalidraw");
|
||||
data = data.trim();
|
||||
|
||||
const demoIndex = data.indexOf("### Demo");
|
||||
const excalidrawNextNote =
|
||||
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
|
||||
// Add excalidraw next note to try out for unreleased changes
|
||||
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
|
||||
|
||||
// update readme
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
};
|
||||
|
||||
module.exports = updateReadme;
|
@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
|
@ -1,10 +1,9 @@
|
||||
import React from "react";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||
import { trash, 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";
|
||||
@ -17,13 +16,14 @@ import { getNewZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
|
||||
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>
|
||||
@ -104,6 +108,7 @@ export const actionZoomIn = register({
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@ -138,6 +143,7 @@ export const actionZoomOut = register({
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@ -164,16 +170,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) &&
|
||||
@ -267,7 +278,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,
|
||||
};
|
||||
|
@ -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,25 +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 actionSaveScene = register({
|
||||
name: "saveScene",
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
perform: async (elements, appState, value) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||
? await resaveAsImageWithScene(elements, appState)
|
||||
: await saveAsJSON(elements, appState);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
fileHandle,
|
||||
toastMessage: fileHandleExists
|
||||
? fileHandle.name
|
||||
? fileHandle?.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
@ -101,20 +160,16 @@ 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",
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
perform: async (elements, appState, value) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(elements, {
|
||||
@ -138,7 +193,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"
|
||||
/>
|
||||
@ -152,7 +207,7 @@ export const actionLoadScene = register({
|
||||
const {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
} = await loadFromJSON(appState);
|
||||
} = await loadFromJSON(appState, elements);
|
||||
return {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
@ -201,9 +256,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";
|
||||
|
@ -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";
|
||||
@ -99,13 +102,18 @@ export const actionChangeStrokeColor = register({
|
||||
name: "changeStrokeColor",
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
strokeColor: value,
|
||||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemStrokeColor: value },
|
||||
commitToHistory: true,
|
||||
...(value.currentItemStrokeColor && {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
strokeColor: value.currentItemStrokeColor,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
commitToHistory: !!value.currentItemStrokeColor,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
@ -120,7 +128,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 +142,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 +168,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 +502,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 +527,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";
|
||||
|
||||
|
@ -5,6 +5,7 @@ import {
|
||||
UpdaterFn,
|
||||
ActionName,
|
||||
ActionResult,
|
||||
PanelComponentProps,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppProps, AppState } from "../types";
|
||||
@ -107,11 +108,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 +139,8 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
elements={this.getElementsIncludingDeleted()}
|
||||
appState={this.getAppState()}
|
||||
updateData={updateData}
|
||||
id={id}
|
||||
appProps={this.app.props}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import React from "react";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import Library from "../data/library";
|
||||
import { ToolButtonSize } from "../components/ToolButton";
|
||||
|
||||
/** if false, the action should be prevented */
|
||||
export type ActionResult =
|
||||
@ -66,8 +67,9 @@ export type ActionName =
|
||||
| "changeProjectName"
|
||||
| "changeExportBackground"
|
||||
| "changeExportEmbedScene"
|
||||
| "saveScene"
|
||||
| "saveAsScene"
|
||||
| "changeExportScale"
|
||||
| "saveToActiveFile"
|
||||
| "saveFileToDisk"
|
||||
| "loadScene"
|
||||
| "duplicateSelection"
|
||||
| "deleteSelectedElements"
|
||||
@ -101,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?: (
|
||||
|
@ -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,
|
||||
@ -116,6 +124,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
errorMessage: { browser: false, export: false },
|
||||
exportBackground: { browser: true, export: false },
|
||||
exportEmbedScene: { browser: true, export: false },
|
||||
exportScale: { browser: true, export: false },
|
||||
exportWithDarkMode: { browser: true, export: false },
|
||||
fileHandle: { browser: false, export: false },
|
||||
gridSize: { browser: true, export: true },
|
||||
@ -131,6 +140,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
offsetLeft: { browser: false, export: false },
|
||||
offsetTop: { browser: false, export: false },
|
||||
openMenu: { browser: true, export: false },
|
||||
openPopup: { browser: false, export: false },
|
||||
pasteDialog: { browser: false, export: false },
|
||||
previousSelectedElementIds: { browser: true, export: false },
|
||||
resizingElement: { browser: false, export: false },
|
||||
|
@ -151,23 +151,14 @@ 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,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
elementType: ExcalidrawElement["type"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
isLibraryOpen: boolean;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
@ -201,19 +192,6 @@ export const ShapesSwitcher = ({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ToolButton
|
||||
className="Shape ToolIcon_type_button__library"
|
||||
type="button"
|
||||
icon={LIBRARY_ICON}
|
||||
name="editor-library"
|
||||
keyBindingLabel="9"
|
||||
aria-keyshortcuts="9"
|
||||
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
onClick={() => {
|
||||
setAppState({ isLibraryOpen: !isLibraryOpen });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -226,12 +204,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>
|
||||
);
|
||||
|
21
src/components/ActiveFile.scss
Normal file
21
src/components/ActiveFile.scss
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
28
src/components/ActiveFile.tsx
Normal file
28
src/components/ActiveFile.tsx
Normal file
@ -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>
|
||||
);
|
@ -2,7 +2,6 @@ import React, { useContext } from "react";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import clsx from "clsx";
|
||||
import { supported as fsSupported } from "browser-fs-access";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import {
|
||||
@ -61,6 +60,7 @@ import {
|
||||
SCROLL_TIMEOUT,
|
||||
TAP_TWICE_TIMEOUT,
|
||||
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
||||
THEME,
|
||||
TOUCH_CTX_MENU_TIMEOUT,
|
||||
URL_HASH_KEYS,
|
||||
URL_QUERY_KEYS,
|
||||
@ -111,7 +111,6 @@ import {
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
|
||||
import { MaybeTransformHandleType } from "../element/transformHandles";
|
||||
import {
|
||||
isBindingElement,
|
||||
isBindingElementType,
|
||||
@ -157,6 +156,7 @@ import {
|
||||
getElementsWithinSelection,
|
||||
getNormalizedZoom,
|
||||
getSelectedElements,
|
||||
hasBackground,
|
||||
isOverScrollBars,
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
@ -167,9 +167,11 @@ import { findShapeByKey } from "../shapes";
|
||||
import {
|
||||
AppProps,
|
||||
AppState,
|
||||
ExcalidrawImperativeAPI,
|
||||
Gesture,
|
||||
GestureEvent,
|
||||
LibraryItems,
|
||||
PointerDownState,
|
||||
SceneData,
|
||||
} from "../types";
|
||||
import {
|
||||
@ -180,7 +182,6 @@ import {
|
||||
isToolIcon,
|
||||
isWritableElement,
|
||||
resetCursor,
|
||||
ResolvablePromise,
|
||||
resolvablePromise,
|
||||
sceneCoordsToViewportCoords,
|
||||
setCursor,
|
||||
@ -194,12 +195,14 @@ import LayerUI from "./LayerUI";
|
||||
import { Stats } from "./Stats";
|
||||
import { Toast } from "./Toast";
|
||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
|
||||
const IsMobileContext = React.createContext(false);
|
||||
export const useIsMobile = () => useContext(IsMobileContext);
|
||||
const ExcalidrawContainerContext = React.createContext<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
const ExcalidrawContainerContext = React.createContext<{
|
||||
container: HTMLDivElement | null;
|
||||
id: string | null;
|
||||
}>({ container: null, id: null });
|
||||
export const useExcalidrawContainer = () =>
|
||||
useContext(ExcalidrawContainerContext);
|
||||
|
||||
@ -222,83 +225,6 @@ const gesture: Gesture = {
|
||||
initialScale: null,
|
||||
};
|
||||
|
||||
export type PointerDownState = Readonly<{
|
||||
// The first position at which pointerDown happened
|
||||
origin: Readonly<{ x: number; y: number }>;
|
||||
// Same as "origin" but snapped to the grid, if grid is on
|
||||
originInGrid: Readonly<{ x: number; y: number }>;
|
||||
// Scrollbar checks
|
||||
scrollbars: ReturnType<typeof isOverScrollBars>;
|
||||
// The previous pointer position
|
||||
lastCoords: { x: number; y: number };
|
||||
// map of original elements data
|
||||
originalElements: Map<string, NonDeleted<ExcalidrawElement>>;
|
||||
resize: {
|
||||
// Handle when resizing, might change during the pointer interaction
|
||||
handleType: MaybeTransformHandleType;
|
||||
// This is determined on the initial pointer down event
|
||||
isResizing: boolean;
|
||||
// This is determined on the initial pointer down event
|
||||
offset: { x: number; y: number };
|
||||
// This is determined on the initial pointer down event
|
||||
arrowDirection: "origin" | "end";
|
||||
// This is a center point of selected elements determined on the initial pointer down event (for rotation only)
|
||||
center: { x: number; y: number };
|
||||
};
|
||||
hit: {
|
||||
// The element the pointer is "hitting", is determined on the initial
|
||||
// pointer down event
|
||||
element: NonDeleted<ExcalidrawElement> | null;
|
||||
// The elements the pointer is "hitting", is determined on the initial
|
||||
// pointer down event
|
||||
allHitElements: NonDeleted<ExcalidrawElement>[];
|
||||
// This is determined on the initial pointer down event
|
||||
wasAddedToSelection: boolean;
|
||||
// Whether selected element(s) were duplicated, might change during the
|
||||
// pointer interaction
|
||||
hasBeenDuplicated: boolean;
|
||||
hasHitCommonBoundingBoxOfSelectedElements: boolean;
|
||||
};
|
||||
withCmdOrCtrl: boolean;
|
||||
drag: {
|
||||
// Might change during the pointer interation
|
||||
hasOccurred: boolean;
|
||||
// Might change during the pointer interation
|
||||
offset: { x: number; y: number } | null;
|
||||
};
|
||||
// We need to have these in the state so that we can unsubscribe them
|
||||
eventListeners: {
|
||||
// It's defined on the initial pointer down event
|
||||
onMove: null | ((event: PointerEvent) => void);
|
||||
// It's defined on the initial pointer down event
|
||||
onUp: null | ((event: PointerEvent) => void);
|
||||
// It's defined on the initial pointer down event
|
||||
onKeyDown: null | ((event: KeyboardEvent) => void);
|
||||
// It's defined on the initial pointer down event
|
||||
onKeyUp: null | ((event: KeyboardEvent) => void);
|
||||
};
|
||||
}>;
|
||||
|
||||
export type ExcalidrawImperativeAPI = {
|
||||
updateScene: InstanceType<typeof App>["updateScene"];
|
||||
resetScene: InstanceType<typeof App>["resetScene"];
|
||||
getSceneElementsIncludingDeleted: InstanceType<
|
||||
typeof App
|
||||
>["getSceneElementsIncludingDeleted"];
|
||||
history: {
|
||||
clear: InstanceType<typeof App>["resetHistory"];
|
||||
};
|
||||
scrollToContent: InstanceType<typeof App>["scrollToContent"];
|
||||
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
||||
getAppState: () => InstanceType<typeof App>["state"];
|
||||
refresh: InstanceType<typeof App>["refresh"];
|
||||
importLibrary: InstanceType<typeof App>["importLibraryFromUrl"];
|
||||
setToastMessage: InstanceType<typeof App>["setToastMessage"];
|
||||
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
|
||||
ready: true;
|
||||
id: string;
|
||||
};
|
||||
|
||||
class App extends React.Component<AppProps, AppState> {
|
||||
canvas: HTMLCanvasElement | null = null;
|
||||
rc: RoughCanvas | null = null;
|
||||
@ -321,6 +247,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
public libraryItemsFromStorage: LibraryItems | undefined;
|
||||
private id: string;
|
||||
private history: History;
|
||||
private excalidrawContainerValue: {
|
||||
container: HTMLDivElement | null;
|
||||
id: string;
|
||||
};
|
||||
|
||||
constructor(props: AppProps) {
|
||||
super(props);
|
||||
@ -377,6 +307,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
readyPromise.resolve(api);
|
||||
}
|
||||
|
||||
this.excalidrawContainerValue = {
|
||||
container: this.excalidrawContainerRef.current,
|
||||
id: this.id,
|
||||
};
|
||||
|
||||
this.scene = new Scene();
|
||||
this.library = new Library(this);
|
||||
this.history = new History();
|
||||
@ -404,11 +340,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (viewModeEnabled) {
|
||||
return (
|
||||
<canvas
|
||||
id="canvas"
|
||||
className="excalidraw__canvas"
|
||||
style={{
|
||||
width: canvasDOMWidth,
|
||||
height: canvasDOMHeight,
|
||||
cursor: "grabbing",
|
||||
cursor: CURSOR_TYPE.GRAB,
|
||||
}}
|
||||
width={canvasWidth}
|
||||
height={canvasHeight}
|
||||
@ -426,7 +362,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
return (
|
||||
<canvas
|
||||
id="canvas"
|
||||
className="excalidraw__canvas"
|
||||
style={{
|
||||
width: canvasDOMWidth,
|
||||
height: canvasDOMHeight,
|
||||
@ -452,7 +388,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const {
|
||||
onCollabButtonClick,
|
||||
onExportToBackend,
|
||||
renderTopRightUI,
|
||||
renderFooter,
|
||||
renderCustomStats,
|
||||
@ -472,7 +407,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
>
|
||||
<ExcalidrawContainerContext.Provider
|
||||
value={this.excalidrawContainerRef.current}
|
||||
value={this.excalidrawContainerValue}
|
||||
>
|
||||
<IsMobileContext.Provider value={this.isMobile}>
|
||||
<LayerUI
|
||||
@ -493,7 +428,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
toggleZenMode={this.toggleZenMode}
|
||||
langCode={getLanguage().code}
|
||||
isCollaborating={this.props.isCollaborating || false}
|
||||
onExportToBackend={onExportToBackend}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomFooter={renderFooter}
|
||||
viewModeEnabled={viewModeEnabled}
|
||||
@ -536,7 +470,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
public focusContainer = () => {
|
||||
this.excalidrawContainerRef.current?.focus();
|
||||
if (this.props.autoFocus) {
|
||||
this.excalidrawContainerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
public getSceneElementsIncludingDeleted = () => {
|
||||
@ -578,7 +514,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
|
||||
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
|
||||
let gridSize = actionResult?.appState?.gridSize || null;
|
||||
let theme = actionResult?.appState?.theme || "light";
|
||||
let theme = actionResult?.appState?.theme || THEME.LIGHT;
|
||||
let name = actionResult?.appState?.name ?? this.state.name;
|
||||
if (typeof this.props.viewModeEnabled !== "undefined") {
|
||||
viewModeEnabled = this.props.viewModeEnabled;
|
||||
@ -720,7 +656,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const fileHandle = launchParams.files[0];
|
||||
const blob: Blob = await fileHandle.getFile();
|
||||
blob.handle = fileHandle;
|
||||
loadFromBlob(blob, this.state)
|
||||
loadFromBlob(
|
||||
blob,
|
||||
this.state,
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
)
|
||||
.then(({ elements, appState }) =>
|
||||
this.syncActionResult({
|
||||
elements,
|
||||
@ -747,7 +687,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (initialData?.libraryItems) {
|
||||
this.libraryItemsFromStorage = initialData.libraryItems;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
initialData = {
|
||||
appState: {
|
||||
@ -758,7 +698,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
}
|
||||
|
||||
const scene = restore(initialData, null);
|
||||
const scene = restore(initialData, null, null);
|
||||
scene.appState = {
|
||||
...scene.appState,
|
||||
isLoading: false,
|
||||
@ -802,6 +742,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
public async componentDidMount() {
|
||||
this.excalidrawContainerValue.container = this.excalidrawContainerRef.current;
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === ENV.TEST ||
|
||||
process.env.NODE_ENV === ENV.DEVELOPMENT
|
||||
@ -890,6 +832,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
|
||||
private removeEventListeners() {
|
||||
document.removeEventListener(EVENT.POINTER_UP, this.removePointer);
|
||||
document.removeEventListener(EVENT.COPY, this.onCopy);
|
||||
document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
|
||||
document.removeEventListener(EVENT.CUT, this.onCut);
|
||||
@ -931,6 +874,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
private addEventListeners() {
|
||||
this.removeEventListeners();
|
||||
document.addEventListener(EVENT.POINTER_UP, this.removePointer); // #3553
|
||||
document.addEventListener(EVENT.COPY, this.onCopy);
|
||||
if (this.props.handleKeyboardGlobally) {
|
||||
document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
|
||||
@ -986,14 +930,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
|
||||
this.setState(
|
||||
{ viewModeEnabled: !!this.props.viewModeEnabled },
|
||||
this.addEventListeners,
|
||||
);
|
||||
this.setState({ viewModeEnabled: !!this.props.viewModeEnabled });
|
||||
}
|
||||
|
||||
if (prevState.viewModeEnabled !== this.state.viewModeEnabled) {
|
||||
this.addEventListeners();
|
||||
this.deselectElements();
|
||||
}
|
||||
|
||||
if (prevProps.zenModeEnabled !== this.props.zenModeEnabled) {
|
||||
@ -1252,8 +1194,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
const data = await parseClipboard(event);
|
||||
if (this.props.onPaste) {
|
||||
if (await this.props.onPaste(data, event)) {
|
||||
return;
|
||||
try {
|
||||
if ((await this.props.onPaste(data, event)) === false) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
if (data.errorMessage) {
|
||||
@ -1267,7 +1213,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
} else if (data.elements) {
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements: restoreElements(data.elements),
|
||||
elements: data.elements,
|
||||
position: "cursor",
|
||||
});
|
||||
} else if (data.text) {
|
||||
@ -1282,7 +1228,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
position: { clientX: number; clientY: number } | "cursor" | "center";
|
||||
}) => {
|
||||
const elements = restoreElements(opts.elements);
|
||||
const elements = restoreElements(opts.elements, null);
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
|
||||
const elementsCenterX = distance(minX, maxX) / 2;
|
||||
@ -1348,6 +1294,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getElements(),
|
||||
),
|
||||
);
|
||||
this.selectShapeTool("selection");
|
||||
};
|
||||
|
||||
private addTextFromPaste(text: any) {
|
||||
@ -1388,7 +1335,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState(obj);
|
||||
};
|
||||
|
||||
removePointer = (event: React.PointerEvent<HTMLElement>) => {
|
||||
removePointer = (event: React.PointerEvent<HTMLElement> | PointerEvent) => {
|
||||
// remove touch handler for context menu on touch devices
|
||||
if (event.pointerType === "touch" && touchTimeout) {
|
||||
clearTimeout(touchTimeout);
|
||||
@ -1454,7 +1401,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
await webShareTargetCache.delete("shared-file");
|
||||
window.history.replaceState(null, APP_NAME, window.location.pathname);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
this.setState({ errorMessage: error.message });
|
||||
}
|
||||
};
|
||||
@ -1645,12 +1592,38 @@ class App extends React.Component<AppProps, AppState> {
|
||||
isHoldingSpace = true;
|
||||
setCursor(this.canvas, CURSOR_TYPE.GRABBING);
|
||||
}
|
||||
|
||||
if (event.key === KEYS.G || event.key === KEYS.S) {
|
||||
const selectedElements = getSelectedElements(
|
||||
this.scene.getElements(),
|
||||
this.state,
|
||||
);
|
||||
if (
|
||||
this.state.elementType === "selection" &&
|
||||
!selectedElements.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === KEYS.G &&
|
||||
(hasBackground(this.state.elementType) ||
|
||||
selectedElements.some((element) => hasBackground(element.type)))
|
||||
) {
|
||||
this.setState({ openPopup: "backgroundColorPicker" });
|
||||
}
|
||||
if (event.key === KEYS.S) {
|
||||
this.setState({ openPopup: "strokeColorPicker" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.SPACE) {
|
||||
if (this.state.elementType === "selection") {
|
||||
if (this.state.viewModeEnabled) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.GRAB);
|
||||
} else if (this.state.elementType === "selection") {
|
||||
resetCursor(this.canvas);
|
||||
} else {
|
||||
setCursorForShape(this.canvas, this.state.elementType);
|
||||
@ -1798,7 +1771,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
[element.id]: true,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
}
|
||||
if (isDeleted) {
|
||||
fixBindingsAfterDeletion(this.scene.getElements(), [element]);
|
||||
}
|
||||
if (!isDeleted || isExistingElement) {
|
||||
@ -1819,15 +1793,19 @@ class App extends React.Component<AppProps, AppState> {
|
||||
excalidrawContainer: this.excalidrawContainerRef.current,
|
||||
});
|
||||
// deselect all other elements when inserting text
|
||||
this.deselectElements();
|
||||
|
||||
// do an initial update to re-initialize element position since we were
|
||||
// modifying element's x/y for sake of editor (case: syncing to remote)
|
||||
updateElement(element.text);
|
||||
}
|
||||
|
||||
private deselectElements() {
|
||||
this.setState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
});
|
||||
|
||||
// do an initial update to re-initialize element position since we were
|
||||
// modifying element's x/y for sake of editor (case: syncing to remote)
|
||||
updateElement(element.text);
|
||||
}
|
||||
|
||||
private getTextElementAtPosition(
|
||||
@ -1845,9 +1823,21 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private getElementAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
opts?: {
|
||||
/** if true, returns the first selected element (with highest z-index)
|
||||
of all hit elements */
|
||||
preferSelected?: boolean;
|
||||
},
|
||||
): NonDeleted<ExcalidrawElement> | null {
|
||||
const allHitElements = this.getElementsAtPosition(x, y);
|
||||
if (allHitElements.length > 1) {
|
||||
if (opts?.preferSelected) {
|
||||
for (let index = allHitElements.length - 1; index > -1; index--) {
|
||||
if (this.state.selectedElementIds[allHitElements[index].id]) {
|
||||
return allHitElements[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
const elementWithHighestZIndex =
|
||||
allHitElements[allHitElements.length - 1];
|
||||
// If we're hitting element with highest z-index only on its bounding box
|
||||
@ -2258,6 +2248,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.canvas,
|
||||
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
|
||||
);
|
||||
} else if (this.state.viewModeEnabled) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.GRAB);
|
||||
} else if (isOverScrollBar) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.AUTO);
|
||||
} else if (
|
||||
@ -2283,8 +2275,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private handleCanvasPointerDown = (
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
) => {
|
||||
event.persist();
|
||||
|
||||
// remove any active selection when we start to interact with canvas
|
||||
// (mainly, we care about removing selection outside the component which
|
||||
// would prevent our copy handling otherwise)
|
||||
@ -2497,7 +2487,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
lastPointerUp = null;
|
||||
isPanning = false;
|
||||
if (!isHoldingSpace) {
|
||||
setCursorForShape(this.canvas, this.state.elementType);
|
||||
if (this.state.viewModeEnabled) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.GRAB);
|
||||
} else {
|
||||
setCursorForShape(this.canvas, this.state.elementType);
|
||||
}
|
||||
}
|
||||
this.setState({
|
||||
cursorButton: "up",
|
||||
@ -3501,6 +3495,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(draggingElement, {
|
||||
points: [...points, [dx, dy]],
|
||||
pressures,
|
||||
lastCommittedPoint: [dx, dy],
|
||||
});
|
||||
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
@ -3840,7 +3835,22 @@ class App extends React.Component<AppProps, AppState> {
|
||||
try {
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file?.type === "image/png" || file?.type === "image/svg+xml") {
|
||||
const { elements, appState } = await loadFromBlob(file, this.state);
|
||||
if (nativeFileSystemSupported) {
|
||||
try {
|
||||
// This will only work as of Chrome 86,
|
||||
// but can be safely ignored on older releases.
|
||||
const item = event.dataTransfer.items[0];
|
||||
(file as any).handle = await (item as any).getAsFileSystemHandle();
|
||||
} catch (error: any) {
|
||||
console.warn(error.name, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const { elements, appState } = await loadFromBlob(
|
||||
file,
|
||||
this.state,
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
);
|
||||
this.syncActionResult({
|
||||
elements,
|
||||
appState: {
|
||||
@ -3851,7 +3861,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
return this.setState({
|
||||
isLoading: false,
|
||||
errorMessage: error.message,
|
||||
@ -3885,13 +3895,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// default: assume an Excalidraw file regardless of extension/MimeType
|
||||
} else {
|
||||
this.setState({ isLoading: true });
|
||||
if (fsSupported) {
|
||||
if (nativeFileSystemSupported) {
|
||||
try {
|
||||
// This will only work as of Chrome 86,
|
||||
// but can be safely ignored on older releases.
|
||||
const item = event.dataTransfer.items[0];
|
||||
(file as any).handle = await (item as any).getAsFileSystemHandle();
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.warn(error.name, error.message);
|
||||
}
|
||||
}
|
||||
@ -3900,7 +3910,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
loadFileToCanvas = (file: Blob) => {
|
||||
loadFromBlob(file, this.state)
|
||||
loadFromBlob(file, this.state, this.scene.getElementsIncludingDeleted())
|
||||
.then(({ elements, appState }) =>
|
||||
this.syncActionResult({
|
||||
elements,
|
||||
@ -3922,7 +3932,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event.preventDefault();
|
||||
|
||||
const { x, y } = viewportCoordsToSceneCoords(event, this.state);
|
||||
const element = this.getElementAtPosition(x, y);
|
||||
const element = this.getElementAtPosition(x, y, { preferSelected: true });
|
||||
|
||||
const type = element ? "element" : "canvas";
|
||||
|
||||
@ -4084,116 +4094,112 @@ class App extends React.Component<AppProps, AppState> {
|
||||
actionToggleStats,
|
||||
];
|
||||
|
||||
ContextMenu.push({
|
||||
options: viewModeOptions,
|
||||
top,
|
||||
left,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
|
||||
if (this.state.viewModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
ContextMenu.push({
|
||||
options: [
|
||||
this.isMobile &&
|
||||
navigator.clipboard && {
|
||||
name: "paste",
|
||||
perform: (elements, appStates) => {
|
||||
this.pasteFromClipboard(null);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
ContextMenu.push({
|
||||
options: viewModeOptions,
|
||||
top,
|
||||
left,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
} else {
|
||||
ContextMenu.push({
|
||||
options: [
|
||||
this.isMobile &&
|
||||
navigator.clipboard && {
|
||||
name: "paste",
|
||||
perform: (elements, appStates) => {
|
||||
this.pasteFromClipboard(null);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
},
|
||||
this.isMobile && navigator.clipboard && separator,
|
||||
probablySupportsClipboardBlob &&
|
||||
elements.length > 0 &&
|
||||
actionCopyAsPng,
|
||||
probablySupportsClipboardWriteText &&
|
||||
elements.length > 0 &&
|
||||
actionCopyAsSvg,
|
||||
((probablySupportsClipboardBlob && elements.length > 0) ||
|
||||
(probablySupportsClipboardWriteText && elements.length > 0)) &&
|
||||
this.isMobile && navigator.clipboard && separator,
|
||||
probablySupportsClipboardBlob &&
|
||||
elements.length > 0 &&
|
||||
actionCopyAsPng,
|
||||
probablySupportsClipboardWriteText &&
|
||||
elements.length > 0 &&
|
||||
actionCopyAsSvg,
|
||||
((probablySupportsClipboardBlob && elements.length > 0) ||
|
||||
(probablySupportsClipboardWriteText && elements.length > 0)) &&
|
||||
separator,
|
||||
actionSelectAll,
|
||||
separator,
|
||||
actionSelectAll,
|
||||
separator,
|
||||
typeof this.props.gridModeEnabled === "undefined" &&
|
||||
actionToggleGridMode,
|
||||
typeof this.props.zenModeEnabled === "undefined" &&
|
||||
actionToggleZenMode,
|
||||
typeof this.props.viewModeEnabled === "undefined" &&
|
||||
actionToggleViewMode,
|
||||
actionToggleStats,
|
||||
],
|
||||
top,
|
||||
left,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
return;
|
||||
typeof this.props.gridModeEnabled === "undefined" &&
|
||||
actionToggleGridMode,
|
||||
typeof this.props.zenModeEnabled === "undefined" &&
|
||||
actionToggleZenMode,
|
||||
typeof this.props.viewModeEnabled === "undefined" &&
|
||||
actionToggleViewMode,
|
||||
actionToggleStats,
|
||||
],
|
||||
top,
|
||||
left,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
}
|
||||
} else if (type === "element") {
|
||||
if (this.state.viewModeEnabled) {
|
||||
ContextMenu.push({
|
||||
options: [navigator.clipboard && actionCopy, ...options],
|
||||
top,
|
||||
left,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
} else {
|
||||
ContextMenu.push({
|
||||
options: [
|
||||
this.isMobile && actionCut,
|
||||
this.isMobile && navigator.clipboard && actionCopy,
|
||||
this.isMobile &&
|
||||
navigator.clipboard && {
|
||||
name: "paste",
|
||||
perform: (elements, appStates) => {
|
||||
this.pasteFromClipboard(null);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
},
|
||||
this.isMobile && separator,
|
||||
...options,
|
||||
separator,
|
||||
actionCopyStyles,
|
||||
actionPasteStyles,
|
||||
separator,
|
||||
maybeGroupAction && actionGroup,
|
||||
maybeUngroupAction && actionUngroup,
|
||||
(maybeGroupAction || maybeUngroupAction) && separator,
|
||||
actionAddToLibrary,
|
||||
separator,
|
||||
actionSendBackward,
|
||||
actionBringForward,
|
||||
actionSendToBack,
|
||||
actionBringToFront,
|
||||
separator,
|
||||
maybeFlipHorizontal && actionFlipHorizontal,
|
||||
maybeFlipVertical && actionFlipVertical,
|
||||
(maybeFlipHorizontal || maybeFlipVertical) && separator,
|
||||
actionDuplicateSelection,
|
||||
actionDeleteSelected,
|
||||
],
|
||||
top,
|
||||
left,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.viewModeEnabled) {
|
||||
ContextMenu.push({
|
||||
options: [navigator.clipboard && actionCopy, ...options],
|
||||
top,
|
||||
left,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ContextMenu.push({
|
||||
options: [
|
||||
this.isMobile && actionCut,
|
||||
this.isMobile && navigator.clipboard && actionCopy,
|
||||
this.isMobile &&
|
||||
navigator.clipboard && {
|
||||
name: "paste",
|
||||
perform: (elements, appStates) => {
|
||||
this.pasteFromClipboard(null);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
},
|
||||
this.isMobile && separator,
|
||||
...options,
|
||||
separator,
|
||||
actionCopyStyles,
|
||||
actionPasteStyles,
|
||||
separator,
|
||||
maybeGroupAction && actionGroup,
|
||||
maybeUngroupAction && actionUngroup,
|
||||
(maybeGroupAction || maybeUngroupAction) && separator,
|
||||
actionAddToLibrary,
|
||||
separator,
|
||||
actionSendBackward,
|
||||
actionBringForward,
|
||||
actionSendToBack,
|
||||
actionBringToFront,
|
||||
separator,
|
||||
maybeFlipHorizontal && actionFlipHorizontal,
|
||||
maybeFlipVertical && actionFlipVertical,
|
||||
(maybeFlipHorizontal || maybeFlipVertical) && separator,
|
||||
actionDuplicateSelection,
|
||||
actionDeleteSelected,
|
||||
],
|
||||
top,
|
||||
left,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
};
|
||||
|
||||
private handleWheel = withBatchedUpdates((event: WheelEvent) => {
|
||||
|
@ -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>({
|
||||
|
@ -81,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";
|
||||
|
||||
|
@ -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;
|
||||
|
@ -157,6 +157,13 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
shortcuts={["Shift+P", "7"]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||
<Shortcut
|
||||
label={t("helpDialog.editSelectedShape")}
|
||||
shortcuts={[
|
||||
getShortcutKey("Enter"),
|
||||
t("helpDialog.doubleClick"),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.textNewLine")}
|
||||
shortcuts={[
|
||||
@ -365,6 +372,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,10 @@
|
||||
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 { isLinearElement, isTextElement } from "../element/typeChecks";
|
||||
import { getShortcutKey } from "../utils";
|
||||
|
||||
interface Hint {
|
||||
@ -57,6 +56,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 { exportToCanvas } from "../scene/export";
|
||||
import { AppState } 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,7 @@ const ExportButton: React.FC<{
|
||||
const ImageExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding = 10,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
actionManager,
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
@ -98,7 +95,6 @@ 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 } = appState;
|
||||
@ -121,7 +117,6 @@ const ImageExportModal = ({
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
scale,
|
||||
});
|
||||
|
||||
// if converting to blob fails, there's some problem that will
|
||||
@ -144,7 +139,6 @@ const ImageExportModal = ({
|
||||
exportBackground,
|
||||
exportPadding,
|
||||
viewBackgroundColor,
|
||||
scale,
|
||||
]);
|
||||
|
||||
return (
|
||||
@ -175,33 +169,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,
|
||||
_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>
|
||||
@ -213,14 +182,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>
|
||||
@ -228,14 +198,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}
|
||||
>
|
||||
@ -250,7 +220,7 @@ const ImageExportModal = ({
|
||||
export const ImageExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding = 10,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
actionManager,
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
|
@ -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 } 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[],
|
||||
@ -22,35 +22,41 @@ const JSONExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
actionManager,
|
||||
onExportToBackend,
|
||||
exportOpts,
|
||||
canvas,
|
||||
}: {
|
||||
appState: AppState;
|
||||
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 +68,12 @@ const JSONExportModal = ({
|
||||
title={t("exportDialog.link_button")}
|
||||
aria-label={t("exportDialog.link_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => onExportToBackend(elements)}
|
||||
onClick={() => onExportToBackend(elements, appState, canvas)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
{exportOpts.renderCustomUI &&
|
||||
exportOpts.renderCustomUI(elements, appState, canvas)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -75,12 +83,14 @@ export const JSONExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
actionManager,
|
||||
onExportToBackend,
|
||||
exportOpts,
|
||||
canvas,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToBackend?: ExportCB;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
|
||||
@ -107,8 +117,9 @@ export const JSONExportDialog = ({
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -36,7 +36,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,6 +47,8 @@ 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;
|
||||
@ -63,11 +65,6 @@ interface LayerUIProps {
|
||||
toggleZenMode: () => void;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
onExportToBackend?: (
|
||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
canvas: HTMLCanvasElement | null,
|
||||
) => void;
|
||||
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
@ -112,6 +109,7 @@ const LibraryMenuItems = ({
|
||||
onAddToLibrary,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
theme,
|
||||
setAppState,
|
||||
setLibraryItems,
|
||||
libraryReturnUrl,
|
||||
@ -124,6 +122,7 @@ const LibraryMenuItems = ({
|
||||
onRemoveFromLibrary: (index: number) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
onAddToLibrary: (elements: LibraryItem) => void;
|
||||
theme: AppState["theme"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
setLibraryItems: (library: LibraryItems) => void;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
@ -197,7 +196,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")}
|
||||
@ -251,6 +250,7 @@ const LibraryMenu = ({
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
theme,
|
||||
setAppState,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
@ -261,6 +261,7 @@ const LibraryMenu = ({
|
||||
onClickOutside: (event: MouseEvent) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
onAddToLibrary: () => void;
|
||||
theme: AppState["theme"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
@ -350,6 +351,7 @@ const LibraryMenu = ({
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
theme={theme}
|
||||
id={id}
|
||||
/>
|
||||
)}
|
||||
@ -371,7 +373,6 @@ const LayerUI = ({
|
||||
showThemeBtn,
|
||||
toggleZenMode,
|
||||
isCollaborating,
|
||||
onExportToBackend,
|
||||
renderTopRightUI,
|
||||
renderCustomFooter,
|
||||
viewModeEnabled,
|
||||
@ -393,38 +394,38 @@ const LayerUI = ({
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
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, {
|
||||
const fileHandle = await exportCanvas(type, exportedElements, appState, {
|
||||
exportBackground: appState.exportBackground,
|
||||
name: appState.name,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
scale,
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
|
||||
if (
|
||||
appState.exportEmbedScene &&
|
||||
fileHandle &&
|
||||
isImageFileHandle(fileHandle)
|
||||
) {
|
||||
setAppState({ fileHandle });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -496,6 +497,9 @@ const LayerUI = ({
|
||||
setAppState={setAppState}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
{appState.fileHandle && (
|
||||
<>{actionManager.renderAction("saveToActiveFile")}</>
|
||||
)}
|
||||
</Stack.Col>
|
||||
</Island>
|
||||
</Section>
|
||||
@ -514,7 +518,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
|
||||
@ -551,6 +556,7 @@ const LayerUI = ({
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
theme={appState.theme}
|
||||
id={id}
|
||||
/>
|
||||
) : null;
|
||||
@ -578,6 +584,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 })}
|
||||
@ -589,15 +601,12 @@ const LayerUI = ({
|
||||
canvas={canvas}
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
isLibraryOpen={appState.isLibraryOpen}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
<LockIcon
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
<LibraryButton
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
@ -623,7 +632,9 @@ const LayerUI = ({
|
||||
label={client.username || "Unknown user"}
|
||||
key={clientId}
|
||||
>
|
||||
{actionManager.renderAction("goToCollaborator", clientId)}
|
||||
{actionManager.renderAction("goToCollaborator", {
|
||||
id: clientId,
|
||||
})}
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
@ -656,6 +667,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>
|
||||
|
46
src/components/LibraryButton.tsx
Normal file
46
src/components/LibraryButton.tsx
Normal file
@ -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"))} — 9`}
|
||||
style={{ marginInlineStart: "var(--space-factor)" }}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
name="editor-library"
|
||||
onChange={(event) => {
|
||||
setAppState({ isLibraryOpen: event.target.checked });
|
||||
}}
|
||||
checked={appState.isLibraryOpen}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
aria-keyshortcuts="9"
|
||||
/>
|
||||
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
|
||||
</label>
|
||||
);
|
||||
};
|
@ -1,6 +1,6 @@
|
||||
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";
|
||||
@ -36,21 +36,27 @@ export const LibraryUnit = ({
|
||||
if (!elementsToRender) {
|
||||
return;
|
||||
}
|
||||
const svg = exportToSvg(elementsToRender, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
});
|
||||
for (const child of ref.current!.children) {
|
||||
if (child.tagName !== "svg") {
|
||||
continue;
|
||||
}
|
||||
ref.current!.removeChild(child);
|
||||
}
|
||||
ref.current!.appendChild(svg);
|
||||
|
||||
let svg: SVGSVGElement;
|
||||
const current = ref.current!;
|
||||
|
||||
(async () => {
|
||||
svg = await exportToSvg(elementsToRender, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
});
|
||||
for (const child of ref.current!.children) {
|
||||
if (child.tagName !== "svg") {
|
||||
continue;
|
||||
}
|
||||
current!.removeChild(child);
|
||||
}
|
||||
current!.appendChild(svg);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
current.removeChild(svg);
|
||||
if (svg) {
|
||||
current.removeChild(svg);
|
||||
}
|
||||
};
|
||||
}, [elements, pendingElements]);
|
||||
|
||||
|
@ -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;
|
||||
@ -64,15 +65,15 @@ export const MobileMenu = ({
|
||||
canvas={canvas}
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
isLibraryOpen={appState.isLibraryOpen}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
<LockIcon
|
||||
<LockButton
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
<LibraryButton appState={appState} setAppState={setAppState} />
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
</Stack.Col>
|
||||
@ -167,10 +168,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,19 +34,21 @@ const ChartPreviewBtn = (props: {
|
||||
0,
|
||||
);
|
||||
setChartElements(elements);
|
||||
|
||||
const svg = exportToSvg(elements, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
});
|
||||
|
||||
let svg: SVGSVGElement;
|
||||
const previewNode = previewRef.current!;
|
||||
|
||||
previewNode.appendChild(svg);
|
||||
(async () => {
|
||||
svg = await exportToSvg(elements, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
});
|
||||
|
||||
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)
|
||||
) : (
|
||||
|
@ -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";
|
||||
|
||||
|
@ -2,8 +2,9 @@ import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
type ToolIconSize = "s" | "m";
|
||||
export type ToolButtonSize = "small" | "medium";
|
||||
|
||||
type ToolButtonBaseProps = {
|
||||
icon?: React.ReactNode;
|
||||
@ -14,7 +15,7 @@ type ToolButtonBaseProps = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
size?: ToolIconSize;
|
||||
size?: ToolButtonSize;
|
||||
keyBindingLabel?: string;
|
||||
showAriaLabel?: boolean;
|
||||
hidden?: boolean;
|
||||
@ -40,12 +41,11 @@ type ToolButtonProps =
|
||||
onChange?(): void;
|
||||
});
|
||||
|
||||
const DEFAULT_SIZE: ToolIconSize = "m";
|
||||
|
||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
const { id: excalId } = useExcalidrawContainer();
|
||||
const innerRef = React.useRef(null);
|
||||
React.useImperativeHandle(ref, () => innerRef.current);
|
||||
const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
|
||||
const sizeCn = `ToolIcon_size_${props.size}`;
|
||||
|
||||
if (props.type === "button" || props.type === "icon") {
|
||||
return (
|
||||
@ -98,7 +98,7 @@ 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}
|
||||
id={`${excalId}-${props.id}`}
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
ref={innerRef}
|
||||
@ -116,4 +116,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 {
|
||||
@ -52,9 +60,9 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.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 +74,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 +86,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-gray-2);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
|
||||
&--show {
|
||||
visibility: visible;
|
||||
}
|
||||
@ -103,6 +111,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 +141,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 +186,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 +213,14 @@
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
.ToolIcon.ToolIcon__library {
|
||||
top: 100px;
|
||||
}
|
||||
|
||||
.ToolIcon.ToolIcon__lock {
|
||||
margin-inline-end: 0;
|
||||
top: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.unlocked-icon {
|
||||
|
@ -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,
|
||||
|
@ -10,13 +10,15 @@ import React from "react";
|
||||
|
||||
import oc from "open-color";
|
||||
import clsx from "clsx";
|
||||
import { Theme } from "../element/types";
|
||||
import { THEME } from "../constants";
|
||||
|
||||
const activeElementColor = (theme: "light" | "dark") =>
|
||||
theme === "light" ? oc.orange[4] : oc.orange[9];
|
||||
const iconFillColor = (theme: "light" | "dark") =>
|
||||
theme === "light" ? oc.black : oc.gray[4];
|
||||
const handlerColor = (theme: "light" | "dark") =>
|
||||
theme === "light" ? oc.white : "#1e1e1e";
|
||||
const activeElementColor = (theme: Theme) =>
|
||||
theme === THEME.LIGHT ? oc.orange[4] : oc.orange[9];
|
||||
const iconFillColor = (theme: Theme) =>
|
||||
theme === THEME.LIGHT ? oc.black : oc.gray[4];
|
||||
const handlerColor = (theme: Theme) =>
|
||||
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
|
||||
|
||||
type Opts = {
|
||||
width?: number;
|
||||
@ -24,7 +26,10 @@ type Opts = {
|
||||
mirror?: true;
|
||||
} & React.SVGProps<SVGSVGElement>;
|
||||
|
||||
const createIcon = (d: string | React.ReactNode, opts: number | Opts = 512) => {
|
||||
export const createIcon = (
|
||||
d: string | React.ReactNode,
|
||||
opts: number | Opts = 512,
|
||||
) => {
|
||||
const { width = 512, height = width, mirror, style } =
|
||||
typeof opts === "number" ? ({ width: opts } as Opts) : opts;
|
||||
return (
|
||||
@ -172,88 +177,84 @@ export const resetZoom = createIcon(
|
||||
{ width: 1024 },
|
||||
);
|
||||
|
||||
export const BringForwardIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M22 9.556C22 8.696 21.303 8 20.444 8H16v8H8v4.444C8 21.304 8.697 22 9.556 22h10.888c.86 0 1.556-.697 1.556-1.556V9.556z"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M16 3.556C16 2.696 15.303 2 14.444 2H3.556C2.696 2 2 2.697 2 3.556v10.888C2 15.304 2.697 16 3.556 16h10.888c.86 0 1.556-.697 1.556-1.556V3.556z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24, mirror: true },
|
||||
),
|
||||
export const BringForwardIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M22 9.556C22 8.696 21.303 8 20.444 8H16v8H8v4.444C8 21.304 8.697 22 9.556 22h10.888c.86 0 1.556-.697 1.556-1.556V9.556z"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M16 3.556C16 2.696 15.303 2 14.444 2H3.556C2.696 2 2 2.697 2 3.556v10.888C2 15.304 2.697 16 3.556 16h10.888c.86 0 1.556-.697 1.556-1.556V3.556z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24, mirror: true },
|
||||
),
|
||||
);
|
||||
|
||||
export const SendBackwardIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M16 3.556C16 2.696 15.303 2 14.444 2H3.556C2.696 2 2 2.697 2 3.556v10.888C2 15.304 2.697 16 3.556 16h10.888c.86 0 1.556-.697 1.556-1.556V3.556z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M22 9.556C22 8.696 21.303 8 20.444 8H9.556C8.696 8 8 8.697 8 9.556v10.888C8 21.304 8.697 22 9.556 22h10.888c.86 0 1.556-.697 1.556-1.556V9.556z"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24, mirror: true },
|
||||
),
|
||||
export const SendBackwardIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M16 3.556C16 2.696 15.303 2 14.444 2H3.556C2.696 2 2 2.697 2 3.556v10.888C2 15.304 2.697 16 3.556 16h10.888c.86 0 1.556-.697 1.556-1.556V3.556z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M22 9.556C22 8.696 21.303 8 20.444 8H9.556C8.696 8 8 8.697 8 9.556v10.888C8 21.304 8.697 22 9.556 22h10.888c.86 0 1.556-.697 1.556-1.556V9.556z"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24, mirror: true },
|
||||
),
|
||||
);
|
||||
|
||||
export const BringToFrontIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M13 21a1 1 0 001 1h7a1 1 0 001-1v-7a1 1 0 00-1-1h-3v5h-5v3zM11 3a1 1 0 00-1-1H3a1 1 0 00-1 1v7a1 1 0 001 1h3V6h5V3z"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M18 7.333C18 6.597 17.403 6 16.667 6H7.333C6.597 6 6 6.597 6 7.333v9.334C6 17.403 6.597 18 7.333 18h9.334c.736 0 1.333-.597 1.333-1.333V7.333z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24, mirror: true },
|
||||
),
|
||||
export const BringToFrontIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M13 21a1 1 0 001 1h7a1 1 0 001-1v-7a1 1 0 00-1-1h-3v5h-5v3zM11 3a1 1 0 00-1-1H3a1 1 0 00-1 1v7a1 1 0 001 1h3V6h5V3z"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M18 7.333C18 6.597 17.403 6 16.667 6H7.333C6.597 6 6 6.597 6 7.333v9.334C6 17.403 6.597 18 7.333 18h9.334c.736 0 1.333-.597 1.333-1.333V7.333z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24, mirror: true },
|
||||
),
|
||||
);
|
||||
|
||||
export const SendToBackIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M18 7.333C18 6.597 17.403 6 16.667 6H7.333C6.597 6 6 6.597 6 7.333v9.334C6 17.403 6.597 18 7.333 18h9.334c.736 0 1.333-.597 1.333-1.333V7.333z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M11 3a1 1 0 00-1-1H3a1 1 0 00-1 1v7a1 1 0 001 1h8V3zM22 14a1 1 0 00-1-1h-7a1 1 0 00-1 1v7a1 1 0 001 1h8v-8z"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24, mirror: true },
|
||||
),
|
||||
export const SendToBackIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M18 7.333C18 6.597 17.403 6 16.667 6H7.333C6.597 6 6 6.597 6 7.333v9.334C6 17.403 6.597 18 7.333 18h9.334c.736 0 1.333-.597 1.333-1.333V7.333z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M11 3a1 1 0 00-1-1H3a1 1 0 00-1 1v7a1 1 0 001 1h8V3zM22 14a1 1 0 00-1-1h-7a1 1 0 00-1 1v7a1 1 0 001 1h8v-8z"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24, mirror: true },
|
||||
),
|
||||
);
|
||||
|
||||
//
|
||||
@ -262,96 +263,92 @@ export const SendToBackIcon = React.memo(
|
||||
// first one the user sees. Horizontal align icons should not be flipped since
|
||||
// that would make them lie about their function.
|
||||
//
|
||||
export const AlignTopIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M 2,5 H 22"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M 6,7 C 5.446,7 5,7.446 5,8 v 9.999992 c 0,0.554 0.446,1 1,1 h 3.0000001 c 0.554,0 0.9999999,-0.446 0.9999999,-1 V 8 C 10,7.446 9.5540001,7 9.0000001,7 Z m 9,0 c -0.554,0 -1,0.446 -1,1 v 5.999992 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 V 8 C 19,7.446 18.554,7 18,7 Z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24, mirror: true },
|
||||
),
|
||||
export const AlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M 2,5 H 22"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M 6,7 C 5.446,7 5,7.446 5,8 v 9.999992 c 0,0.554 0.446,1 1,1 h 3.0000001 c 0.554,0 0.9999999,-0.446 0.9999999,-1 V 8 C 10,7.446 9.5540001,7 9.0000001,7 Z m 9,0 c -0.554,0 -1,0.446 -1,1 v 5.999992 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 V 8 C 19,7.446 18.554,7 18,7 Z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24, mirror: true },
|
||||
),
|
||||
);
|
||||
|
||||
export const AlignBottomIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M 2,19 H 22"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="m 6,16.999992 c -0.554,0 -1,-0.446 -1,-1 V 6 C 5,5.446 5.446,5 6,5 H 9.0000001 C 9.5540001,5 10,5.446 10,6 v 9.999992 c 0,0.554 -0.4459999,1 -0.9999999,1 z m 9,0 c -0.554,0 -1,-0.446 -1,-1 V 10 c 0,-0.554 0.446,-1 1,-1 h 3 c 0.554,0 1,0.446 1,1 v 5.999992 c 0,0.554 -0.446,1 -1,1 z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24, mirror: true },
|
||||
),
|
||||
export const AlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M 2,19 H 22"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="m 6,16.999992 c -0.554,0 -1,-0.446 -1,-1 V 6 C 5,5.446 5.446,5 6,5 H 9.0000001 C 9.5540001,5 10,5.446 10,6 v 9.999992 c 0,0.554 -0.4459999,1 -0.9999999,1 z m 9,0 c -0.554,0 -1,-0.446 -1,-1 V 10 c 0,-0.554 0.446,-1 1,-1 h 3 c 0.554,0 1,0.446 1,1 v 5.999992 c 0,0.554 -0.446,1 -1,1 z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24, mirror: true },
|
||||
),
|
||||
);
|
||||
|
||||
export const AlignLeftIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M 5,2 V 22"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="m 7.000004,5.999996 c 0,-0.554 0.446,-1 1,-1 h 9.999992 c 0.554,0 1,0.446 1,1 v 3.0000001 c 0,0.554 -0.446,0.9999999 -1,0.9999999 H 8.000004 c -0.554,0 -1,-0.4459999 -1,-0.9999999 z m 0,9 c 0,-0.554 0.446,-1 1,-1 h 5.999992 c 0.554,0 1,0.446 1,1 v 3 c 0,0.554 -0.446,1 -1,1 H 8.000004 c -0.554,0 -1,-0.446 -1,-1 z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24 },
|
||||
),
|
||||
export const AlignLeftIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M 5,2 V 22"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="m 7.000004,5.999996 c 0,-0.554 0.446,-1 1,-1 h 9.999992 c 0.554,0 1,0.446 1,1 v 3.0000001 c 0,0.554 -0.446,0.9999999 -1,0.9999999 H 8.000004 c -0.554,0 -1,-0.4459999 -1,-0.9999999 z m 0,9 c 0,-0.554 0.446,-1 1,-1 h 5.999992 c 0.554,0 1,0.446 1,1 v 3 c 0,0.554 -0.446,1 -1,1 H 8.000004 c -0.554,0 -1,-0.446 -1,-1 z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24 },
|
||||
),
|
||||
);
|
||||
|
||||
export const AlignRightIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M 19,2 V 22"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="m 16.999996,5.999996 c 0,-0.554 -0.446,-1 -1,-1 H 6.000004 c -0.554,0 -1,0.446 -1,1 v 3.0000001 c 0,0.554 0.446,0.9999999 1,0.9999999 h 9.999992 c 0.554,0 1,-0.4459999 1,-0.9999999 z m 0,9 c 0,-0.554 -0.446,-1 -1,-1 h -5.999992 c -0.554,0 -1,0.446 -1,1 v 3 c 0,0.554 0.446,1 1,1 h 5.999992 c 0.554,0 1,-0.446 1,-1 z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24 },
|
||||
),
|
||||
export const AlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M 19,2 V 22"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="m 16.999996,5.999996 c 0,-0.554 -0.446,-1 -1,-1 H 6.000004 c -0.554,0 -1,0.446 -1,1 v 3.0000001 c 0,0.554 0.446,0.9999999 1,0.9999999 h 9.999992 c 0.554,0 1,-0.4459999 1,-0.9999999 z m 0,9 c 0,-0.554 -0.446,-1 -1,-1 h -5.999992 c -0.554,0 -1,0.446 -1,1 v 3 c 0,0.554 0.446,1 1,1 h 5.999992 c 0.554,0 1,-0.446 1,-1 z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24 },
|
||||
),
|
||||
);
|
||||
|
||||
export const DistributeHorizontallyIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
@ -372,7 +369,7 @@ export const DistributeHorizontallyIcon = React.memo(
|
||||
);
|
||||
|
||||
export const DistributeVerticallyIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
@ -393,31 +390,30 @@ export const DistributeVerticallyIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const CenterVerticallyIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="m 5.000004,16.999996 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 v -10 c 0,-0.554 -0.446,-1 -1,-1 h -3 c -0.554,0 -1,0.446 -1,1 z m 9,-2 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 v -6 c 0,-0.554 -0.446,-1 -1,-1 h -3 c -0.554,0 -1,0.446 -1,1 z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M 2,12 H 22"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeDasharray="1, 2.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24, mirror: true },
|
||||
),
|
||||
export const CenterVerticallyIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="m 5.000004,16.999996 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 v -10 c 0,-0.554 -0.446,-1 -1,-1 h -3 c -0.554,0 -1,0.446 -1,1 z m 9,-2 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 v -6 c 0,-0.554 -0.446,-1 -1,-1 h -3 c -0.554,0 -1,0.446 -1,1 z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M 2,12 H 22"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeDasharray="1, 2.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>,
|
||||
{ width: 24, mirror: true },
|
||||
),
|
||||
);
|
||||
|
||||
export const CenterHorizontallyIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
@ -474,7 +470,12 @@ export const shield = createIcon(
|
||||
{ width: 24 },
|
||||
);
|
||||
|
||||
export const GroupIcon = React.memo(({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const file = createIcon(
|
||||
"M369.9 97.9L286 14C277 5 264.8-.1 252.1-.1H48C21.5 0 0 21.5 0 48v416c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48V131.9c0-12.7-5.1-25-14.1-34zM332.1 128H256V51.9l76.1 76.1zM48 464V48h160v104c0 13.3 10.7 24 24 24h104v288H48zm32-48h224V288l-23.5-23.5c-4.7-4.7-12.3-4.7-17 0L176 352l-39.5-39.5c-4.7-4.7-12.3-4.7-17 0L80 352v64zm48-240c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48z",
|
||||
{ width: 384, height: 512 },
|
||||
);
|
||||
|
||||
export const GroupIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
|
||||
@ -504,73 +505,69 @@ export const GroupIcon = React.memo(({ theme }: { theme: "light" | "dark" }) =>
|
||||
),
|
||||
);
|
||||
|
||||
export const UngroupIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
|
||||
<path
|
||||
d="M25 111C25 80.2068 25 49.4135 25 26M25 26C48.6174 26 72.2348 26 111 26H25ZM25 26C53.3671 26 81.7343 26 111 26H25ZM111 26C111 52.303 111 78.606 111 111V26ZM111 26C111 51.2947 111 76.5893 111 111V26ZM111 111C87.0792 111 63.1585 111 25 111H111ZM111 111C87.4646 111 63.9293 111 25 111H111ZM25 111C25 81.1514 25 51.3028 25 26V111Z"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path d="M100 100H160V160H100" fill={iconFillColor(theme)} />
|
||||
<path
|
||||
d="M100 160C100 144.106 100 128.211 100 100M100 100C117.706 100 135.412 100 160 100H100ZM100 100C114.214 100 128.428 100 160 100H100ZM160 100C160 120.184 160 140.369 160 160V100ZM160 100C160 113.219 160 126.437 160 160V100ZM160 160C145.534 160 131.068 160 100 160H160ZM160 160C143.467 160 126.934 160 100 160H160ZM100 160C100 143.661 100 127.321 100 100V160Z"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<g
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
>
|
||||
<rect x="2.5" y="2.5" width="30" height="30" />
|
||||
<rect x="78.5" y="149.5" width="30" height="30" />
|
||||
<rect x="147.5" y="149.5" width="30" height="30" />
|
||||
<rect x="147.5" y="78.5" width="30" height="30" />
|
||||
<rect x="105.5" y="2.5" width="30" height="30" />
|
||||
<rect x="2.5" y="102.5" width="30" height="30" />
|
||||
</g>
|
||||
</>,
|
||||
{ width: 182, height: 182, mirror: true },
|
||||
),
|
||||
);
|
||||
|
||||
export const FillHachureIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
export const UngroupIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M20.101 16H28.0934L36 8.95989V4H33.5779L20.101 16ZM30.5704 4L17.0935 16H9.10101L22.5779 4H30.5704ZM19.5704 4L6.09349 16H4V10.7475L11.5779 4H19.5704ZM8.57036 4H4V8.06952L8.57036 4ZM36 11.6378L31.101 16H36V11.6378ZM2 2V18H38V2H2Z"
|
||||
fill={iconFillColor(theme)}
|
||||
/>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
d="M25 111C25 80.2068 25 49.4135 25 26M25 26C48.6174 26 72.2348 26 111 26H25ZM25 26C53.3671 26 81.7343 26 111 26H25ZM111 26C111 52.303 111 78.606 111 111V26ZM111 26C111 51.2947 111 76.5893 111 111V26ZM111 111C87.0792 111 63.1585 111 25 111H111ZM111 111C87.4646 111 63.9293 111 25 111H111ZM25 111C25 81.1514 25 51.3028 25 26V111Z"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path d="M100 100H160V160H100" fill={iconFillColor(theme)} />
|
||||
<path
|
||||
d="M100 160C100 144.106 100 128.211 100 100M100 100C117.706 100 135.412 100 160 100H100ZM100 100C114.214 100 128.428 100 160 100H100ZM160 100C160 120.184 160 140.369 160 160V100ZM160 100C160 113.219 160 126.437 160 160V100ZM160 160C145.534 160 131.068 160 100 160H160ZM160 160C143.467 160 126.934 160 100 160H160ZM100 160C100 143.661 100 127.321 100 100V160Z"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<g
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
>
|
||||
<rect x="2.5" y="2.5" width="30" height="30" />
|
||||
<rect x="78.5" y="149.5" width="30" height="30" />
|
||||
<rect x="147.5" y="149.5" width="30" height="30" />
|
||||
<rect x="147.5" y="78.5" width="30" height="30" />
|
||||
<rect x="105.5" y="2.5" width="30" height="30" />
|
||||
<rect x="2.5" y="102.5" width="30" height="30" />
|
||||
</g>
|
||||
</>,
|
||||
{ width: 182, height: 182, mirror: true },
|
||||
),
|
||||
);
|
||||
|
||||
export const FillCrossHatchIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<g fill={iconFillColor(theme)} fillRule="evenodd" clipRule="evenodd">
|
||||
<path d="M20.101 16H28.0934L36 8.95989V4H33.5779L20.101 16ZM30.5704 4L17.0935 16H9.10101L22.5779 4H30.5704ZM19.5704 4L6.09349 16H4V10.7475L11.5779 4H19.5704ZM8.57036 4H4V8.06952L8.57036 4ZM36 11.6378L31.101 16H36V11.6378ZM2 2V18H38V2H2Z" />
|
||||
<path d="M14.0001 18L3.00006 4.00002L4.5727 2.76438L15.5727 16.7644L14.0001 18ZM25.0001 18L14.0001 4.00002L15.5727 2.76438L26.5727 16.7644L25.0001 18ZM36.0001 18L25.0001 4.00002L26.5727 2.76438L37.5727 16.7644L36.0001 18Z" />
|
||||
</g>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
export const FillHachureIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M20.101 16H28.0934L36 8.95989V4H33.5779L20.101 16ZM30.5704 4L17.0935 16H9.10101L22.5779 4H30.5704ZM19.5704 4L6.09349 16H4V10.7475L11.5779 4H19.5704ZM8.57036 4H4V8.06952L8.57036 4ZM36 11.6378L31.101 16H36V11.6378ZM2 2V18H38V2H2Z"
|
||||
fill={iconFillColor(theme)}
|
||||
/>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const FillSolidIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(<path d="M2 2H38V18H2V2Z" fill={iconFillColor(theme)} />, {
|
||||
width: 40,
|
||||
height: 20,
|
||||
}),
|
||||
export const FillCrossHatchIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<g fill={iconFillColor(theme)} fillRule="evenodd" clipRule="evenodd">
|
||||
<path d="M20.101 16H28.0934L36 8.95989V4H33.5779L20.101 16ZM30.5704 4L17.0935 16H9.10101L22.5779 4H30.5704ZM19.5704 4L6.09349 16H4V10.7475L11.5779 4H19.5704ZM8.57036 4H4V8.06952L8.57036 4ZM36 11.6378L31.101 16H36V11.6378ZM2 2V18H38V2H2Z" />
|
||||
<path d="M14.0001 18L3.00006 4.00002L4.5727 2.76438L15.5727 16.7644L14.0001 18ZM25.0001 18L14.0001 4.00002L15.5727 2.76438L26.5727 16.7644L25.0001 18ZM36.0001 18L25.0001 4.00002L26.5727 2.76438L37.5727 16.7644L36.0001 18Z" />
|
||||
</g>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const FillSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(<path d="M2 2H38V18H2V2Z" fill={iconFillColor(theme)} />, {
|
||||
width: 40,
|
||||
height: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
export const StrokeWidthIcon = React.memo(
|
||||
({ theme, strokeWidth }: { theme: "light" | "dark"; strokeWidth: number }) =>
|
||||
({ theme, strokeWidth }: { theme: Theme; strokeWidth: number }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H32"
|
||||
@ -583,55 +580,52 @@ export const StrokeWidthIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const StrokeStyleSolidIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{
|
||||
width: 40,
|
||||
height: 20,
|
||||
},
|
||||
),
|
||||
export const StrokeStyleSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{
|
||||
width: 40,
|
||||
height: 20,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const StrokeStyleDashedIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2.5}
|
||||
strokeDasharray={"10, 8"}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
export const StrokeStyleDashedIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2.5}
|
||||
strokeDasharray={"10, 8"}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const StrokeStyleDottedIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H36"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2.5}
|
||||
strokeDasharray={"2, 4.5"}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
export const StrokeStyleDottedIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H36"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2.5}
|
||||
strokeDasharray={"2, 4.5"}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const SloppinessArchitectIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M3.00098 16.1691C6.28774 13.9744 19.6399 2.8905 22.7215 3.00082C25.8041 3.11113 19.1158 15.5488 21.4962 16.8309C23.8757 18.1131 34.4155 11.7148 37.0001 10.6919"
|
||||
@ -644,22 +638,21 @@ export const SloppinessArchitectIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const SloppinessArtistIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M3 17C6.68158 14.8752 16.1296 9.09849 22.0648 6.54922C28 3.99995 22.2896 13.3209 25 14C27.7104 14.6791 36.3757 9.6471 36.3757 9.6471M6.40706 15C13 11.1918 20.0468 1.51045 23.0234 3.0052C26 4.49995 20.457 12.8659 22.7285 16.4329C25 20 36.3757 13 36.3757 13"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>,
|
||||
{ width: 40, height: 20, mirror: true },
|
||||
),
|
||||
export const SloppinessArtistIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M3 17C6.68158 14.8752 16.1296 9.09849 22.0648 6.54922C28 3.99995 22.2896 13.3209 25 14C27.7104 14.6791 36.3757 9.6471 36.3757 9.6471M6.40706 15C13 11.1918 20.0468 1.51045 23.0234 3.0052C26 4.49995 20.457 12.8659 22.7285 16.4329C25 20 36.3757 13 36.3757 13"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>,
|
||||
{ width: 40, height: 20, mirror: true },
|
||||
),
|
||||
);
|
||||
|
||||
export const SloppinessCartoonistIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M3 15.6468C6.93692 13.5378 22.5544 2.81528 26.6206 3.00242C30.6877 3.18956 25.6708 15.3346 27.4009 16.7705C29.1309 18.2055 35.4001 12.4762 37 11.6177M3.97143 10.4917C6.61158 9.24563 16.3706 2.61886 19.8104 3.01724C23.2522 3.41472 22.0773 12.2013 24.6181 12.8783C27.1598 13.5536 33.3179 8.04068 35.0571 7.07244"
|
||||
@ -672,52 +665,49 @@ export const SloppinessCartoonistIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const EdgeSharpIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M10 17L10 5L35 5"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>,
|
||||
{ width: 40, height: 20, mirror: true },
|
||||
),
|
||||
export const EdgeSharpIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M10 17L10 5L35 5"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>,
|
||||
{ width: 40, height: 20, mirror: true },
|
||||
),
|
||||
);
|
||||
|
||||
export const EdgeRoundIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M10 17V15C10 8 13 5 21 5L33.5 5"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>,
|
||||
{ width: 40, height: 20, mirror: true },
|
||||
),
|
||||
export const EdgeRoundIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M10 17V15C10 8 13 5 21 5L33.5 5"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>,
|
||||
{ width: 40, height: 20, mirror: true },
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadNoneIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>,
|
||||
{
|
||||
width: 40,
|
||||
height: 20,
|
||||
},
|
||||
),
|
||||
export const ArrowheadNoneIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>,
|
||||
{
|
||||
width: 40,
|
||||
height: 20,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadArrowIcon = React.memo(
|
||||
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
|
||||
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
@ -733,7 +723,7 @@ export const ArrowheadArrowIcon = React.memo(
|
||||
);
|
||||
|
||||
export const ArrowheadDotIcon = React.memo(
|
||||
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
|
||||
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke={iconFillColor(theme)}
|
||||
@ -748,7 +738,7 @@ export const ArrowheadDotIcon = React.memo(
|
||||
);
|
||||
|
||||
export const ArrowheadBarIcon = React.memo(
|
||||
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
|
||||
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
|
||||
<path
|
||||
@ -762,41 +752,38 @@ export const ArrowheadBarIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const FontSizeSmallIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 0 69.092 L 0 55.03 A 124.24 124.24 0 0 0 4.706 57.02 Q 6.826 57.863 8.708 58.5 A 53.466 53.466 0 0 0 12.231 59.571 Q 17.236 60.889 21.387 60.889 A 20.909 20.909 0 0 0 24.265 60.704 Q 25.719 60.502 26.903 60.077 A 8.649 8.649 0 0 0 29.028 58.985 Q 31.689 57.08 31.689 53.321 Q 31.689 51.221 30.518 49.585 A 10.126 10.126 0 0 0 29.282 48.177 Q 28.352 47.287 27.075 46.436 A 23.719 23.719 0 0 0 25.752 45.627 Q 23.774 44.492 20.176 42.735 A 254.44 254.44 0 0 0 17.822 41.602 Q 11.503 38.631 8.236 35.888 A 19.742 19.742 0 0 1 8.008 35.694 A 22.18 22.18 0 0 1 2.783 29.102 Q 0.83 25.342 0.83 20.313 A 22.471 22.471 0 0 1 1.733 13.778 A 17.283 17.283 0 0 1 7.251 5.42 A 21.486 21.486 0 0 1 15.177 1.272 Q 18.361 0.338 22.166 0.09 A 43.573 43.573 0 0 1 25 0 A 42.399 42.399 0 0 1 34.349 1.01 A 39.075 39.075 0 0 1 35.62 1.319 A 67.407 67.407 0 0 1 42.108 3.382 A 83.357 83.357 0 0 1 46.191 5.03 L 41.309 16.797 Q 35.596 14.453 31.86 13.526 A 30.762 30.762 0 0 0 25.417 12.612 A 28.337 28.337 0 0 0 24.512 12.598 A 14.846 14.846 0 0 0 22.022 12.793 Q 19.498 13.224 17.92 14.6 Q 15.625 16.602 15.625 19.824 Q 15.625 21.826 16.553 23.316 Q 17.48 24.805 19.507 26.197 A 18.343 18.343 0 0 0 20.659 26.912 Q 22.596 28.035 26.516 29.953 A 299.99 299.99 0 0 0 29.102 31.201 Q 37.91 35.412 41.841 39.642 A 16.553 16.553 0 0 1 42.822 40.796 A 17.675 17.675 0 0 1 46.301 49.233 A 23.517 23.517 0 0 1 46.533 52.588 A 21.581 21.581 0 0 1 45.471 59.515 A 17.733 17.733 0 0 1 39.575 67.823 Q 33.745 72.486 24.094 73.243 A 49.683 49.683 0 0 1 20.215 73.389 A 51.712 51.712 0 0 1 9.448 72.315 A 40.672 40.672 0 0 1 0 69.092 Z"
|
||||
/>,
|
||||
{ width: 47, height: 77 },
|
||||
),
|
||||
export const FontSizeSmallIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 0 69.092 L 0 55.03 A 124.24 124.24 0 0 0 4.706 57.02 Q 6.826 57.863 8.708 58.5 A 53.466 53.466 0 0 0 12.231 59.571 Q 17.236 60.889 21.387 60.889 A 20.909 20.909 0 0 0 24.265 60.704 Q 25.719 60.502 26.903 60.077 A 8.649 8.649 0 0 0 29.028 58.985 Q 31.689 57.08 31.689 53.321 Q 31.689 51.221 30.518 49.585 A 10.126 10.126 0 0 0 29.282 48.177 Q 28.352 47.287 27.075 46.436 A 23.719 23.719 0 0 0 25.752 45.627 Q 23.774 44.492 20.176 42.735 A 254.44 254.44 0 0 0 17.822 41.602 Q 11.503 38.631 8.236 35.888 A 19.742 19.742 0 0 1 8.008 35.694 A 22.18 22.18 0 0 1 2.783 29.102 Q 0.83 25.342 0.83 20.313 A 22.471 22.471 0 0 1 1.733 13.778 A 17.283 17.283 0 0 1 7.251 5.42 A 21.486 21.486 0 0 1 15.177 1.272 Q 18.361 0.338 22.166 0.09 A 43.573 43.573 0 0 1 25 0 A 42.399 42.399 0 0 1 34.349 1.01 A 39.075 39.075 0 0 1 35.62 1.319 A 67.407 67.407 0 0 1 42.108 3.382 A 83.357 83.357 0 0 1 46.191 5.03 L 41.309 16.797 Q 35.596 14.453 31.86 13.526 A 30.762 30.762 0 0 0 25.417 12.612 A 28.337 28.337 0 0 0 24.512 12.598 A 14.846 14.846 0 0 0 22.022 12.793 Q 19.498 13.224 17.92 14.6 Q 15.625 16.602 15.625 19.824 Q 15.625 21.826 16.553 23.316 Q 17.48 24.805 19.507 26.197 A 18.343 18.343 0 0 0 20.659 26.912 Q 22.596 28.035 26.516 29.953 A 299.99 299.99 0 0 0 29.102 31.201 Q 37.91 35.412 41.841 39.642 A 16.553 16.553 0 0 1 42.822 40.796 A 17.675 17.675 0 0 1 46.301 49.233 A 23.517 23.517 0 0 1 46.533 52.588 A 21.581 21.581 0 0 1 45.471 59.515 A 17.733 17.733 0 0 1 39.575 67.823 Q 33.745 72.486 24.094 73.243 A 49.683 49.683 0 0 1 20.215 73.389 A 51.712 51.712 0 0 1 9.448 72.315 A 40.672 40.672 0 0 1 0 69.092 Z"
|
||||
/>,
|
||||
{ width: 47, height: 77 },
|
||||
),
|
||||
);
|
||||
|
||||
export const FontSizeMediumIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 44.092 71.387 L 30.225 71.387 L 13.037 15.381 L 12.598 15.381 A 1505.093 1505.093 0 0 1 12.959 22.313 Q 13.426 31.715 13.508 36.4 A 102.991 102.991 0 0 1 13.525 38.184 L 13.525 71.387 L 0 71.387 L 0 0 L 20.605 0 L 37.5 54.59 L 37.793 54.59 L 55.713 0 L 76.318 0 L 76.318 71.387 L 62.207 71.387 L 62.207 37.598 Q 62.207 35.205 62.28 32.08 A 160.703 160.703 0 0 1 62.326 30.544 Q 62.452 26.754 62.866 17.168 A 5390.536 5390.536 0 0 1 62.939 15.479 L 62.5 15.479 L 44.092 71.387 Z"
|
||||
/>,
|
||||
{ width: 77, height: 75 },
|
||||
),
|
||||
export const FontSizeMediumIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 44.092 71.387 L 30.225 71.387 L 13.037 15.381 L 12.598 15.381 A 1505.093 1505.093 0 0 1 12.959 22.313 Q 13.426 31.715 13.508 36.4 A 102.991 102.991 0 0 1 13.525 38.184 L 13.525 71.387 L 0 71.387 L 0 0 L 20.605 0 L 37.5 54.59 L 37.793 54.59 L 55.713 0 L 76.318 0 L 76.318 71.387 L 62.207 71.387 L 62.207 37.598 Q 62.207 35.205 62.28 32.08 A 160.703 160.703 0 0 1 62.326 30.544 Q 62.452 26.754 62.866 17.168 A 5390.536 5390.536 0 0 1 62.939 15.479 L 62.5 15.479 L 44.092 71.387 Z"
|
||||
/>,
|
||||
{ width: 77, height: 75 },
|
||||
),
|
||||
);
|
||||
|
||||
export const FontSizeLargeIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 44.092 71.387 L 0 71.387 L 0 0 L 15.137 0 L 15.137 58.887 L 44.092 58.887 L 44.092 71.387 Z"
|
||||
/>,
|
||||
{ width: 45, height: 75 },
|
||||
),
|
||||
export const FontSizeLargeIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 44.092 71.387 L 0 71.387 L 0 0 L 15.137 0 L 15.137 58.887 L 44.092 58.887 L 44.092 71.387 Z"
|
||||
/>,
|
||||
{ width: 45, height: 75 },
|
||||
),
|
||||
);
|
||||
|
||||
export const FontSizeExtraLargeIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
@ -807,7 +794,7 @@ export const FontSizeExtraLargeIcon = React.memo(
|
||||
);
|
||||
|
||||
export const FontFamilyHandDrawnIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
@ -817,67 +804,62 @@ export const FontFamilyHandDrawnIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const FontFamilyNormalIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 63.818 71.68 L 54.492 71.68 L 45.898 49.561 L 17.578 49.561 L 9.082 71.68 L 0 71.68 L 27.881 0 L 35.986 0 L 63.818 71.68 Z M 20.605 41.602 L 43.213 41.602 L 35.205 19.971 L 31.787 9.277 Q 30.322 15.137 28.711 19.971 L 20.605 41.602 Z"
|
||||
/>
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 68.994 71.68 L 52.686 71.68 L 47.51 54.688 L 21.484 54.688 L 16.309 71.68 L 0 71.68 L 25.195 0 L 43.701 0 L 68.994 71.68 Z M 25.293 41.992 L 43.896 41.992 A 27590.463 27590.463 0 0 1 42.2 36.532 Q 36.965 19.676 35.937 16.273 A 120.932 120.932 0 0 1 35.815 15.869 A 131.65 131.65 0 0 1 35.396 14.435 Q 34.951 12.879 34.675 11.741 A 34.866 34.866 0 0 1 34.521 11.084 A 141.762 141.762 0 0 1 33.706 14.075 Q 31.482 21.957 25.293 41.992 Z"
|
||||
/>
|
||||
</>,
|
||||
{ width: 70, height: 78 },
|
||||
),
|
||||
);
|
||||
|
||||
export const FontFamilyCodeIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M278.9 511.5l-61-17.7c-6.4-1.8-10-8.5-8.2-14.9L346.2 8.7c1.8-6.4 8.5-10 14.9-8.2l61 17.7c6.4 1.8 10 8.5 8.2 14.9L293.8 503.3c-1.9 6.4-8.5 10.1-14.9 8.2zm-114-112.2l43.5-46.4c4.6-4.9 4.3-12.7-.8-17.2L117 256l90.6-79.7c5.1-4.5 5.5-12.3.8-17.2l-43.5-46.4c-4.5-4.8-12.1-5.1-17-.5L3.8 247.2c-5.1 4.7-5.1 12.8 0 17.5l144.1 135.1c4.9 4.6 12.5 4.4 17-.5zm327.2.6l144.1-135.1c5.1-4.7 5.1-12.8 0-17.5L492.1 112.1c-4.8-4.5-12.4-4.3-17 .5L431.6 159c-4.6 4.9-4.3 12.7.8 17.2L523 256l-90.6 79.7c-5.1 4.5-5.5 12.3-.8 17.2l43.5 46.4c4.5 4.9 12.1 5.1 17 .6z"
|
||||
/>
|
||||
</>,
|
||||
{ width: 640, height: 512 },
|
||||
),
|
||||
);
|
||||
|
||||
export const TextAlignLeftIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
export const FontFamilyNormalIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M12.83 352h262.34A12.82 12.82 0 00288 339.17v-38.34A12.82 12.82 0 00275.17 288H12.83A12.82 12.82 0 000 300.83v38.34A12.82 12.82 0 0012.83 352zm0-256h262.34A12.82 12.82 0 00288 83.17V44.83A12.82 12.82 0 00275.17 32H12.83A12.82 12.82 0 000 44.83v38.34A12.82 12.82 0 0012.83 96zM432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16z"
|
||||
fill={iconFillColor(theme)}
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
d="M 63.818 71.68 L 54.492 71.68 L 45.898 49.561 L 17.578 49.561 L 9.082 71.68 L 0 71.68 L 27.881 0 L 35.986 0 L 63.818 71.68 Z M 20.605 41.602 L 43.213 41.602 L 35.205 19.971 L 31.787 9.277 Q 30.322 15.137 28.711 19.971 L 20.605 41.602 Z"
|
||||
/>
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 68.994 71.68 L 52.686 71.68 L 47.51 54.688 L 21.484 54.688 L 16.309 71.68 L 0 71.68 L 25.195 0 L 43.701 0 L 68.994 71.68 Z M 25.293 41.992 L 43.896 41.992 A 27590.463 27590.463 0 0 1 42.2 36.532 Q 36.965 19.676 35.937 16.273 A 120.932 120.932 0 0 1 35.815 15.869 A 131.65 131.65 0 0 1 35.396 14.435 Q 34.951 12.879 34.675 11.741 A 34.866 34.866 0 0 1 34.521 11.084 A 141.762 141.762 0 0 1 33.706 14.075 Q 31.482 21.957 25.293 41.992 Z"
|
||||
/>
|
||||
</>,
|
||||
{ width: 70, height: 78 },
|
||||
),
|
||||
);
|
||||
|
||||
export const TextAlignCenterIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
export const FontFamilyCodeIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zM108.1 96h231.81A12.09 12.09 0 00352 83.9V44.09A12.09 12.09 0 00339.91 32H108.1A12.09 12.09 0 0096 44.09V83.9A12.1 12.1 0 00108.1 96zm231.81 256A12.09 12.09 0 00352 339.9v-39.81A12.09 12.09 0 00339.91 288H108.1A12.09 12.09 0 0096 300.09v39.81a12.1 12.1 0 0012.1 12.1z"
|
||||
fill={iconFillColor(theme)}
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
d="M278.9 511.5l-61-17.7c-6.4-1.8-10-8.5-8.2-14.9L346.2 8.7c1.8-6.4 8.5-10 14.9-8.2l61 17.7c6.4 1.8 10 8.5 8.2 14.9L293.8 503.3c-1.9 6.4-8.5 10.1-14.9 8.2zm-114-112.2l43.5-46.4c4.6-4.9 4.3-12.7-.8-17.2L117 256l90.6-79.7c5.1-4.5 5.5-12.3.8-17.2l-43.5-46.4c-4.5-4.8-12.1-5.1-17-.5L3.8 247.2c-5.1 4.7-5.1 12.8 0 17.5l144.1 135.1c4.9 4.6 12.5 4.4 17-.5zm327.2.6l144.1-135.1c5.1-4.7 5.1-12.8 0-17.5L492.1 112.1c-4.8-4.5-12.4-4.3-17 .5L431.6 159c-4.6 4.9-4.3 12.7.8 17.2L523 256l-90.6 79.7c-5.1 4.5-5.5 12.3-.8 17.2l43.5 46.4c4.5 4.9 12.1 5.1 17 .6z"
|
||||
/>
|
||||
</>,
|
||||
{ width: 640, height: 512 },
|
||||
),
|
||||
);
|
||||
|
||||
export const TextAlignRightIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M16 224h416a16 16 0 0016-16v-32a16 16 0 00-16-16H16a16 16 0 00-16 16v32a16 16 0 0016 16zm416 192H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm3.17-384H172.83A12.82 12.82 0 00160 44.83v38.34A12.82 12.82 0 00172.83 96h262.34A12.82 12.82 0 00448 83.17V44.83A12.82 12.82 0 00435.17 32zm0 256H172.83A12.82 12.82 0 00160 300.83v38.34A12.82 12.82 0 00172.83 352h262.34A12.82 12.82 0 00448 339.17v-38.34A12.82 12.82 0 00435.17 288z"
|
||||
fill={iconFillColor(theme)}
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
export const TextAlignLeftIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M12.83 352h262.34A12.82 12.82 0 00288 339.17v-38.34A12.82 12.82 0 00275.17 288H12.83A12.82 12.82 0 000 300.83v38.34A12.82 12.82 0 0012.83 352zm0-256h262.34A12.82 12.82 0 00288 83.17V44.83A12.82 12.82 0 00275.17 32H12.83A12.82 12.82 0 000 44.83v38.34A12.82 12.82 0 0012.83 96zM432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16z"
|
||||
fill={iconFillColor(theme)}
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
);
|
||||
|
||||
export const TextAlignCenterIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zM108.1 96h231.81A12.09 12.09 0 00352 83.9V44.09A12.09 12.09 0 00339.91 32H108.1A12.09 12.09 0 0096 44.09V83.9A12.1 12.1 0 00108.1 96zm231.81 256A12.09 12.09 0 00352 339.9v-39.81A12.09 12.09 0 00339.91 288H108.1A12.09 12.09 0 0096 300.09v39.81a12.1 12.1 0 0012.1 12.1z"
|
||||
fill={iconFillColor(theme)}
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
);
|
||||
|
||||
export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M16 224h416a16 16 0 0016-16v-32a16 16 0 00-16-16H16a16 16 0 00-16 16v32a16 16 0 0016 16zm416 192H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm3.17-384H172.83A12.82 12.82 0 00160 44.83v38.34A12.82 12.82 0 00172.83 96h262.34A12.82 12.82 0 00448 83.17V44.83A12.82 12.82 0 00435.17 32zm0 256H172.83A12.82 12.82 0 00160 300.83v38.34A12.82 12.82 0 00172.83 352h262.34A12.82 12.82 0 00448 339.17v-38.34A12.82 12.82 0 00435.17 288z"
|
||||
fill={iconFillColor(theme)}
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
);
|
||||
|
@ -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,7 @@ export const GRID_SIZE = 20; // TODO make it configurable?
|
||||
export const MIME_TYPES = {
|
||||
excalidraw: "application/vnd.excalidraw+json",
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const EXPORT_DATA_TYPES = {
|
||||
excalidraw: "excalidraw",
|
||||
@ -131,11 +138,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 +151,6 @@ 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
|
||||
|
@ -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 {
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { EXPORT_DATA_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { FileSystemHandle } from "./filesystem";
|
||||
import { isValidExcalidrawData } from "./json";
|
||||
import { restore } from "./restore";
|
||||
import { ImportedLibraryData } from "./types";
|
||||
@ -79,10 +81,30 @@ export const getMimeType = (blob: Blob | string): string => {
|
||||
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 loadFromBlob = async (
|
||||
blob: Blob,
|
||||
/** @see restore.localAppState */
|
||||
localAppState: AppState | null,
|
||||
localElements: readonly ExcalidrawElement[] | null,
|
||||
) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
try {
|
||||
@ -95,7 +117,7 @@ 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)
|
||||
@ -103,6 +125,7 @@ export const loadFromBlob = async (
|
||||
},
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
);
|
||||
|
||||
return result;
|
||||
|
122
src/data/filesystem.ts
Normal file
122
src/data/filesystem.ts
Normal file
@ -0,0 +1,122 @@
|
||||
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 =
|
||||
| "jpg"
|
||||
| "png"
|
||||
| "svg"
|
||||
| "json"
|
||||
| "excalidraw"
|
||||
| "excalidrawlib";
|
||||
|
||||
const FILE_TYPE_TO_MIME_TYPE: Record<FILE_EXTENSION, string> = {
|
||||
jpg: "image/jpeg",
|
||||
png: "image/png",
|
||||
svg: "image/svg+xml",
|
||||
json: "application/json",
|
||||
excalidraw: MIME_TYPES.excalidraw,
|
||||
excalidrawlib: MIME_TYPES.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(FILE_TYPE_TO_MIME_TYPE[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,14 +1,15 @@
|
||||
import { fileSave } from "browser-fs-access";
|
||||
import {
|
||||
copyBlobToClipboardAsPng,
|
||||
copyTextToSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { DEFAULT_EXPORT_PADDING } 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 { canvasToBlob } from "./blob";
|
||||
import { fileSave, FileSystemHandle } from "./filesystem";
|
||||
import { serializeAsJSON } from "./json";
|
||||
|
||||
export { loadFromBlob } from "./blob";
|
||||
@ -20,45 +21,41 @@ export const exportCanvas = async (
|
||||
appState: AppState,
|
||||
{
|
||||
exportBackground,
|
||||
exportPadding = 10,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
viewBackgroundColor,
|
||||
name,
|
||||
scale = 1,
|
||||
fileHandle = null,
|
||||
}: {
|
||||
exportBackground: boolean;
|
||||
exportPadding?: number;
|
||||
viewBackgroundColor: string;
|
||||
name: string;
|
||||
scale?: number;
|
||||
fileHandle?: FileSystemHandle | null;
|
||||
},
|
||||
) => {
|
||||
if (elements.length === 0) {
|
||||
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||
}
|
||||
if (type === "svg" || type === "clipboard-svg") {
|
||||
const tempSvg = exportToSvg(elements, {
|
||||
const tempSvg = await exportToSvg(elements, {
|
||||
exportBackground,
|
||||
exportWithDarkMode: appState.exportWithDarkMode,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
scale,
|
||||
metadata:
|
||||
appState.exportEmbedScene && type === "svg"
|
||||
? await (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
).encodeSvgMetadata({
|
||||
text: serializeAsJSON(elements, appState),
|
||||
})
|
||||
: undefined,
|
||||
exportScale: appState.exportScale,
|
||||
exportEmbedScene: appState.exportEmbedScene && type === "svg",
|
||||
});
|
||||
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: "image/svg+xml" }),
|
||||
{
|
||||
name,
|
||||
extension: "svg",
|
||||
fileHandle,
|
||||
},
|
||||
);
|
||||
} else if (type === "clipboard-svg") {
|
||||
copyTextToSystemClipboard(tempSvg.outerHTML);
|
||||
await copyTextToSystemClipboard(tempSvg.outerHTML);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -67,7 +64,6 @@ export const exportCanvas = async (
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
scale,
|
||||
});
|
||||
tempCanvas.style.display = "none";
|
||||
document.body.appendChild(tempCanvas);
|
||||
@ -75,7 +71,6 @@ export const exportCanvas = async (
|
||||
tempCanvas.remove();
|
||||
|
||||
if (type === "png") {
|
||||
const fileName = `${name}.png`;
|
||||
if (appState.exportEmbedScene) {
|
||||
blob = await (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
@ -85,9 +80,10 @@ export const exportCanvas = async (
|
||||
});
|
||||
}
|
||||
|
||||
await fileSave(blob, {
|
||||
fileName,
|
||||
extensions: [".png"],
|
||||
return await fileSave(blob, {
|
||||
name,
|
||||
extension: "png",
|
||||
fileHandle,
|
||||
});
|
||||
} else if (type === "clipboard") {
|
||||
try {
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { fileOpen, fileSave } from "browser-fs-access";
|
||||
import { fileOpen, fileSave } from "./filesystem";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { loadFromBlob } from "./blob";
|
||||
import { isImageFileHandle, loadFromBlob } from "./blob";
|
||||
|
||||
import {
|
||||
ExportedDataState,
|
||||
@ -15,7 +15,7 @@ import Library from "./library";
|
||||
|
||||
export const serializeAsJSON = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
appState: Partial<AppState>,
|
||||
): string => {
|
||||
const data: ExportedDataState = {
|
||||
type: EXPORT_DATA_TYPES.excalidraw,
|
||||
@ -37,19 +37,21 @@ export const saveAsJSON = async (
|
||||
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
|
||||
@ -64,7 +66,7 @@ export const loadFromJSON = async (localAppState: AppState) => {
|
||||
],
|
||||
*/
|
||||
});
|
||||
return loadFromBlob(blob, localAppState);
|
||||
return loadFromBlob(blob, localAppState, localElements);
|
||||
};
|
||||
|
||||
export const isValidExcalidrawData = (data?: {
|
||||
@ -98,15 +100,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,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;
|
||||
};
|
||||
|
||||
|
38
src/data/resave.ts
Normal file
38
src/data/resave.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { exportCanvas } from ".";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { getFileHandleType, isImageFileHandleType } from "./blob";
|
||||
|
||||
export const resaveAsImageWithScene = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
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,
|
||||
{
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
name,
|
||||
fileHandle,
|
||||
},
|
||||
);
|
||||
|
||||
return { fileHandle };
|
||||
};
|
@ -1,21 +1,26 @@
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FontFamily,
|
||||
ExcalidrawSelectionElement,
|
||||
FontFamilyValues,
|
||||
} from "../element/types";
|
||||
import { AppState, 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,
|
||||
@ -41,11 +46,11 @@ export type RestoredDataState = {
|
||||
appState: RestoredAppState;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@ -181,13 +186,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 = 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 +209,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 +255,10 @@ 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),
|
||||
};
|
||||
};
|
||||
|
@ -328,15 +328,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]);
|
||||
|
||||
|
@ -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,
|
||||
|
@ -120,8 +120,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",
|
||||
});
|
||||
|
@ -5,11 +5,11 @@ import {
|
||||
ExcalidrawGenericElement,
|
||||
NonDeleted,
|
||||
TextAlign,
|
||||
FontFamily,
|
||||
GroupId,
|
||||
VerticalAlign,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
FontFamilyValues,
|
||||
} from "../element/types";
|
||||
import { measureText, getFontString } from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
@ -109,7 +109,7 @@ export const newTextElement = (
|
||||
opts: {
|
||||
text: string;
|
||||
fontSize: number;
|
||||
fontFamily: FontFamily;
|
||||
fontFamily: FontFamilyValues;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
} & ElementConstructorOpts,
|
||||
@ -307,7 +307,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) {
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { Point } from "../types";
|
||||
import { FONT_FAMILY } from "../constants";
|
||||
import { FONT_FAMILY, THEME } from "../constants";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid";
|
||||
export type FontFamily = keyof typeof FONT_FAMILY;
|
||||
export type FontFamilyKeys = keyof typeof FONT_FAMILY;
|
||||
export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];
|
||||
export type Theme = typeof THEME[keyof typeof THEME];
|
||||
export type FontString = string & { _brand: "fontString" };
|
||||
export type GroupId = string;
|
||||
export type PointerType = "mouse" | "pen" | "touch";
|
||||
@ -91,7 +93,7 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "text";
|
||||
fontSize: number;
|
||||
fontFamily: FontFamily;
|
||||
fontFamily: FontFamilyValues;
|
||||
text: string;
|
||||
baseline: number;
|
||||
textAlign: TextAlign;
|
||||
|
@ -1,4 +1,5 @@
|
||||
type CANVAS_ERROR_NAMES = "CANVAS_ERROR" | "CANVAS_POSSIBLY_TOO_BIG";
|
||||
|
||||
export class CanvasError extends Error {
|
||||
constructor(
|
||||
message: string = "Couldn't export canvas.",
|
||||
@ -9,3 +10,9 @@ export class CanvasError extends Error {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
export class AbortError extends DOMException {
|
||||
constructor(message: string = "Request Aborted") {
|
||||
super(message, "AbortError");
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { debounce, getVersion, nFormatter } from "../utils";
|
||||
import {
|
||||
getElementsStorageSize,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import throttle from "lodash.throttle";
|
||||
import React, { PureComponent } from "react";
|
||||
import { ExcalidrawImperativeAPI } from "../../components/App";
|
||||
import { PureComponent } from "react";
|
||||
import { ExcalidrawImperativeAPI } from "../../types";
|
||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||
import { APP_NAME, ENV, EVENT } from "../../constants";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
@ -41,6 +41,7 @@ import { UserIdleState } from "../../types";
|
||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { isInvisiblySmallElement } from "../../element";
|
||||
import { getRandomUsername } from "@excalidraw/random-username";
|
||||
|
||||
interface CollabState {
|
||||
modalIsShown: boolean;
|
||||
@ -223,6 +224,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.state.username) {
|
||||
this.updateUsername(getRandomUsername());
|
||||
}
|
||||
|
||||
let roomId;
|
||||
let roomKey;
|
||||
|
||||
@ -593,7 +598,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
this.setState({ modalIsShown: false });
|
||||
};
|
||||
|
||||
onUsernameChange = (username: string) => {
|
||||
updateUsername = (username: string) => {
|
||||
this.setState({ username });
|
||||
saveUsernameToLocalStorage(username);
|
||||
};
|
||||
@ -635,7 +640,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
handleClose={this.handleClose}
|
||||
activeRoomLink={activeRoomLink}
|
||||
username={username}
|
||||
onUsernameChange={this.onUsernameChange}
|
||||
onUsernameChange={this.updateUsername}
|
||||
onRoomCreate={this.openPortal}
|
||||
onRoomDestroy={this.closePortal}
|
||||
setErrorMessage={(errorMessage) => {
|
||||
|
@ -14,6 +14,7 @@ import { t } from "../../i18n";
|
||||
import "./RoomDialog.scss";
|
||||
import Stack from "../../components/Stack";
|
||||
import { AppState } from "../../types";
|
||||
import { getRandomUsername } from "@excalidraw/random-username";
|
||||
|
||||
const getShareIcon = () => {
|
||||
const navigator = window.navigator as any;
|
||||
@ -137,9 +138,14 @@ const RoomDialog = ({
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
value={username || ""}
|
||||
value={username}
|
||||
className="RoomDialog-username TextInput"
|
||||
onChange={(event) => onUsernameChange(event.target.value)}
|
||||
onBlur={(event) => {
|
||||
if (!event.target.value.trim()) {
|
||||
onUsernameChange(getRandomUsername());
|
||||
}
|
||||
}}
|
||||
onKeyPress={(event) => event.key === "Enter" && handleClose()}
|
||||
/>
|
||||
</div>
|
||||
|
92
src/excalidraw-app/components/ExportToExcalidrawPlus.tsx
Normal file
92
src/excalidraw-app/components/ExportToExcalidrawPlus.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { Card } from "../../components/Card";
|
||||
import { ToolButton } from "../../components/ToolButton";
|
||||
import { serializeAsJSON } from "../../data/json";
|
||||
import { getImportedKey, createIV, generateEncryptionKey } from "../data";
|
||||
import { loadFirebaseStorage } from "../data/firebase";
|
||||
import { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import { AppState } from "../../types";
|
||||
import { nanoid } from "nanoid";
|
||||
import { t } from "../../i18n";
|
||||
import { excalidrawPlusIcon } from "./icons";
|
||||
|
||||
const encryptData = async (
|
||||
key: string,
|
||||
json: string,
|
||||
): Promise<{ blob: Blob; iv: Uint8Array }> => {
|
||||
const importedKey = await getImportedKey(key, "encrypt");
|
||||
const iv = createIV();
|
||||
const encoded = new TextEncoder().encode(json);
|
||||
const ciphertext = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv,
|
||||
},
|
||||
importedKey,
|
||||
encoded,
|
||||
);
|
||||
|
||||
return { blob: new Blob([new Uint8Array(ciphertext)]), iv };
|
||||
};
|
||||
|
||||
const exportToExcalidrawPlus = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const firebase = await loadFirebaseStorage();
|
||||
|
||||
const id = `${nanoid(12)}`;
|
||||
|
||||
const key = (await generateEncryptionKey())!;
|
||||
const encryptedData = await encryptData(
|
||||
key,
|
||||
serializeAsJSON(elements, appState),
|
||||
);
|
||||
|
||||
const blob = new Blob([encryptedData.iv, encryptedData.blob], {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
|
||||
await firebase
|
||||
.storage()
|
||||
.ref(`/migrations/scenes/${id}`)
|
||||
.put(blob, {
|
||||
customMetadata: {
|
||||
data: JSON.stringify({ version: 1, name: appState.name }),
|
||||
created: Date.now().toString(),
|
||||
},
|
||||
});
|
||||
|
||||
window.open(`https://plus.excalidraw.com/import?excalidraw=${id},${key}`);
|
||||
};
|
||||
|
||||
export const ExportToExcalidrawPlus: React.FC<{
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
onError: (error: Error) => void;
|
||||
}> = ({ elements, appState, onError }) => {
|
||||
return (
|
||||
<Card color="indigo">
|
||||
<div className="Card-icon">{excalidrawPlusIcon}</div>
|
||||
<h2>Excalidraw+</h2>
|
||||
<div className="Card-details">
|
||||
{t("exportDialog.excalidrawplus_description")}
|
||||
</div>
|
||||
<ToolButton
|
||||
className="Card-button"
|
||||
type="button"
|
||||
title={t("exportDialog.excalidrawplus_button")}
|
||||
aria-label={t("exportDialog.excalidrawplus_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await exportToExcalidrawPlus(elements, appState);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
onError(new Error(t("exportDialog.excalidrawplus_exportError")));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
@ -1,9 +1,11 @@
|
||||
import oc from "open-color";
|
||||
import React from "react";
|
||||
import { THEME } from "../../constants";
|
||||
import { Theme } from "../../element/types";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
||||
({ theme, dir }: { theme: "light" | "dark"; dir: string }) => (
|
||||
({ theme, dir }: { theme: Theme; dir: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
@ -25,18 +27,18 @@ export const GitHubCorner = React.memo(
|
||||
>
|
||||
<path
|
||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||
fill={theme === "light" ? oc.gray[6] : oc.gray[7]}
|
||||
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
|
||||
/>
|
||||
<path
|
||||
className="octo-arm"
|
||||
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
|
||||
style={{ transformOrigin: "130px 106px" }}
|
||||
fill={theme === "light" ? oc.white : "var(--default-bg-color)"}
|
||||
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
||||
/>
|
||||
<path
|
||||
className="octo-body"
|
||||
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
|
||||
fill={theme === "light" ? oc.white : "var(--default-bg-color)"}
|
||||
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
||||
/>
|
||||
</a>
|
||||
</svg>
|
||||
|
@ -1,23 +1,18 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import * as i18n from "../../i18n";
|
||||
|
||||
export const LanguageList = ({
|
||||
onChange,
|
||||
languages = i18n.languages,
|
||||
currentLangCode = i18n.getLanguage().code,
|
||||
floating,
|
||||
}: {
|
||||
languages?: { code: string; label: string }[];
|
||||
onChange: (langCode: i18n.Language["code"]) => void;
|
||||
currentLangCode?: i18n.Language["code"];
|
||||
floating?: boolean;
|
||||
}) => (
|
||||
<React.Fragment>
|
||||
<select
|
||||
className={clsx("dropdown-select dropdown-select__language", {
|
||||
"dropdown-select--floating": floating,
|
||||
})}
|
||||
className="dropdown-select dropdown-select__language"
|
||||
onChange={({ target }) => onChange(target.value)}
|
||||
value={currentLangCode}
|
||||
aria-label={i18n.t("buttons.selectLanguage")}
|
||||
|
19
src/excalidraw-app/components/icons.tsx
Normal file
19
src/excalidraw-app/components/icons.tsx
Normal file
File diff suppressed because one or more lines are too long
@ -5,15 +5,19 @@ import { getSceneVersion } from "../../element";
|
||||
import Portal from "../collab/Portal";
|
||||
import { restoreElements } from "../../data/restore";
|
||||
|
||||
// private
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
let firebasePromise: Promise<
|
||||
typeof import("firebase/app").default
|
||||
> | null = null;
|
||||
let firestorePromise: Promise<any> | null = null;
|
||||
let firebseStoragePromise: Promise<any> | null = null;
|
||||
|
||||
const loadFirebase = async () => {
|
||||
const _loadFirebase = async () => {
|
||||
const firebase = (
|
||||
await import(/* webpackChunkName: "firebase" */ "firebase/app")
|
||||
).default;
|
||||
await import(/* webpackChunkName: "firestore" */ "firebase/firestore");
|
||||
|
||||
const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
|
||||
firebase.initializeApp(firebaseConfig);
|
||||
@ -21,13 +25,37 @@ const loadFirebase = async () => {
|
||||
return firebase;
|
||||
};
|
||||
|
||||
const getFirebase = async (): Promise<
|
||||
const _getFirebase = async (): Promise<
|
||||
typeof import("firebase/app").default
|
||||
> => {
|
||||
if (!firebasePromise) {
|
||||
firebasePromise = loadFirebase();
|
||||
firebasePromise = _loadFirebase();
|
||||
}
|
||||
return await firebasePromise!;
|
||||
return firebasePromise;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const loadFirestore = async () => {
|
||||
const firebase = await _getFirebase();
|
||||
if (!firestorePromise) {
|
||||
firestorePromise = import(
|
||||
/* webpackChunkName: "firestore" */ "firebase/firestore"
|
||||
);
|
||||
await firestorePromise;
|
||||
}
|
||||
return firebase;
|
||||
};
|
||||
|
||||
export const loadFirebaseStorage = async () => {
|
||||
const firebase = await _getFirebase();
|
||||
if (!firebseStoragePromise) {
|
||||
firebseStoragePromise = import(
|
||||
/* webpackChunkName: "storage" */ "firebase/storage"
|
||||
);
|
||||
await firebseStoragePromise;
|
||||
}
|
||||
return firebase;
|
||||
};
|
||||
|
||||
interface FirebaseStoredScene {
|
||||
@ -108,7 +136,7 @@ export const saveToFirebase = async (
|
||||
return true;
|
||||
}
|
||||
|
||||
const firebase = await getFirebase();
|
||||
const firebase = await loadFirestore();
|
||||
const sceneVersion = getSceneVersion(elements);
|
||||
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
||||
|
||||
@ -150,7 +178,7 @@ export const loadFromFirebase = async (
|
||||
roomKey: string,
|
||||
socket: SocketIOClient.Socket | null,
|
||||
): Promise<readonly ExcalidrawElement[] | null> => {
|
||||
const firebase = await getFirebase();
|
||||
const firebase = await loadFirestore();
|
||||
const db = firebase.firestore();
|
||||
|
||||
const docRef = db.collection("scenes").doc(roomId);
|
||||
@ -168,5 +196,5 @@ export const loadFromFirebase = async (
|
||||
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
|
||||
}
|
||||
|
||||
return restoreElements(elements);
|
||||
return restoreElements(elements, null);
|
||||
};
|
||||
|
@ -17,7 +17,7 @@ const generateRandomID = async () => {
|
||||
return Array.from(arr, byteToHex).join("");
|
||||
};
|
||||
|
||||
const generateEncryptionKey = async () => {
|
||||
export const generateEncryptionKey = async () => {
|
||||
const key = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
@ -137,6 +137,10 @@ export const decryptAESGEM = async (
|
||||
export const getCollaborationLinkData = (link: string) => {
|
||||
const hash = new URL(link).hash;
|
||||
const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
|
||||
if (match && match[2].length !== 22) {
|
||||
window.alert(t("alerts.invalidEncryptionKey"));
|
||||
return null;
|
||||
}
|
||||
return match ? { roomId: match[1], roomKey: match[2] } : null;
|
||||
};
|
||||
|
||||
@ -176,7 +180,7 @@ export const getImportedKey = (key: string, usage: KeyUsage) =>
|
||||
[usage],
|
||||
);
|
||||
|
||||
const decryptImported = async (
|
||||
export const decryptImported = async (
|
||||
iv: ArrayBuffer,
|
||||
encrypted: ArrayBuffer,
|
||||
privateKey: string,
|
||||
@ -257,9 +261,10 @@ export const loadScene = async (
|
||||
data = restore(
|
||||
await importFromBackend(id, privateKey),
|
||||
localDataState?.appState,
|
||||
localDataState?.elements,
|
||||
);
|
||||
} else {
|
||||
data = restore(localDataState || null, null);
|
||||
data = restore(localDataState || null, null, null);
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -2,12 +2,18 @@
|
||||
.layer-ui__wrapper__footer-center {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.encrypted-icon {
|
||||
border-radius: var(--space-factor);
|
||||
color: var(--icon-green-fill-color);
|
||||
margin-top: 13px;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 0.6em;
|
||||
|
||||
svg {
|
||||
width: 1.2rem;
|
||||
|
@ -1,14 +1,7 @@
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { ExcalidrawImperativeAPI } from "../components/App";
|
||||
import { ErrorDialog } from "../components/ErrorDialog";
|
||||
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
||||
import {
|
||||
@ -31,7 +24,7 @@ import Excalidraw, {
|
||||
defaultLang,
|
||||
languages,
|
||||
} from "../packages/excalidraw/index";
|
||||
import { AppState, LibraryItems } from "../types";
|
||||
import { AppState, LibraryItems, ExcalidrawImperativeAPI } from "../types";
|
||||
import {
|
||||
debounce,
|
||||
getVersion,
|
||||
@ -56,6 +49,7 @@ import { Tooltip } from "../components/Tooltip";
|
||||
import { shield } from "../components/icons";
|
||||
|
||||
import "./index.scss";
|
||||
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
@ -141,7 +135,7 @@ const initializeScene = async (opts: {
|
||||
const url = externalUrlMatch[1];
|
||||
try {
|
||||
const request = await fetch(window.decodeURIComponent(url));
|
||||
const data = await loadFromBlob(await request.blob(), null);
|
||||
const data = await loadFromBlob(await request.blob(), null, null);
|
||||
if (
|
||||
!scene.elements.length ||
|
||||
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
||||
@ -348,11 +342,8 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
const renderLanguageList = () => (
|
||||
<LanguageList
|
||||
onChange={(langCode) => {
|
||||
setLangCode(langCode);
|
||||
}}
|
||||
onChange={(langCode) => setLangCode(langCode)}
|
||||
languages={languages}
|
||||
floating={!isMobile}
|
||||
currentLangCode={langCode}
|
||||
/>
|
||||
);
|
||||
@ -424,7 +415,28 @@ const ExcalidrawWrapper = () => {
|
||||
onCollabButtonClick={collabAPI?.onCollabButtonClick}
|
||||
isCollaborating={collabAPI?.isCollaborating()}
|
||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||
onExportToBackend={onExportToBackend}
|
||||
UIOptions={{
|
||||
canvasActions: {
|
||||
export: {
|
||||
onExportToBackend,
|
||||
renderCustomUI: (elements, appState) => {
|
||||
return (
|
||||
<ExportToExcalidrawPlus
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
onError={(error) => {
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: {
|
||||
errorMessage: error.message,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderFooter={renderFooter}
|
||||
langCode={langCode}
|
||||
@ -432,6 +444,7 @@ const ExcalidrawWrapper = () => {
|
||||
detectScroll={false}
|
||||
handleKeyboardGlobally={true}
|
||||
onLibraryChange={onLibraryChange}
|
||||
autoFocus={true}
|
||||
/>
|
||||
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
|
||||
{errorMessage && (
|
||||
|
@ -21,6 +21,7 @@ interface DehydratedHistoryEntry {
|
||||
const clearAppStatePropertiesForHistory = (appState: AppState) => {
|
||||
return {
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
selectedGroupIds: appState.selectedGroupIds,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
editingLinearElement: appState.editingLinearElement,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
@ -169,7 +170,7 @@ class History {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (key === "selectedElementIds") {
|
||||
if (key === "selectedElementIds" || key === "selectedGroupIds") {
|
||||
continue;
|
||||
}
|
||||
if (nextEntry.appState[key] !== lastEntry.appState[key]) {
|
||||
|
@ -48,6 +48,8 @@ const allLanguages: Language[] = [
|
||||
{ code: "zh-CN", label: "简体中文" },
|
||||
{ code: "zh-TW", label: "繁體中文" },
|
||||
{ code: "lv-LV", label: "Latviešu" },
|
||||
{ code: "cs-CZ", label: "Česky" },
|
||||
{ code: "kk-KZ", label: "Қазақ тілі" },
|
||||
].concat([defaultLang]);
|
||||
|
||||
export const languages: Language[] = allLanguages
|
||||
|
@ -69,7 +69,6 @@ const canvas = exportToCanvas(
|
||||
{
|
||||
exportBackground: true,
|
||||
viewBackgroundColor: "#ffffff",
|
||||
scale: 1,
|
||||
},
|
||||
createCanvas,
|
||||
);
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user