Compare commits
104 Commits
| 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 |
@@ -5,9 +5,11 @@
|
||||
.env.test.local
|
||||
.envrc
|
||||
.eslintcache
|
||||
.history
|
||||
.idea
|
||||
.vercel
|
||||
.vscode
|
||||
.yarn
|
||||
*.log
|
||||
*.tgz
|
||||
build
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ ARG NODE_ENV=production
|
||||
COPY . .
|
||||
RUN yarn build:app:docker
|
||||
|
||||
FROM nginx:1.17-alpine
|
||||
FROM nginx:1.21-alpine
|
||||
|
||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ The first set of digits is the room. This is visible from the server that’s go
|
||||
|
||||
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
||||
|
||||
> Note: Please ensure that the encryption key is 22 characters long.
|
||||
|
||||
## Shape libraries
|
||||
|
||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||
@@ -93,7 +95,7 @@ These instructions will get you a copy of the project up and running on your loc
|
||||
#### Requirements
|
||||
|
||||
- [Node.js](https://nodejs.org/en/)
|
||||
- [Yarn](https://yarnpkg.com/getting-started/install)
|
||||
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
#### Clone the repo
|
||||
|
||||
@@ -2,5 +2,8 @@
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"storage": {
|
||||
"rules": "storage.rules"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
rules_version = '2';
|
||||
service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /{migrations} {
|
||||
match /{scenes}/{scene} {
|
||||
allow get, write: if true;
|
||||
// redundant, but let's be explicit'
|
||||
allow list: if false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"capture_links": "new_client",
|
||||
"capture_links": "new-client",
|
||||
"share_target": {
|
||||
"action": "/web-share-target",
|
||||
"method": "POST",
|
||||
|
||||
@@ -38,7 +38,8 @@ const crowdinMap = {
|
||||
"zh-CN": "en-zhcn",
|
||||
"zh-TW": "en-zhtw",
|
||||
"lv-LV": "en-lv",
|
||||
"cs-CZ": "cs-cz",
|
||||
"cs-CZ": "en-cs",
|
||||
"kk-KZ": "en-kk",
|
||||
};
|
||||
|
||||
const flags = {
|
||||
@@ -78,6 +79,7 @@ const flags = {
|
||||
"zh-TW": "🇹🇼",
|
||||
"lv-LV": "🇱🇻",
|
||||
"cs-CZ": "🇨🇿",
|
||||
"kk-KZ": "🇰🇿",
|
||||
};
|
||||
|
||||
const languages = {
|
||||
@@ -117,6 +119,7 @@ const languages = {
|
||||
"zh-TW": "繁體中文",
|
||||
"lv-LV": "Latviešu",
|
||||
"cs-CZ": "Česky",
|
||||
"kk-KZ": "Қазақ тілі",
|
||||
};
|
||||
|
||||
const percentages = fs.readFileSync(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
const fs = require("fs");
|
||||
const util = require("util");
|
||||
const exec = util.promisify(require("child_process").exec);
|
||||
const updateReadme = require("./updateReadme");
|
||||
const updateChangelog = require("./updateChangelog");
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
|
||||
const updatePackageVersion = (nextVersion) => {
|
||||
const pkg = require(excalidrawPackage);
|
||||
pkg.version = nextVersion;
|
||||
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
||||
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
||||
};
|
||||
|
||||
const release = async (nextVersion) => {
|
||||
try {
|
||||
updateReadme();
|
||||
await updateChangelog(nextVersion);
|
||||
updatePackageVersion(nextVersion);
|
||||
await exec(`git add -u`);
|
||||
await exec(
|
||||
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
||||
);
|
||||
/* eslint-disable no-console */
|
||||
console.log("Done!");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const nextVersion = process.argv.slice(2)[0];
|
||||
if (!nextVersion) {
|
||||
console.error("Pass the next version to release!");
|
||||
process.exit(1);
|
||||
}
|
||||
release(nextVersion);
|
||||
@@ -0,0 +1,97 @@
|
||||
const fs = require("fs");
|
||||
const util = require("util");
|
||||
const exec = util.promisify(require("child_process").exec);
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
const pkg = require(excalidrawPackage);
|
||||
const lastVersion = pkg.version;
|
||||
const existingChangeLog = fs.readFileSync(
|
||||
`${excalidrawDir}/CHANGELOG.md`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
|
||||
const headerForType = {
|
||||
feat: "Features",
|
||||
fix: "Fixes",
|
||||
style: "Styles",
|
||||
refactor: " Refactor",
|
||||
perf: "Performance",
|
||||
build: "Build",
|
||||
};
|
||||
|
||||
const getCommitHashForLastVersion = async () => {
|
||||
try {
|
||||
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
|
||||
const { stdout } = await exec(
|
||||
`git log --format=format:"%H" --grep=${commitMessage}`,
|
||||
);
|
||||
return stdout;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const getLibraryCommitsSinceLastRelease = async () => {
|
||||
const commitHash = await getCommitHashForLastVersion();
|
||||
const { stdout } = await exec(
|
||||
`git log --pretty=format:%s ${commitHash}...master`,
|
||||
);
|
||||
const commitsSinceLastRelease = stdout.split("\n");
|
||||
const commitList = {};
|
||||
supportedTypes.forEach((type) => {
|
||||
commitList[type] = [];
|
||||
});
|
||||
|
||||
commitsSinceLastRelease.forEach((commit) => {
|
||||
const indexOfColon = commit.indexOf(":");
|
||||
const type = commit.slice(0, indexOfColon);
|
||||
if (!supportedTypes.includes(type)) {
|
||||
return;
|
||||
}
|
||||
const messageWithoutType = commit.slice(indexOfColon + 1).trim();
|
||||
const messageWithCapitalizeFirst =
|
||||
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
|
||||
const prNumber = commit.match(/\(#([0-9]*)\)/)[1];
|
||||
|
||||
// return if the changelog already contains the pr number which would happen for package updates
|
||||
if (existingChangeLog.includes(prNumber)) {
|
||||
return;
|
||||
}
|
||||
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
|
||||
const messageWithPRLink = messageWithCapitalizeFirst.replace(
|
||||
/\(#[0-9]*\)/,
|
||||
prMarkdown,
|
||||
);
|
||||
commitList[type].push(messageWithPRLink);
|
||||
});
|
||||
return commitList;
|
||||
};
|
||||
|
||||
const updateChangelog = async (nextVersion) => {
|
||||
const commitList = await getLibraryCommitsSinceLastRelease();
|
||||
let changelogForLibrary =
|
||||
"## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
|
||||
supportedTypes.forEach((type) => {
|
||||
if (commitList[type].length) {
|
||||
changelogForLibrary += `### ${headerForType[type]}\n\n`;
|
||||
const commits = commitList[type];
|
||||
commits.forEach((commit) => {
|
||||
changelogForLibrary += `- ${commit}\n\n`;
|
||||
});
|
||||
}
|
||||
});
|
||||
changelogForLibrary += "---\n";
|
||||
const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
|
||||
let updatedContent =
|
||||
existingChangeLog.slice(0, lastVersionIndex) +
|
||||
changelogForLibrary +
|
||||
existingChangeLog.slice(lastVersionIndex);
|
||||
const currentDate = new Date().toISOString().slice(0, 10);
|
||||
const newVersion = `## ${nextVersion} (${currentDate})`;
|
||||
updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
|
||||
fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
|
||||
};
|
||||
|
||||
module.exports = updateChangelog;
|
||||
@@ -0,0 +1,27 @@
|
||||
const fs = require("fs");
|
||||
|
||||
const updateReadme = () => {
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||
|
||||
// remove note for unstable release
|
||||
data = data.replace(
|
||||
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
|
||||
"",
|
||||
);
|
||||
|
||||
// replace "excalidraw-next" with "excalidraw"
|
||||
data = data.replace(/excalidraw-next/g, "excalidraw");
|
||||
data = data.trim();
|
||||
|
||||
const demoIndex = data.indexOf("### Demo");
|
||||
const excalidrawNextNote =
|
||||
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
|
||||
// Add excalidraw next note to try out for unreleased changes
|
||||
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
|
||||
|
||||
// update readme
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
};
|
||||
|
||||
module.exports = updateReadme;
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
|
||||
@@ -1,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,6 +16,7 @@ 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",
|
||||
@@ -34,9 +34,9 @@ export const actionChangeViewBackgroundColor = register({
|
||||
type="canvasBackground"
|
||||
color={appState.viewBackgroundColor}
|
||||
onChange={(color) => updateData({ viewBackgroundColor: color })}
|
||||
isActive={appState.openMenu === "canvasColorPicker"}
|
||||
isActive={appState.openPopup === "canvasColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openMenu: active ? "canvasColorPicker" : null })
|
||||
updateData({ openPopup: active ? "canvasColorPicker" : null })
|
||||
}
|
||||
data-testid="canvas-background-picker"
|
||||
/>
|
||||
@@ -108,6 +108,7 @@ export const actionZoomIn = register({
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@@ -142,6 +143,7 @@ export const actionZoomOut = register({
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@@ -168,16 +170,21 @@ export const actionResetZoom = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<Tooltip label={t("buttons.resetZoom")}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={resetZoom}
|
||||
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) &&
|
||||
@@ -271,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,7 +120,7 @@ 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>
|
||||
),
|
||||
@@ -75,15 +130,19 @@ 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,14 +160,10 @@ export const actionSaveToActiveFile = 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}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -138,7 +193,7 @@ export const actionSaveFileToDisk = 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";
|
||||
@@ -126,9 +129,9 @@ export const actionChangeStrokeColor = register({
|
||||
appState.currentItemStrokeColor,
|
||||
)}
|
||||
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
||||
isActive={appState.openMenu === "strokeColorPicker"}
|
||||
isActive={appState.openPopup === "strokeColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openMenu: active ? "strokeColorPicker" : null })
|
||||
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
@@ -166,9 +169,9 @@ export const actionChangeBackgroundColor = register({
|
||||
appState.currentItemBackgroundColor,
|
||||
)}
|
||||
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
||||
isActive={appState.openMenu === "backgroundColorPicker"}
|
||||
isActive={appState.openPopup === "backgroundColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openMenu: active ? "backgroundColorPicker" : null })
|
||||
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
@@ -499,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} />,
|
||||
},
|
||||
@@ -520,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,
|
||||
};
|
||||
|
||||
@@ -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,6 +67,7 @@ export type ActionName =
|
||||
| "changeProjectName"
|
||||
| "changeExportBackground"
|
||||
| "changeExportEmbedScene"
|
||||
| "changeExportScale"
|
||||
| "saveToActiveFile"
|
||||
| "saveFileToDisk"
|
||||
| "loadScene"
|
||||
@@ -101,15 +103,17 @@ export type ActionName =
|
||||
| "exportWithDarkMode"
|
||||
| "toggleTheme";
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
PanelComponent?: React.FC<{
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
updateData: (formData?: any) => void;
|
||||
appProps: ExcalidrawProps;
|
||||
id?: string;
|
||||
}>;
|
||||
data?: Partial<{ id: string; size: ToolButtonSize }>;
|
||||
};
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
PanelComponent?: React.FC<PanelComponentProps>;
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
keyTest?: (
|
||||
|
||||
+11
-1
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.excalidraw {
|
||||
.ActiveFile {
|
||||
.ActiveFile__fileName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 9.3em;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1.15em;
|
||||
margin-inline-end: 0.3em;
|
||||
transform: scaleY(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Stack from "../components/Stack";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { save, file } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import "./ActiveFile.scss";
|
||||
|
||||
type ActiveFileProps = {
|
||||
fileName?: string;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
|
||||
<Stack.Row className="ActiveFile" gap={1} align="center">
|
||||
<span className="ActiveFile__fileName">
|
||||
{file}
|
||||
<span>{fileName}</span>
|
||||
</span>
|
||||
<ToolButton
|
||||
type="icon"
|
||||
icon={save}
|
||||
title={t("buttons.save")}
|
||||
aria-label={t("buttons.save")}
|
||||
onClick={onSave}
|
||||
data-testid="save-button"
|
||||
/>
|
||||
</Stack.Row>
|
||||
);
|
||||
+109
-128
@@ -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,
|
||||
@@ -471,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
|
||||
@@ -534,7 +470,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
public focusContainer = () => {
|
||||
if (this.props.autoFocus) {
|
||||
this.excalidrawContainerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
public getSceneElementsIncludingDeleted = () => {
|
||||
@@ -576,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;
|
||||
@@ -718,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,
|
||||
@@ -745,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: {
|
||||
@@ -756,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,
|
||||
@@ -800,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
|
||||
@@ -888,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);
|
||||
@@ -929,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);
|
||||
@@ -984,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) {
|
||||
@@ -1250,9 +1194,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
const data = await parseClipboard(event);
|
||||
if (this.props.onPaste) {
|
||||
if (await this.props.onPaste(data, event)) {
|
||||
try {
|
||||
if ((await this.props.onPaste(data, event)) === false) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
if (data.errorMessage) {
|
||||
this.setState({ errorMessage: data.errorMessage });
|
||||
@@ -1265,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) {
|
||||
@@ -1280,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;
|
||||
@@ -1346,6 +1294,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getElements(),
|
||||
),
|
||||
);
|
||||
this.selectShapeTool("selection");
|
||||
};
|
||||
|
||||
private addTextFromPaste(text: any) {
|
||||
@@ -1386,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);
|
||||
@@ -1452,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 });
|
||||
}
|
||||
};
|
||||
@@ -1649,13 +1598,22 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getElements(),
|
||||
this.state,
|
||||
);
|
||||
if (selectedElements.length) {
|
||||
if (event.key === KEYS.G) {
|
||||
this.setState({ openMenu: "backgroundColorPicker" });
|
||||
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({ openMenu: "strokeColorPicker" });
|
||||
}
|
||||
this.setState({ openPopup: "strokeColorPicker" });
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1663,7 +1621,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
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);
|
||||
@@ -1811,7 +1771,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
[element.id]: true,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
}
|
||||
if (isDeleted) {
|
||||
fixBindingsAfterDeletion(this.scene.getElements(), [element]);
|
||||
}
|
||||
if (!isDeleted || isExistingElement) {
|
||||
@@ -1832,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(
|
||||
@@ -2283,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 (
|
||||
@@ -2308,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)
|
||||
@@ -2522,8 +2487,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
lastPointerUp = null;
|
||||
isPanning = false;
|
||||
if (!isHoldingSpace) {
|
||||
if (this.state.viewModeEnabled) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.GRAB);
|
||||
} else {
|
||||
setCursorForShape(this.canvas, this.state.elementType);
|
||||
}
|
||||
}
|
||||
this.setState({
|
||||
cursorButton: "up",
|
||||
});
|
||||
@@ -3526,6 +3495,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(draggingElement, {
|
||||
points: [...points, [dx, dy]],
|
||||
pressures,
|
||||
lastCommittedPoint: [dx, dy],
|
||||
});
|
||||
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
@@ -3865,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: {
|
||||
@@ -3876,7 +3861,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
return this.setState({
|
||||
isLoading: false,
|
||||
errorMessage: error.message,
|
||||
@@ -3910,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);
|
||||
}
|
||||
}
|
||||
@@ -3925,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,
|
||||
@@ -4109,6 +4094,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
actionToggleStats,
|
||||
];
|
||||
|
||||
if (this.state.viewModeEnabled) {
|
||||
ContextMenu.push({
|
||||
options: viewModeOptions,
|
||||
top,
|
||||
@@ -4117,11 +4103,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
|
||||
if (this.state.viewModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
} else {
|
||||
ContextMenu.push({
|
||||
options: [
|
||||
this.isMobile &&
|
||||
@@ -4161,9 +4143,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
} else if (type === "element") {
|
||||
if (this.state.viewModeEnabled) {
|
||||
ContextMenu.push({
|
||||
options: [navigator.clipboard && actionCopy, ...options],
|
||||
@@ -4173,9 +4154,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
} else {
|
||||
ContextMenu.push({
|
||||
options: [
|
||||
this.isMobile && actionCut,
|
||||
@@ -4219,6 +4198,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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("saveToActiveFile")}
|
||||
</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,15 +138,19 @@ const Picker = ({
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
{colors.map((_color, i) => (
|
||||
{colors.map((_color, i) => {
|
||||
const _colorWithoutHash = _color.replace("#", "");
|
||||
return (
|
||||
<button
|
||||
className="color-picker-swatch"
|
||||
onClick={(event) => {
|
||||
(event.currentTarget as HTMLButtonElement).focus();
|
||||
onChange(_color);
|
||||
}}
|
||||
title={`${_color} — ${keyBindings[i].toUpperCase()}`}
|
||||
aria-label={_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}
|
||||
@@ -161,12 +166,13 @@ const Picker = ({
|
||||
onChange(_color);
|
||||
}}
|
||||
>
|
||||
{_color === "transparent" ? (
|
||||
{isTransparent(_color) ? (
|
||||
<div className="color-picker-transparent"></div>
|
||||
) : undefined}
|
||||
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{showInput && (
|
||||
<ColorInput
|
||||
color={color}
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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() {
|
||||
const currentLang =
|
||||
languages.find((lang) => lang.code === this.props.langCode) ||
|
||||
defaultLang;
|
||||
export const InitializeApp = (props: Props) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const updateLang = async () => {
|
||||
await setLanguage(currentLang);
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
const currentLang =
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ 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[],
|
||||
@@ -42,7 +42,8 @@ const JSONExportModal = ({
|
||||
<h2>{t("exportDialog.disk_title")}</h2>
|
||||
<div className="Card-details">
|
||||
{t("exportDialog.disk_details")}
|
||||
{!fsSupported && actionManager.renderAction("changeProjectName")}
|
||||
{!nativeFileSystemSupported &&
|
||||
actionManager.renderAction("changeProjectName")}
|
||||
</div>
|
||||
<ToolButton
|
||||
className="Card-button"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
-13
@@ -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;
|
||||
@@ -107,6 +109,7 @@ const LibraryMenuItems = ({
|
||||
onAddToLibrary,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
theme,
|
||||
setAppState,
|
||||
setLibraryItems,
|
||||
libraryReturnUrl,
|
||||
@@ -119,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"];
|
||||
@@ -192,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")}
|
||||
@@ -246,6 +250,7 @@ const LibraryMenu = ({
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
theme,
|
||||
setAppState,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
@@ -256,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;
|
||||
@@ -345,6 +351,7 @@ const LibraryMenu = ({
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
theme={theme}
|
||||
id={id}
|
||||
/>
|
||||
)}
|
||||
@@ -400,19 +407,25 @@ const LayerUI = ({
|
||||
|
||||
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 (
|
||||
@@ -484,6 +497,9 @@ const LayerUI = ({
|
||||
setAppState={setAppState}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
{appState.fileHandle && (
|
||||
<>{actionManager.renderAction("saveToActiveFile")}</>
|
||||
)}
|
||||
</Stack.Col>
|
||||
</Island>
|
||||
</Section>
|
||||
@@ -502,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
|
||||
@@ -539,6 +556,7 @@ const LayerUI = ({
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
theme={appState.theme}
|
||||
id={id}
|
||||
/>
|
||||
) : null;
|
||||
@@ -566,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 })}
|
||||
@@ -577,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}
|
||||
@@ -611,7 +632,9 @@ const LayerUI = ({
|
||||
label={client.username || "Unknown user"}
|
||||
key={clientId}
|
||||
>
|
||||
{actionManager.renderAction("goToCollaborator", clientId)}
|
||||
{actionManager.renderAction("goToCollaborator", {
|
||||
id: clientId,
|
||||
})}
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
@@ -644,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>
|
||||
|
||||
@@ -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,7 +36,11 @@ export const LibraryUnit = ({
|
||||
if (!elementsToRender) {
|
||||
return;
|
||||
}
|
||||
const svg = exportToSvg(elementsToRender, {
|
||||
let svg: SVGSVGElement;
|
||||
const current = ref.current!;
|
||||
|
||||
(async () => {
|
||||
svg = await exportToSvg(elementsToRender, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
});
|
||||
@@ -44,13 +48,15 @@ export const LibraryUnit = ({
|
||||
if (child.tagName !== "svg") {
|
||||
continue;
|
||||
}
|
||||
ref.current!.removeChild(child);
|
||||
current!.removeChild(child);
|
||||
}
|
||||
ref.current!.appendChild(svg);
|
||||
current!.appendChild(svg);
|
||||
})();
|
||||
|
||||
const current = ref.current!;
|
||||
return () => {
|
||||
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);
|
||||
let svg: SVGSVGElement;
|
||||
const previewNode = previewRef.current!;
|
||||
|
||||
const svg = exportToSvg(elements, {
|
||||
(async () => {
|
||||
svg = await exportToSvg(elements, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
});
|
||||
|
||||
const previewNode = previewRef.current!;
|
||||
|
||||
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 ? "" : ":"}`}
|
||||
{`${props.label}${props.isNameEditable ? "" : ":"}`}
|
||||
</label>
|
||||
{this.props.isNameEditable ? (
|
||||
{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 })
|
||||
}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
id={`${id}-filename`}
|
||||
value={fileName}
|
||||
onChange={(event) => setFileName(event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<span className="TextInput TextInput--readonly" id="filename">
|
||||
{this.props.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,8 +26,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
.Tooltip-icon {
|
||||
// wraps the element we want to apply the tooltip to
|
||||
.excalidraw-tooltip-wrapper {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.excalidraw-tooltip-icon {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
margin-left: 5px;
|
||||
@@ -36,4 +43,3 @@
|
||||
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,
|
||||
|
||||
+57
-75
@@ -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,8 +177,7 @@ export const resetZoom = createIcon(
|
||||
{ width: 1024 },
|
||||
);
|
||||
|
||||
export const BringForwardIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const BringForwardIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
@@ -193,8 +197,7 @@ export const BringForwardIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const SendBackwardIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const SendBackwardIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
@@ -214,8 +217,7 @@ export const SendBackwardIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const BringToFrontIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const BringToFrontIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
@@ -235,8 +237,7 @@ export const BringToFrontIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const SendToBackIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const SendToBackIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
@@ -262,8 +263,7 @@ 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" }) =>
|
||||
export const AlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
@@ -284,8 +284,7 @@ export const AlignTopIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const AlignBottomIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const AlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
@@ -306,8 +305,7 @@ export const AlignBottomIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const AlignLeftIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const AlignLeftIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
@@ -328,8 +326,7 @@ export const AlignLeftIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const AlignRightIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const AlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
@@ -351,7 +348,7 @@ export const AlignRightIcon = React.memo(
|
||||
);
|
||||
|
||||
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,8 +390,7 @@ export const DistributeVerticallyIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const CenterVerticallyIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const CenterVerticallyIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
@@ -417,7 +413,7 @@ export const CenterVerticallyIcon = React.memo(
|
||||
);
|
||||
|
||||
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,8 +505,7 @@ export const GroupIcon = React.memo(({ theme }: { theme: "light" | "dark" }) =>
|
||||
),
|
||||
);
|
||||
|
||||
export const UngroupIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const UngroupIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
|
||||
@@ -537,8 +537,7 @@ export const UngroupIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const FillHachureIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const FillHachureIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
@@ -550,8 +549,7 @@ export const FillHachureIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const FillCrossHatchIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
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" />
|
||||
@@ -561,8 +559,7 @@ export const FillCrossHatchIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const FillSolidIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const FillSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(<path d="M2 2H38V18H2V2Z" fill={iconFillColor(theme)} />, {
|
||||
width: 40,
|
||||
height: 20,
|
||||
@@ -570,7 +567,7 @@ export const FillSolidIcon = React.memo(
|
||||
);
|
||||
|
||||
export const StrokeWidthIcon = React.memo(
|
||||
({ theme, strokeWidth }: { theme: "light" | "dark"; strokeWidth: number }) =>
|
||||
({ theme, strokeWidth }: { theme: Theme; strokeWidth: number }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H32"
|
||||
@@ -583,8 +580,7 @@ export const StrokeWidthIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const StrokeStyleSolidIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const StrokeStyleSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
@@ -600,8 +596,7 @@ export const StrokeStyleSolidIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const StrokeStyleDashedIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const StrokeStyleDashedIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
@@ -615,8 +610,7 @@ export const StrokeStyleDashedIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const StrokeStyleDottedIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const StrokeStyleDottedIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H36"
|
||||
@@ -631,7 +625,7 @@ export const StrokeStyleDottedIcon = React.memo(
|
||||
);
|
||||
|
||||
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,8 +638,7 @@ export const SloppinessArchitectIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const SloppinessArtistIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
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"
|
||||
@@ -659,7 +652,7 @@ export const SloppinessArtistIcon = React.memo(
|
||||
);
|
||||
|
||||
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,8 +665,7 @@ export const SloppinessCartoonistIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const EdgeSharpIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const EdgeSharpIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M10 17L10 5L35 5"
|
||||
@@ -686,8 +678,7 @@ export const EdgeSharpIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const EdgeRoundIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const EdgeRoundIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M10 17V15C10 8 13 5 21 5L33.5 5"
|
||||
@@ -700,8 +691,7 @@ export const EdgeRoundIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadNoneIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const ArrowheadNoneIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
@@ -717,7 +707,7 @@ export const ArrowheadNoneIcon = React.memo(
|
||||
);
|
||||
|
||||
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,8 +752,7 @@ export const ArrowheadBarIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const FontSizeSmallIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const FontSizeSmallIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
@@ -773,8 +762,7 @@ export const FontSizeSmallIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const FontSizeMediumIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const FontSizeMediumIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
@@ -784,8 +772,7 @@ export const FontSizeMediumIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const FontSizeLargeIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const FontSizeLargeIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
@@ -796,7 +783,7 @@ export const FontSizeLargeIcon = React.memo(
|
||||
);
|
||||
|
||||
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,8 +804,7 @@ export const FontFamilyHandDrawnIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const FontFamilyNormalIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const FontFamilyNormalIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
@@ -834,8 +820,7 @@ export const FontFamilyNormalIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const FontFamilyCodeIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
export const FontFamilyCodeIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
@@ -847,8 +832,7 @@ export const FontFamilyCodeIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const TextAlignLeftIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
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"
|
||||
@@ -859,8 +843,7 @@ export const TextAlignLeftIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const TextAlignCenterIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
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"
|
||||
@@ -870,8 +853,7 @@ export const TextAlignCenterIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
export const TextAlignRightIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
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"
|
||||
|
||||
+17
-7
@@ -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",
|
||||
@@ -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
|
||||
|
||||
+22
-25
@@ -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;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
margin-left: 14px;
|
||||
.reset-zoom-button {
|
||||
padding: 0.2em;
|
||||
background: transparent;
|
||||
color: var(--text-primary-color);
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
+24
-1
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
+21
-25
@@ -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 {
|
||||
|
||||
+23
-20
@@ -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`,
|
||||
const fileHandle = await fileSave(blob, {
|
||||
name: appState.name,
|
||||
extension: "excalidraw",
|
||||
description: "Excalidraw file",
|
||||
extensions: [".excalidraw"],
|
||||
},
|
||||
appState.fileHandle,
|
||||
);
|
||||
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], {
|
||||
await fileSave(
|
||||
new Blob([serialized], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
});
|
||||
await fileSave(blob, {
|
||||
fileName,
|
||||
}),
|
||||
{
|
||||
name: "library",
|
||||
extension: "excalidrawlib",
|
||||
description: "Excalidraw library file",
|
||||
extensions: [".excalidrawlib"],
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const importLibraryFromJSON = async (library: Library) => {
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { loadLibraryFromBlob } from "./blob";
|
||||
import { LibraryItems, LibraryItem } from "../types";
|
||||
import { restoreElements } from "./restore";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import App from "../components/App";
|
||||
import type App from "../components/App";
|
||||
|
||||
class Library {
|
||||
private libraryCache: LibraryItems | null = null;
|
||||
@@ -18,7 +18,7 @@ class Library {
|
||||
};
|
||||
|
||||
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
|
||||
const elements = getNonDeletedElements(restoreElements(libraryItem));
|
||||
const elements = getNonDeletedElements(restoreElements(libraryItem, null));
|
||||
return elements.length ? elements : null;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,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 };
|
||||
};
|
||||
+29
-16
@@ -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 (
|
||||
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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
@@ -428,6 +419,21 @@ const ExcalidrawWrapper = () => {
|
||||
canvasActions: {
|
||||
export: {
|
||||
onExportToBackend,
|
||||
renderCustomUI: (elements, appState) => {
|
||||
return (
|
||||
<ExportToExcalidrawPlus
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
onError={(error) => {
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: {
|
||||
errorMessage: error.message,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -438,6 +444,7 @@ const ExcalidrawWrapper = () => {
|
||||
detectScroll={false}
|
||||
handleKeyboardGlobally={true}
|
||||
onLibraryChange={onLibraryChange}
|
||||
autoFocus={true}
|
||||
/>
|
||||
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
|
||||
{errorMessage && (
|
||||
|
||||
@@ -49,6 +49,7 @@ const allLanguages: Language[] = [
|
||||
{ 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,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import ExcalidrawApp from "./excalidraw-app";
|
||||
|
||||
|
||||
+83
-26
@@ -4,7 +4,7 @@
|
||||
"pasteCharts": "لصق الرسوم البيانية",
|
||||
"selectAll": "تحديد الكل",
|
||||
"multiSelect": "إضافة عنصر للتحديد",
|
||||
"moveCanvas": "نقل لوح رسم",
|
||||
"moveCanvas": "نقل لوح الرسم",
|
||||
"cut": "قص",
|
||||
"copy": "نسخ",
|
||||
"copyAsPng": "نسخ إلى الحافظة بصيغة PNG",
|
||||
@@ -14,18 +14,18 @@
|
||||
"bringToFront": "أحضر للأمام",
|
||||
"sendBackward": "أرسل للخلف",
|
||||
"delete": "حذف",
|
||||
"copyStyles": "نسخ النمط",
|
||||
"pasteStyles": "لصق النمط",
|
||||
"stroke": "الحدود",
|
||||
"copyStyles": "نسخ الأنماط",
|
||||
"pasteStyles": "لصق الأنماط",
|
||||
"stroke": "الخط",
|
||||
"background": "الخلفية",
|
||||
"fill": "التعبئة",
|
||||
"strokeWidth": "حجم الحدود",
|
||||
"strokeShape": "",
|
||||
"strokeShape_gel": "",
|
||||
"strokeShape_fountain": "",
|
||||
"strokeShape_brush": "",
|
||||
"strokeStyle": "نمط الحدود",
|
||||
"strokeStyle_solid": "صلبة",
|
||||
"strokeWidth": "سُمك الخط",
|
||||
"strokeShape": "شكل الخط",
|
||||
"strokeShape_gel": "قلم جل",
|
||||
"strokeShape_fountain": "قلم رش",
|
||||
"strokeShape_brush": "فرشاه",
|
||||
"strokeStyle": "نمط الخط",
|
||||
"strokeStyle_solid": "كامل",
|
||||
"strokeStyle_dashed": "متقطع",
|
||||
"strokeStyle_dotted": "منقط",
|
||||
"sloppiness": "الإمالة",
|
||||
@@ -43,12 +43,12 @@
|
||||
"fontFamily": "نوع الخط",
|
||||
"onlySelected": "المحدد فقط",
|
||||
"withBackground": "الخلفية",
|
||||
"exportEmbedScene": "",
|
||||
"exportEmbedScene": "تضمين المشهد",
|
||||
"exportEmbedScene_details": "سيتم حفظ بيانات المشهد في ملف PNG/SVG المصدّر بحيث يمكن استعادة المشهد منه.\nسيزيد حجم الملف المصدر.",
|
||||
"addWatermark": "إضافة \"مصنوعة بواسطة Excalidraw\"",
|
||||
"handDrawn": "رسم باليد",
|
||||
"normal": "عادي",
|
||||
"code": "الرمز",
|
||||
"code": "رمز",
|
||||
"small": "صغير",
|
||||
"medium": "متوسط",
|
||||
"large": "كبير",
|
||||
@@ -66,7 +66,7 @@
|
||||
"artist": "رسام",
|
||||
"cartoonist": "كرتوني",
|
||||
"fileTitle": "إسم الملف",
|
||||
"colorPicker": "اختيار الألوان",
|
||||
"colorPicker": "منتقي اللون",
|
||||
"canvasBackground": "خلفية اللوحة",
|
||||
"drawingCanvas": "لوحة الرسم",
|
||||
"layers": "الطبقات",
|
||||
@@ -99,8 +99,10 @@
|
||||
"flipHorizontal": "قلب عامودي",
|
||||
"flipVertical": "قلب أفقي",
|
||||
"viewMode": "نمط العرض",
|
||||
"toggleExportColorScheme": "",
|
||||
"toggleExportColorScheme": "تبديل نظام ألوان الصادرات",
|
||||
"share": "مشاركة",
|
||||
"showStroke": "إظهار منتقي لون الخط",
|
||||
"showBackground": "إظهار منتقي لون الخلفية",
|
||||
"toggleTheme": "غير النمط"
|
||||
},
|
||||
"buttons": {
|
||||
@@ -149,13 +151,14 @@
|
||||
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
|
||||
"collabStopOverridePrompt": "إيقاف الجلسة سيؤدي إلى الكتابة فوق رسومك السابقة المخزنة داخليا. هل أنت متأكد؟\n\n(إذا كنت ترغب في الاحتفاظ برسمك المخزن داخليا، ببساطة أغلق علامة تبويب المتصفح بدلاً من ذلك.)",
|
||||
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"errorAddingToLibrary": "تعذر إضافة العنصر للمكتبة",
|
||||
"errorRemovingFromLibrary": "تعذر إزالة العنصر من المكتبة",
|
||||
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
|
||||
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
|
||||
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": ""
|
||||
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
|
||||
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
|
||||
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "تحديد",
|
||||
@@ -164,7 +167,7 @@
|
||||
"ellipse": "دائرة",
|
||||
"arrow": "سهم",
|
||||
"line": "خط",
|
||||
"freedraw": "",
|
||||
"freedraw": "رسم",
|
||||
"text": "نص",
|
||||
"library": "مكتبة",
|
||||
"lock": "الحفاظ على أداة التحديد نشطة بعد الرسم"
|
||||
@@ -178,6 +181,8 @@
|
||||
"linearElement": "انقر لبدء نقاط متعددة، اسحب لخط واحد",
|
||||
"freeDraw": "انقر واسحب، افرج عند الانتهاء",
|
||||
"text": "نصيحة: يمكنك أيضًا إضافة نص بالنقر المزدوج في أي مكان بأداة الاختيار",
|
||||
"text_selected": "انقر نقراً مزدوجاً أو اضغط ادخال لتعديل النص",
|
||||
"text_editing": "اضغط على Esc أو (Ctrl أو Cmd) + Enter لإنهاء التعديل",
|
||||
"linearElementMulti": "انقر فوق النقطة الأخيرة أو اضغط على Esc أو Enter للإنهاء",
|
||||
"lockAngle": "يمكنك تقييد الزاوية بالضغط على SHIFT",
|
||||
"resize": "يمكنك تقييد النسب بالضغط على SHIFT أثناء تغيير الحجم،\nاضغط على ALT لتغيير الحجم من المركز",
|
||||
@@ -212,18 +217,21 @@
|
||||
"desc_inProgressIntro": "تجري الآن المشاركة الحية.",
|
||||
"desc_shareLink": "شارك هذا الرابط مع أي شخص تريده أن يشاركك الجلسة:",
|
||||
"desc_exitSession": "إيقاف الجلسة سيؤدي إلى قطع الاتصال الخاص بك من الغرفة، ولكن ستتمكن من مواصلة العمل مع المشهد، محليا. لاحظ أن هذا لن يؤثر على الأشخاص الآخرين، و سيظلون قادرين على التعاون في إصدارهم.",
|
||||
"shareTitle": ""
|
||||
"shareTitle": "الانضمام إلى جلسة تعاون حية على Excalidraw"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "خطأ"
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "حفظ الملف للجهاز",
|
||||
"disk_details": "",
|
||||
"disk_details": "تصدير بيانات المشهد إلى ملف يمكنك الاستيراد منه لاحقاً.",
|
||||
"disk_button": "إحفظ لملف",
|
||||
"link_title": "رابط قابل للمشاركة",
|
||||
"link_details": "صدر الملف للمشاهدة فقط.",
|
||||
"link_button": "التصدير كرابط"
|
||||
"link_button": "التصدير كرابط",
|
||||
"excalidrawplus_description": "حفظ المشهد إلى مساحة العمل +Excalidraw الخاصة بك.",
|
||||
"excalidrawplus_button": "تصدير",
|
||||
"excalidrawplus_exportError": "تعذر التصدير إلى +Excalidraw في الوقت الحالي..."
|
||||
},
|
||||
"helpDialog": {
|
||||
"blog": "اقرأ مدونتنا",
|
||||
@@ -231,16 +239,18 @@
|
||||
"curvedArrow": "سهم مائل",
|
||||
"curvedLine": "خط مائل",
|
||||
"documentation": "دليل الاستخدام",
|
||||
"doubleClick": "انقر مرتين",
|
||||
"drag": "اسحب",
|
||||
"editor": "المحرر",
|
||||
"editSelectedShape": "تعديل الشكل المحدد (النص/السهم/الخط)",
|
||||
"github": "عثرت على مشكلة؟ إرسال",
|
||||
"howto": "اتبع التعليمات",
|
||||
"or": "أو",
|
||||
"preventBinding": "منع ارتبط السهم",
|
||||
"shapes": "أشكال",
|
||||
"shortcuts": "اختصارات لوحة المفاتيح",
|
||||
"textFinish": "الانتهاء من التحرير (نص)",
|
||||
"textNewLine": "اضف سطر جديد (نص)",
|
||||
"textFinish": "إنهاء التعديل (محرر النص)",
|
||||
"textNewLine": "أضف سطر جديد (محرر نص)",
|
||||
"title": "المساعدة",
|
||||
"view": "عرض",
|
||||
"zoomToFit": "تكبير للملائمة",
|
||||
@@ -266,12 +276,59 @@
|
||||
"width": "العرض"
|
||||
},
|
||||
"toast": {
|
||||
"copyStyles": "نسخ النمط.",
|
||||
"copyStyles": "نسخت الانماط.",
|
||||
"copyToClipboard": "نسخ إلى الحافظة.",
|
||||
"copyToClipboardAsPng": "تم نسخ {{exportSelection}} إلى الحافظة بصيغة PNG\n({{exportColorScheme}})",
|
||||
"fileSaved": "تم حفظ الملف.",
|
||||
"fileSavedToFilename": "حفظ باسم {filename}",
|
||||
"canvas": "لوحة الرسم",
|
||||
"selection": "العنصر المحدد"
|
||||
},
|
||||
"colors": {
|
||||
"ffffff": "أبيض",
|
||||
"f8f9fa": "رمادي 0",
|
||||
"f1f3f5": "رمادي 1",
|
||||
"fff5f5": "أحمر 0",
|
||||
"fff0f6": "وردي 0",
|
||||
"f8f0fc": "عنبي 0",
|
||||
"f3f0ff": "بنفسجي 0",
|
||||
"edf2ff": "نيلي 0",
|
||||
"e7f5ff": "أزرق 0",
|
||||
"e3fafc": "سماوي 0",
|
||||
"e6fcf5": "تركواز 0",
|
||||
"ebfbee": "أخضر 0",
|
||||
"f4fce3": "ليموني 0",
|
||||
"fff9db": "أصفر 0",
|
||||
"fff4e6": "برتقالي 0",
|
||||
"transparent": "شفاف",
|
||||
"ced4da": "رمادي 4",
|
||||
"868e96": "رمادي 6",
|
||||
"fa5252": "أحمر 6",
|
||||
"e64980": "وردي 6",
|
||||
"be4bdb": "عنبي 6",
|
||||
"7950f2": "بنفسجي 6",
|
||||
"4c6ef5": "نيلي 6",
|
||||
"228be6": "أزرق 6",
|
||||
"15aabf": "سماوي 6",
|
||||
"12b886": "تركواز 6",
|
||||
"40c057": "أخضر 6",
|
||||
"82c91e": "ليموني 6",
|
||||
"fab005": "أصفر 6",
|
||||
"fd7e14": "برتقالي 6",
|
||||
"000000": "أسود",
|
||||
"343a40": "رمادي 8",
|
||||
"495057": "رمادي 7",
|
||||
"c92a2a": "أحمر 9",
|
||||
"a61e4d": "وردي 9",
|
||||
"862e9c": "عنبي 9",
|
||||
"5f3dc4": "بنفسجي 9",
|
||||
"364fc7": "نيلي 9",
|
||||
"1864ab": "أزرق 9",
|
||||
"0b7285": "سماوي 9",
|
||||
"087f5b": "تركواز 9",
|
||||
"2b8a3e": "أخضر 9",
|
||||
"5c940d": "ليموني 9",
|
||||
"e67700": "أصفر 9",
|
||||
"d9480f": "برتقالي 9"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user