Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c96b0404ba |
@@ -5,11 +5,9 @@
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.envrc
|
.envrc
|
||||||
.eslintcache
|
.eslintcache
|
||||||
.history
|
|
||||||
.idea
|
.idea
|
||||||
.vercel
|
.vercel
|
||||||
.vscode
|
.vscode
|
||||||
.yarn
|
|
||||||
*.log
|
*.log
|
||||||
*.tgz
|
*.tgz
|
||||||
build
|
build
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ ARG NODE_ENV=production
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN yarn build:app:docker
|
RUN yarn build:app:docker
|
||||||
|
|
||||||
FROM nginx:1.21-alpine
|
FROM nginx:1.17-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ The first set of digits is the room. This is visible from the server that’s go
|
|||||||
|
|
||||||
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
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
|
## Shape libraries
|
||||||
|
|
||||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||||
@@ -95,7 +93,7 @@ These instructions will get you a copy of the project up and running on your loc
|
|||||||
#### Requirements
|
#### Requirements
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/en/)
|
- [Node.js](https://nodejs.org/en/)
|
||||||
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
|
- [Yarn](https://yarnpkg.com/getting-started/install)
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
|
||||||
#### Clone the repo
|
#### Clone the repo
|
||||||
|
|||||||
@@ -2,8 +2,5 @@
|
|||||||
"firestore": {
|
"firestore": {
|
||||||
"rules": "firestore.rules",
|
"rules": "firestore.rules",
|
||||||
"indexes": "firestore.indexes.json"
|
"indexes": "firestore.indexes.json"
|
||||||
},
|
|
||||||
"storage": {
|
|
||||||
"rules": "storage.rules"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
rules_version = '2';
|
|
||||||
service firebase.storage {
|
|
||||||
match /b/{bucket}/o {
|
|
||||||
match /{migrations} {
|
|
||||||
match /{scenes}/{scene} {
|
|
||||||
allow get, write: if true;
|
|
||||||
// redundant, but let's be explicit'
|
|
||||||
allow list: if false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-5
@@ -19,17 +19,15 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dwelle/browser-fs-access": "0.21.1",
|
|
||||||
"@excalidraw/random-username": "1.0.0",
|
|
||||||
"@sentry/browser": "6.2.5",
|
"@sentry/browser": "6.2.5",
|
||||||
"@sentry/integrations": "6.2.5",
|
"@sentry/integrations": "6.2.5",
|
||||||
"@testing-library/jest-dom": "5.11.10",
|
"@testing-library/jest-dom": "5.11.10",
|
||||||
"@testing-library/react": "11.2.6",
|
"@testing-library/react": "11.2.6",
|
||||||
"@tldraw/vec": "0.0.106",
|
|
||||||
"@types/jest": "26.0.22",
|
"@types/jest": "26.0.22",
|
||||||
"@types/react": "17.0.3",
|
"@types/react": "17.0.3",
|
||||||
"@types/react-dom": "17.0.3",
|
"@types/react-dom": "17.0.3",
|
||||||
"@types/socket.io-client": "1.4.36",
|
"@types/socket.io-client": "1.4.36",
|
||||||
|
"browser-fs-access": "0.16.4",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"firebase": "8.3.3",
|
"firebase": "8.3.3",
|
||||||
"i18next-browser-languagedetector": "6.1.0",
|
"i18next-browser-languagedetector": "6.1.0",
|
||||||
@@ -37,7 +35,7 @@
|
|||||||
"nanoid": "3.1.22",
|
"nanoid": "3.1.22",
|
||||||
"open-color": "1.8.0",
|
"open-color": "1.8.0",
|
||||||
"pako": "1.0.11",
|
"pako": "1.0.11",
|
||||||
"perfect-freehand": "1.0.15",
|
"perfect-freehand": "0.4.7",
|
||||||
"png-chunk-text": "1.0.0",
|
"png-chunk-text": "1.0.0",
|
||||||
"png-chunks-encode": "1.0.0",
|
"png-chunks-encode": "1.0.0",
|
||||||
"png-chunks-extract": "1.0.0",
|
"png-chunks-extract": "1.0.0",
|
||||||
@@ -78,7 +76,7 @@
|
|||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)"
|
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
||||||
],
|
],
|
||||||
"resetMocks": false
|
"resetMocks": false
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
@@ -13,18 +13,6 @@
|
|||||||
|
|
||||||
<meta name="theme-color" content="#000" />
|
<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 -->
|
<!-- General tags -->
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
@@ -129,7 +117,6 @@
|
|||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"capture_links": "new-client",
|
"capture_links": "new_client",
|
||||||
"share_target": {
|
"share_target": {
|
||||||
"action": "/web-share-target",
|
"action": "/web-share-target",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ const crowdinMap = {
|
|||||||
"zh-CN": "en-zhcn",
|
"zh-CN": "en-zhcn",
|
||||||
"zh-TW": "en-zhtw",
|
"zh-TW": "en-zhtw",
|
||||||
"lv-LV": "en-lv",
|
"lv-LV": "en-lv",
|
||||||
"cs-CZ": "en-cs",
|
|
||||||
"kk-KZ": "en-kk",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const flags = {
|
const flags = {
|
||||||
@@ -78,8 +76,6 @@ const flags = {
|
|||||||
"zh-CN": "🇨🇳",
|
"zh-CN": "🇨🇳",
|
||||||
"zh-TW": "🇹🇼",
|
"zh-TW": "🇹🇼",
|
||||||
"lv-LV": "🇱🇻",
|
"lv-LV": "🇱🇻",
|
||||||
"cs-CZ": "🇨🇿",
|
|
||||||
"kk-KZ": "🇰🇿",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const languages = {
|
const languages = {
|
||||||
@@ -118,8 +114,6 @@ const languages = {
|
|||||||
"zh-CN": "简体中文",
|
"zh-CN": "简体中文",
|
||||||
"zh-TW": "繁體中文",
|
"zh-TW": "繁體中文",
|
||||||
"lv-LV": "Latviešu",
|
"lv-LV": "Latviešu",
|
||||||
"cs-CZ": "Česky",
|
|
||||||
"kk-KZ": "Қазақ тілі",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const percentages = fs.readFileSync(
|
const percentages = fs.readFileSync(
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const util = require("util");
|
|
||||||
const exec = util.promisify(require("child_process").exec);
|
|
||||||
const updateReadme = require("./updateReadme");
|
|
||||||
const updateChangelog = require("./updateChangelog");
|
|
||||||
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
|
||||||
|
|
||||||
const updatePackageVersion = (nextVersion) => {
|
|
||||||
const pkg = require(excalidrawPackage);
|
|
||||||
pkg.version = nextVersion;
|
|
||||||
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
|
||||||
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
|
||||||
};
|
|
||||||
|
|
||||||
const release = async (nextVersion) => {
|
|
||||||
try {
|
|
||||||
updateReadme();
|
|
||||||
await updateChangelog(nextVersion);
|
|
||||||
updatePackageVersion(nextVersion);
|
|
||||||
await exec(`git add -u`);
|
|
||||||
await exec(
|
|
||||||
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
|
||||||
);
|
|
||||||
/* eslint-disable no-console */
|
|
||||||
console.log("Done!");
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextVersion = process.argv.slice(2)[0];
|
|
||||||
if (!nextVersion) {
|
|
||||||
console.error("Pass the next version to release!");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
release(nextVersion);
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const util = require("util");
|
|
||||||
const exec = util.promisify(require("child_process").exec);
|
|
||||||
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
|
||||||
const pkg = require(excalidrawPackage);
|
|
||||||
const lastVersion = pkg.version;
|
|
||||||
const existingChangeLog = fs.readFileSync(
|
|
||||||
`${excalidrawDir}/CHANGELOG.md`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
|
|
||||||
const headerForType = {
|
|
||||||
feat: "Features",
|
|
||||||
fix: "Fixes",
|
|
||||||
style: "Styles",
|
|
||||||
refactor: " Refactor",
|
|
||||||
perf: "Performance",
|
|
||||||
build: "Build",
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCommitHashForLastVersion = async () => {
|
|
||||||
try {
|
|
||||||
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
|
|
||||||
const { stdout } = await exec(
|
|
||||||
`git log --format=format:"%H" --grep=${commitMessage}`,
|
|
||||||
);
|
|
||||||
return stdout;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLibraryCommitsSinceLastRelease = async () => {
|
|
||||||
const commitHash = await getCommitHashForLastVersion();
|
|
||||||
const { stdout } = await exec(
|
|
||||||
`git log --pretty=format:%s ${commitHash}...master`,
|
|
||||||
);
|
|
||||||
const commitsSinceLastRelease = stdout.split("\n");
|
|
||||||
const commitList = {};
|
|
||||||
supportedTypes.forEach((type) => {
|
|
||||||
commitList[type] = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
commitsSinceLastRelease.forEach((commit) => {
|
|
||||||
const indexOfColon = commit.indexOf(":");
|
|
||||||
const type = commit.slice(0, indexOfColon);
|
|
||||||
if (!supportedTypes.includes(type)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const messageWithoutType = commit.slice(indexOfColon + 1).trim();
|
|
||||||
const messageWithCapitalizeFirst =
|
|
||||||
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
|
|
||||||
const prNumber = commit.match(/\(#([0-9]*)\)/)[1];
|
|
||||||
|
|
||||||
// return if the changelog already contains the pr number which would happen for package updates
|
|
||||||
if (existingChangeLog.includes(prNumber)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
|
|
||||||
const messageWithPRLink = messageWithCapitalizeFirst.replace(
|
|
||||||
/\(#[0-9]*\)/,
|
|
||||||
prMarkdown,
|
|
||||||
);
|
|
||||||
commitList[type].push(messageWithPRLink);
|
|
||||||
});
|
|
||||||
return commitList;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateChangelog = async (nextVersion) => {
|
|
||||||
const commitList = await getLibraryCommitsSinceLastRelease();
|
|
||||||
let changelogForLibrary =
|
|
||||||
"## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
|
|
||||||
supportedTypes.forEach((type) => {
|
|
||||||
if (commitList[type].length) {
|
|
||||||
changelogForLibrary += `### ${headerForType[type]}\n\n`;
|
|
||||||
const commits = commitList[type];
|
|
||||||
commits.forEach((commit) => {
|
|
||||||
changelogForLibrary += `- ${commit}\n\n`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
changelogForLibrary += "---\n";
|
|
||||||
const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
|
|
||||||
let updatedContent =
|
|
||||||
existingChangeLog.slice(0, lastVersionIndex) +
|
|
||||||
changelogForLibrary +
|
|
||||||
existingChangeLog.slice(lastVersionIndex);
|
|
||||||
const currentDate = new Date().toISOString().slice(0, 10);
|
|
||||||
const newVersion = `## ${nextVersion} (${currentDate})`;
|
|
||||||
updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
|
|
||||||
fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = updateChangelog;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
const updateReadme = () => {
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
|
||||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
|
||||||
|
|
||||||
// remove note for unstable release
|
|
||||||
data = data.replace(
|
|
||||||
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
// replace "excalidraw-next" with "excalidraw"
|
|
||||||
data = data.replace(/excalidraw-next/g, "excalidraw");
|
|
||||||
data = data.trim();
|
|
||||||
|
|
||||||
const demoIndex = data.indexOf("### Demo");
|
|
||||||
const excalidrawNextNote =
|
|
||||||
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
|
|
||||||
// Add excalidraw next note to try out for unreleased changes
|
|
||||||
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
|
|
||||||
|
|
||||||
// update readme
|
|
||||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = updateReadme;
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { alignElements, Alignment } from "../align";
|
import { alignElements, Alignment } from "../align";
|
||||||
import {
|
import {
|
||||||
AlignBottomIcon,
|
AlignBottomIcon,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { trash, zoomIn, zoomOut } from "../components/icons";
|
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||||
import { THEME, ZOOM_STEP } from "../constants";
|
import { ZOOM_STEP } from "../constants";
|
||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
@@ -16,14 +17,13 @@ import { getNewZoom } from "../scene/zoom";
|
|||||||
import { AppState, NormalizedZoomValue } from "../types";
|
import { AppState, NormalizedZoomValue } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, ...value },
|
appState: { ...appState, viewBackgroundColor: value },
|
||||||
commitToHistory: !!value.viewBackgroundColor,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => {
|
PanelComponent: ({ appState, updateData }) => {
|
||||||
@@ -33,11 +33,7 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
label={t("labels.canvasBackground")}
|
label={t("labels.canvasBackground")}
|
||||||
type="canvasBackground"
|
type="canvasBackground"
|
||||||
color={appState.viewBackgroundColor}
|
color={appState.viewBackgroundColor}
|
||||||
onChange={(color) => updateData({ viewBackgroundColor: color })}
|
onChange={(color) => updateData(color)}
|
||||||
isActive={appState.openPopup === "canvasColorPicker"}
|
|
||||||
setActive={(active) =>
|
|
||||||
updateData({ openPopup: active ? "canvasColorPicker" : null })
|
|
||||||
}
|
|
||||||
data-testid="canvas-background-picker"
|
data-testid="canvas-background-picker"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,6 +55,7 @@ export const actionClearCanvas = register({
|
|||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
exportEmbedScene: appState.exportEmbedScene,
|
exportEmbedScene: appState.exportEmbedScene,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
|
shouldAddWatermark: appState.shouldAddWatermark,
|
||||||
showStats: appState.showStats,
|
showStats: appState.showStats,
|
||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
},
|
},
|
||||||
@@ -108,7 +105,6 @@ export const actionZoomIn = register({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
size="small"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -143,7 +139,6 @@ export const actionZoomOut = register({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
size="small"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -170,21 +165,16 @@ export const actionResetZoom = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<Tooltip label={t("buttons.resetZoom")}>
|
<ToolButton
|
||||||
<ToolButton
|
type="button"
|
||||||
type="button"
|
icon={resetZoom}
|
||||||
className="reset-zoom-button"
|
title={t("buttons.resetZoom")}
|
||||||
title={t("buttons.resetZoom")}
|
aria-label={t("buttons.resetZoom")}
|
||||||
aria-label={t("buttons.resetZoom")}
|
onClick={() => {
|
||||||
onClick={() => {
|
updateData(null);
|
||||||
updateData(null);
|
}}
|
||||||
}}
|
/>
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{(appState.zoom.value * 100).toFixed(0)}%
|
|
||||||
</ToolButton>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||||
@@ -278,8 +268,7 @@ export const actionToggleTheme = register({
|
|||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
theme:
|
theme: value || (appState.theme === "light" ? "dark" : "light"),
|
||||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import React from "react";
|
||||||
import { trash } from "../components/icons";
|
import { trash } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
DistributeHorizontallyIcon,
|
DistributeHorizontallyIcon,
|
||||||
DistributeVerticallyIcon,
|
DistributeVerticallyIcon,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { load, questionCircle, saveAs } from "../components/icons";
|
import { load, questionCircle, save, saveAs } from "../components/icons";
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import "../components/ToolIcon.scss";
|
import "../components/ToolIcon.scss";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
|
||||||
import { loadFromJSON, saveAsJSON } from "../data";
|
import { loadFromJSON, saveAsJSON } from "../data";
|
||||||
import { resaveAsImageWithScene } from "../data/resave";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { supported as fsSupported } from "browser-fs-access";
|
||||||
import { CheckboxItem } from "../components/CheckboxItem";
|
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({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
@@ -39,54 +32,6 @@ export const actionChangeProjectName = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeExportScale = register({
|
|
||||||
name: "changeExportScale",
|
|
||||||
perform: (_elements, appState, value) => {
|
|
||||||
return {
|
|
||||||
appState: { ...appState, exportScale: value },
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
|
||||||
const elements = getNonDeletedElements(allElements);
|
|
||||||
const exportSelected = isSomeElementSelected(elements, appState);
|
|
||||||
const exportedElements = exportSelected
|
|
||||||
? getSelectedElements(elements, appState)
|
|
||||||
: elements;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{EXPORT_SCALES.map((s) => {
|
|
||||||
const [width, height] = getExportSize(
|
|
||||||
exportedElements,
|
|
||||||
DEFAULT_EXPORT_PADDING,
|
|
||||||
s,
|
|
||||||
);
|
|
||||||
|
|
||||||
const scaleButtonTitle = `${t(
|
|
||||||
"buttons.scale",
|
|
||||||
)} ${s}x (${width}x${height})`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToolButton
|
|
||||||
key={s}
|
|
||||||
size="small"
|
|
||||||
type="radio"
|
|
||||||
icon={`${s}x`}
|
|
||||||
name="export-canvas-scale"
|
|
||||||
title={scaleButtonTitle}
|
|
||||||
aria-label={scaleButtonTitle}
|
|
||||||
id="export-canvas-scale"
|
|
||||||
checked={s === appState.exportScale}
|
|
||||||
onChange={() => updateData(s)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionChangeExportBackground = register({
|
export const actionChangeExportBackground = register({
|
||||||
name: "changeExportBackground",
|
name: "changeExportBackground",
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
@@ -120,29 +65,43 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
>
|
>
|
||||||
{t("labels.exportEmbedScene")}
|
{t("labels.exportEmbedScene")}
|
||||||
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
||||||
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
|
<div className="Tooltip-icon">{questionCircle}</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</CheckboxItem>
|
</CheckboxItem>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionSaveToActiveFile = register({
|
export const actionChangeShouldAddWatermark = register({
|
||||||
name: "saveToActiveFile",
|
name: "changeShouldAddWatermark",
|
||||||
|
perform: (_elements, appState, value) => {
|
||||||
|
return {
|
||||||
|
appState: { ...appState, shouldAddWatermark: value },
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
|
<CheckboxItem
|
||||||
|
checked={appState.shouldAddWatermark}
|
||||||
|
onChange={(checked) => updateData(checked)}
|
||||||
|
>
|
||||||
|
{t("labels.addWatermark")}
|
||||||
|
</CheckboxItem>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionSaveScene = register({
|
||||||
|
name: "saveScene",
|
||||||
perform: async (elements, appState, value) => {
|
perform: async (elements, appState, value) => {
|
||||||
const fileHandleExists = !!appState.fileHandle;
|
const fileHandleExists = !!appState.fileHandle;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||||
? await resaveAsImageWithScene(elements, appState)
|
|
||||||
: await saveAsJSON(elements, appState);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
fileHandle,
|
fileHandle,
|
||||||
toastMessage: fileHandleExists
|
toastMessage: fileHandleExists
|
||||||
? fileHandle?.name
|
? fileHandle.name
|
||||||
? t("toast.fileSavedToFilename").replace(
|
? t("toast.fileSavedToFilename").replace(
|
||||||
"{filename}",
|
"{filename}",
|
||||||
`"${fileHandle.name}"`,
|
`"${fileHandle.name}"`,
|
||||||
@@ -160,16 +119,20 @@ export const actionSaveToActiveFile = register({
|
|||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ActiveFile
|
<ToolButton
|
||||||
onSave={() => updateData(null)}
|
type="icon"
|
||||||
fileName={appState.fileHandle?.name}
|
icon={save}
|
||||||
|
title={t("buttons.save")}
|
||||||
|
aria-label={t("buttons.save")}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
data-testid="save-button"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionSaveFileToDisk = register({
|
export const actionSaveAsScene = register({
|
||||||
name: "saveFileToDisk",
|
name: "saveAsScene",
|
||||||
perform: async (elements, appState, value) => {
|
perform: async (elements, appState, value) => {
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(elements, {
|
const { fileHandle } = await saveAsJSON(elements, {
|
||||||
@@ -193,7 +156,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
title={t("buttons.saveAs")}
|
title={t("buttons.saveAs")}
|
||||||
aria-label={t("buttons.saveAs")}
|
aria-label={t("buttons.saveAs")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
hidden={!nativeFileSystemSupported}
|
hidden={!fsSupported}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
data-testid="save-as-button"
|
data-testid="save-as-button"
|
||||||
/>
|
/>
|
||||||
@@ -207,7 +170,7 @@ export const actionLoadScene = register({
|
|||||||
const {
|
const {
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
} = await loadFromJSON(appState, elements);
|
} = await loadFromJSON(appState);
|
||||||
return {
|
return {
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
@@ -256,9 +219,9 @@ export const actionExportWithDarkMode = register({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DarkModeToggle
|
<DarkModeToggle
|
||||||
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
|
value={appState.exportWithDarkMode ? "dark" : "light"}
|
||||||
onChange={(theme: Theme) => {
|
onChange={(theme: Appearence) => {
|
||||||
updateData(theme === THEME.DARK);
|
updateData(theme === "dark");
|
||||||
}}
|
}}
|
||||||
title={t("labels.toggleExportColorScheme")}
|
title={t("labels.toggleExportColorScheme")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isInvisiblySmallElement } from "../element";
|
import { isInvisiblySmallElement } from "../element";
|
||||||
import { resetCursor } from "../utils";
|
import { resetCursor } from "../utils";
|
||||||
|
import React from "react";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Action, ActionResult } from "./types";
|
import { Action, ActionResult } from "./types";
|
||||||
|
import React from "react";
|
||||||
import { undo, redo } from "../components/icons";
|
import { undo, redo } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -68,13 +69,12 @@ export const createUndoAction: ActionCreator = (history) => ({
|
|||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.key.toLowerCase() === KEYS.Z &&
|
event.key.toLowerCase() === KEYS.Z &&
|
||||||
!event.shiftKey,
|
!event.shiftKey,
|
||||||
PanelComponent: ({ updateData, data }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={undo}
|
icon={undo}
|
||||||
aria-label={t("buttons.undo")}
|
aria-label={t("buttons.undo")}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
size={data?.size || "medium"}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
commitToHistory: () => false,
|
commitToHistory: () => false,
|
||||||
@@ -89,13 +89,12 @@ export const createRedoAction: ActionCreator = (history) => ({
|
|||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event.key.toLowerCase() === KEYS.Z) ||
|
event.key.toLowerCase() === KEYS.Z) ||
|
||||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||||
PanelComponent: ({ updateData, data }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={redo}
|
icon={redo}
|
||||||
aria-label={t("buttons.redo")}
|
aria-label={t("buttons.redo")}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
size={data?.size || "medium"}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
commitToHistory: () => false,
|
commitToHistory: () => false,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { menu, palette } from "../components/icons";
|
import { menu, palette } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { getClientColors, getClientInitials } from "../clients";
|
import { getClientColors, getClientInitials } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
@@ -29,8 +30,8 @@ export const actionGoToCollaborator = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, data }) => {
|
PanelComponent: ({ appState, updateData, id }) => {
|
||||||
const clientId: string | undefined = data?.id;
|
const clientId = id;
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { AppState } from "../../src/types";
|
import { AppState } from "../../src/types";
|
||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
@@ -12,13 +13,6 @@ import {
|
|||||||
FillCrossHatchIcon,
|
FillCrossHatchIcon,
|
||||||
FillHachureIcon,
|
FillHachureIcon,
|
||||||
FillSolidIcon,
|
FillSolidIcon,
|
||||||
FontFamilyCodeIcon,
|
|
||||||
FontFamilyHandDrawnIcon,
|
|
||||||
FontFamilyNormalIcon,
|
|
||||||
FontSizeExtraLargeIcon,
|
|
||||||
FontSizeLargeIcon,
|
|
||||||
FontSizeMediumIcon,
|
|
||||||
FontSizeSmallIcon,
|
|
||||||
SloppinessArchitectIcon,
|
SloppinessArchitectIcon,
|
||||||
SloppinessArtistIcon,
|
SloppinessArtistIcon,
|
||||||
SloppinessCartoonistIcon,
|
SloppinessCartoonistIcon,
|
||||||
@@ -26,15 +20,18 @@ import {
|
|||||||
StrokeStyleDottedIcon,
|
StrokeStyleDottedIcon,
|
||||||
StrokeStyleSolidIcon,
|
StrokeStyleSolidIcon,
|
||||||
StrokeWidthIcon,
|
StrokeWidthIcon,
|
||||||
TextAlignCenterIcon,
|
FontSizeSmallIcon,
|
||||||
|
FontSizeMediumIcon,
|
||||||
|
FontSizeLargeIcon,
|
||||||
|
FontSizeExtraLargeIcon,
|
||||||
|
FontFamilyHandDrawnIcon,
|
||||||
|
FontFamilyNormalIcon,
|
||||||
|
FontFamilyCodeIcon,
|
||||||
TextAlignLeftIcon,
|
TextAlignLeftIcon,
|
||||||
|
TextAlignCenterIcon,
|
||||||
TextAlignRightIcon,
|
TextAlignRightIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import {
|
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
|
||||||
DEFAULT_FONT_FAMILY,
|
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
FONT_FAMILY,
|
|
||||||
} from "../constants";
|
|
||||||
import {
|
import {
|
||||||
getNonDeletedElements,
|
getNonDeletedElements,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
@@ -47,7 +44,7 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
FontFamilyValues,
|
FontFamily,
|
||||||
TextAlign,
|
TextAlign,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getLanguage, t } from "../i18n";
|
import { getLanguage, t } from "../i18n";
|
||||||
@@ -102,18 +99,13 @@ export const actionChangeStrokeColor = register({
|
|||||||
name: "changeStrokeColor",
|
name: "changeStrokeColor",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
...(value.currentItemStrokeColor && {
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
newElementWith(el, {
|
||||||
newElementWith(el, {
|
strokeColor: value,
|
||||||
strokeColor: value.currentItemStrokeColor,
|
}),
|
||||||
}),
|
),
|
||||||
),
|
appState: { ...appState, currentItemStrokeColor: value },
|
||||||
}),
|
commitToHistory: true,
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
...value,
|
|
||||||
},
|
|
||||||
commitToHistory: !!value.currentItemStrokeColor,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@@ -128,11 +120,7 @@ export const actionChangeStrokeColor = register({
|
|||||||
(element) => element.strokeColor,
|
(element) => element.strokeColor,
|
||||||
appState.currentItemStrokeColor,
|
appState.currentItemStrokeColor,
|
||||||
)}
|
)}
|
||||||
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
onChange={updateData}
|
||||||
isActive={appState.openPopup === "strokeColorPicker"}
|
|
||||||
setActive={(active) =>
|
|
||||||
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -142,18 +130,13 @@ export const actionChangeBackgroundColor = register({
|
|||||||
name: "changeBackgroundColor",
|
name: "changeBackgroundColor",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
...(value.currentItemBackgroundColor && {
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
newElementWith(el, {
|
||||||
newElementWith(el, {
|
backgroundColor: value,
|
||||||
backgroundColor: value.currentItemBackgroundColor,
|
}),
|
||||||
}),
|
),
|
||||||
),
|
appState: { ...appState, currentItemBackgroundColor: value },
|
||||||
}),
|
commitToHistory: true,
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
...value,
|
|
||||||
},
|
|
||||||
commitToHistory: !!value.currentItemBackgroundColor,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@@ -168,11 +151,7 @@ export const actionChangeBackgroundColor = register({
|
|||||||
(element) => element.backgroundColor,
|
(element) => element.backgroundColor,
|
||||||
appState.currentItemBackgroundColor,
|
appState.currentItemBackgroundColor,
|
||||||
)}
|
)}
|
||||||
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
onChange={updateData}
|
||||||
isActive={appState.openPopup === "backgroundColorPicker"}
|
|
||||||
setActive={(active) =>
|
|
||||||
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -502,23 +481,19 @@ export const actionChangeFontFamily = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
const options: {
|
const options: { value: FontFamily; text: string; icon: JSX.Element }[] = [
|
||||||
value: FontFamilyValues;
|
|
||||||
text: string;
|
|
||||||
icon: JSX.Element;
|
|
||||||
}[] = [
|
|
||||||
{
|
{
|
||||||
value: FONT_FAMILY.Virgil,
|
value: 1,
|
||||||
text: t("labels.handDrawn"),
|
text: t("labels.handDrawn"),
|
||||||
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
|
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: FONT_FAMILY.Helvetica,
|
value: 2,
|
||||||
text: t("labels.normal"),
|
text: t("labels.normal"),
|
||||||
icon: <FontFamilyNormalIcon theme={appState.theme} />,
|
icon: <FontFamilyNormalIcon theme={appState.theme} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: FONT_FAMILY.Cascadia,
|
value: 3,
|
||||||
text: t("labels.code"),
|
text: t("labels.code"),
|
||||||
icon: <FontFamilyCodeIcon theme={appState.theme} />,
|
icon: <FontFamilyCodeIcon theme={appState.theme} />,
|
||||||
},
|
},
|
||||||
@@ -527,7 +502,7 @@ export const actionChangeFontFamily = register({
|
|||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fontFamily")}</legend>
|
<legend>{t("labels.fontFamily")}</legend>
|
||||||
<ButtonIconSelect<FontFamilyValues | false>
|
<ButtonIconSelect<FontFamily | false>
|
||||||
group="font-family"
|
group="font-family"
|
||||||
options={options}
|
options={options}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const actionToggleViewMode = register({
|
|||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
viewModeEnabled: !this.checked!(appState),
|
viewModeEnabled: !this.checked!(appState),
|
||||||
|
selectedElementIds: {},
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ export { actionFinalize } from "./actionFinalize";
|
|||||||
export {
|
export {
|
||||||
actionChangeProjectName,
|
actionChangeProjectName,
|
||||||
actionChangeExportBackground,
|
actionChangeExportBackground,
|
||||||
actionSaveToActiveFile,
|
actionSaveScene,
|
||||||
actionSaveFileToDisk,
|
actionSaveAsScene,
|
||||||
actionLoadScene,
|
actionLoadScene,
|
||||||
} from "./actionExport";
|
} from "./actionExport";
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
UpdaterFn,
|
UpdaterFn,
|
||||||
ActionName,
|
ActionName,
|
||||||
ActionResult,
|
ActionResult,
|
||||||
PanelComponentProps,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppProps, AppState } from "../types";
|
import { AppProps, AppState } from "../types";
|
||||||
@@ -108,10 +107,11 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Id is an attribute that we can use to pass in data like keys.
|
||||||
* @param data additional data sent to the PanelComponent
|
// This is needed for dynamically generated action components
|
||||||
*/
|
// like the user list. We can use this key to extract more
|
||||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
// data from app state. This is an alternative to generic prop hell!
|
||||||
|
renderAction = (name: ActionName, id?: string) => {
|
||||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -139,8 +139,8 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
elements={this.getElementsIncludingDeleted()}
|
elements={this.getElementsIncludingDeleted()}
|
||||||
appState={this.getAppState()}
|
appState={this.getAppState()}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
id={id}
|
||||||
appProps={this.app.props}
|
appProps={this.app.props}
|
||||||
data={data}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-13
@@ -2,7 +2,6 @@ import React from "react";
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, ExcalidrawProps } from "../types";
|
import { AppState, ExcalidrawProps } from "../types";
|
||||||
import Library from "../data/library";
|
import Library from "../data/library";
|
||||||
import { ToolButtonSize } from "../components/ToolButton";
|
|
||||||
|
|
||||||
/** if false, the action should be prevented */
|
/** if false, the action should be prevented */
|
||||||
export type ActionResult =
|
export type ActionResult =
|
||||||
@@ -67,9 +66,9 @@ export type ActionName =
|
|||||||
| "changeProjectName"
|
| "changeProjectName"
|
||||||
| "changeExportBackground"
|
| "changeExportBackground"
|
||||||
| "changeExportEmbedScene"
|
| "changeExportEmbedScene"
|
||||||
| "changeExportScale"
|
| "changeShouldAddWatermark"
|
||||||
| "saveToActiveFile"
|
| "saveScene"
|
||||||
| "saveFileToDisk"
|
| "saveAsScene"
|
||||||
| "loadScene"
|
| "loadScene"
|
||||||
| "duplicateSelection"
|
| "duplicateSelection"
|
||||||
| "deleteSelectedElements"
|
| "deleteSelectedElements"
|
||||||
@@ -103,17 +102,15 @@ export type ActionName =
|
|||||||
| "exportWithDarkMode"
|
| "exportWithDarkMode"
|
||||||
| "toggleTheme";
|
| "toggleTheme";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
|
||||||
elements: readonly ExcalidrawElement[];
|
|
||||||
appState: AppState;
|
|
||||||
updateData: (formData?: any) => void;
|
|
||||||
appProps: ExcalidrawProps;
|
|
||||||
data?: Partial<{ id: string; size: ToolButtonSize }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: ActionName;
|
name: ActionName;
|
||||||
PanelComponent?: React.FC<PanelComponentProps>;
|
PanelComponent?: React.FC<{
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
appState: AppState;
|
||||||
|
updateData: (formData?: any) => void;
|
||||||
|
appProps: ExcalidrawProps;
|
||||||
|
id?: string;
|
||||||
|
}>;
|
||||||
perform: ActionFn;
|
perform: ActionFn;
|
||||||
keyPriority?: number;
|
keyPriority?: number;
|
||||||
keyTest?: (
|
keyTest?: (
|
||||||
|
|||||||
+3
-11
@@ -3,23 +3,17 @@ import {
|
|||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
EXPORT_SCALES,
|
|
||||||
THEME,
|
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
import { AppState, NormalizedZoomValue } from "./types";
|
import { AppState, NormalizedZoomValue } from "./types";
|
||||||
import { getDateTime } from "./utils";
|
import { getDateTime } from "./utils";
|
||||||
|
|
||||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
|
||||||
? devicePixelRatio
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
export const getDefaultAppState = (): Omit<
|
export const getDefaultAppState = (): Omit<
|
||||||
AppState,
|
AppState,
|
||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
> => {
|
> => {
|
||||||
return {
|
return {
|
||||||
theme: THEME.LIGHT,
|
theme: "light",
|
||||||
collaborators: new Map(),
|
collaborators: new Map(),
|
||||||
currentChartType: "bar",
|
currentChartType: "bar",
|
||||||
currentItemBackgroundColor: "transparent",
|
currentItemBackgroundColor: "transparent",
|
||||||
@@ -45,7 +39,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
elementType: "selection",
|
elementType: "selection",
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
exportBackground: true,
|
exportBackground: true,
|
||||||
exportScale: defaultExportScale,
|
|
||||||
exportEmbedScene: false,
|
exportEmbedScene: false,
|
||||||
exportWithDarkMode: false,
|
exportWithDarkMode: false,
|
||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
@@ -59,7 +52,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
multiElement: null,
|
multiElement: null,
|
||||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||||
openMenu: null,
|
openMenu: null,
|
||||||
openPopup: null,
|
|
||||||
pasteDialog: { shown: false, data: null },
|
pasteDialog: { shown: false, data: null },
|
||||||
previousSelectedElementIds: {},
|
previousSelectedElementIds: {},
|
||||||
resizingElement: null,
|
resizingElement: null,
|
||||||
@@ -69,6 +61,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
|
shouldAddWatermark: false,
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
showHelpDialog: false,
|
showHelpDialog: false,
|
||||||
showStats: false,
|
showStats: false,
|
||||||
@@ -124,7 +117,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
errorMessage: { browser: false, export: false },
|
errorMessage: { browser: false, export: false },
|
||||||
exportBackground: { browser: true, export: false },
|
exportBackground: { browser: true, export: false },
|
||||||
exportEmbedScene: { browser: true, export: false },
|
exportEmbedScene: { browser: true, export: false },
|
||||||
exportScale: { browser: true, export: false },
|
|
||||||
exportWithDarkMode: { browser: true, export: false },
|
exportWithDarkMode: { browser: true, export: false },
|
||||||
fileHandle: { browser: false, export: false },
|
fileHandle: { browser: false, export: false },
|
||||||
gridSize: { browser: true, export: true },
|
gridSize: { browser: true, export: true },
|
||||||
@@ -140,7 +132,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
offsetLeft: { browser: false, export: false },
|
offsetLeft: { browser: false, export: false },
|
||||||
offsetTop: { browser: false, export: false },
|
offsetTop: { browser: false, export: false },
|
||||||
openMenu: { browser: true, export: false },
|
openMenu: { browser: true, export: false },
|
||||||
openPopup: { browser: false, export: false },
|
|
||||||
pasteDialog: { browser: false, export: false },
|
pasteDialog: { browser: false, export: false },
|
||||||
previousSelectedElementIds: { browser: true, export: false },
|
previousSelectedElementIds: { browser: true, export: false },
|
||||||
resizingElement: { browser: false, export: false },
|
resizingElement: { browser: false, export: false },
|
||||||
@@ -150,6 +141,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
selectedElementIds: { browser: true, export: false },
|
selectedElementIds: { browser: true, export: false },
|
||||||
selectedGroupIds: { browser: true, export: false },
|
selectedGroupIds: { browser: true, export: false },
|
||||||
selectionElement: { browser: false, export: false },
|
selectionElement: { browser: false, export: false },
|
||||||
|
shouldAddWatermark: { browser: true, export: false },
|
||||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
shouldCacheIgnoreZoom: { browser: true, export: false },
|
||||||
showHelpDialog: { browser: false, export: false },
|
showHelpDialog: { browser: false, export: false },
|
||||||
showStats: { browser: true, export: false },
|
showStats: { browser: true, export: false },
|
||||||
|
|||||||
@@ -151,14 +151,23 @@ 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 = ({
|
export const ShapesSwitcher = ({
|
||||||
canvas,
|
canvas,
|
||||||
elementType,
|
elementType,
|
||||||
setAppState,
|
setAppState,
|
||||||
|
isLibraryOpen,
|
||||||
}: {
|
}: {
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
elementType: ExcalidrawElement["type"];
|
elementType: ExcalidrawElement["type"];
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
isLibraryOpen: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<>
|
<>
|
||||||
{SHAPES.map(({ value, icon, key }, index) => {
|
{SHAPES.map(({ value, icon, key }, index) => {
|
||||||
@@ -192,6 +201,19 @@ 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 });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -204,9 +226,12 @@ export const ZoomActions = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<Stack.Col gap={1}>
|
<Stack.Col gap={1}>
|
||||||
<Stack.Row gap={1} align="center">
|
<Stack.Row gap={1} align="center">
|
||||||
{renderAction("zoomOut")}
|
|
||||||
{renderAction("zoomIn")}
|
{renderAction("zoomIn")}
|
||||||
|
{renderAction("zoomOut")}
|
||||||
{renderAction("resetZoom")}
|
{renderAction("resetZoom")}
|
||||||
|
<div style={{ marginInlineStart: 4 }}>
|
||||||
|
{(zoom.value * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
.excalidraw {
|
|
||||||
.ActiveFile {
|
|
||||||
.ActiveFile__fileName {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
span {
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
width: 9.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 1.15em;
|
|
||||||
margin-inline-end: 0.3em;
|
|
||||||
transform: scaleY(0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import Stack from "../components/Stack";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
|
||||||
import { save, file } from "../components/icons";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
import "./ActiveFile.scss";
|
|
||||||
|
|
||||||
type ActiveFileProps = {
|
|
||||||
fileName?: string;
|
|
||||||
onSave: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
|
|
||||||
<Stack.Row className="ActiveFile" gap={1} align="center">
|
|
||||||
<span className="ActiveFile__fileName">
|
|
||||||
{file}
|
|
||||||
<span>{fileName}</span>
|
|
||||||
</span>
|
|
||||||
<ToolButton
|
|
||||||
type="icon"
|
|
||||||
icon={save}
|
|
||||||
title={t("buttons.save")}
|
|
||||||
aria-label={t("buttons.save")}
|
|
||||||
onClick={onSave}
|
|
||||||
data-testid="save-button"
|
|
||||||
/>
|
|
||||||
</Stack.Row>
|
|
||||||
);
|
|
||||||
+316
-318
@@ -2,6 +2,7 @@ import React, { useContext } from "react";
|
|||||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { supported as fsSupported } from "browser-fs-access";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -60,7 +61,6 @@ import {
|
|||||||
SCROLL_TIMEOUT,
|
SCROLL_TIMEOUT,
|
||||||
TAP_TWICE_TIMEOUT,
|
TAP_TWICE_TIMEOUT,
|
||||||
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
||||||
THEME,
|
|
||||||
TOUCH_CTX_MENU_TIMEOUT,
|
TOUCH_CTX_MENU_TIMEOUT,
|
||||||
URL_HASH_KEYS,
|
URL_HASH_KEYS,
|
||||||
URL_QUERY_KEYS,
|
URL_QUERY_KEYS,
|
||||||
@@ -111,6 +111,7 @@ import {
|
|||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
|
import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
|
||||||
|
import { MaybeTransformHandleType } from "../element/transformHandles";
|
||||||
import {
|
import {
|
||||||
isBindingElement,
|
isBindingElement,
|
||||||
isBindingElementType,
|
isBindingElementType,
|
||||||
@@ -156,7 +157,6 @@ import {
|
|||||||
getElementsWithinSelection,
|
getElementsWithinSelection,
|
||||||
getNormalizedZoom,
|
getNormalizedZoom,
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
hasBackground,
|
|
||||||
isOverScrollBars,
|
isOverScrollBars,
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
@@ -167,11 +167,9 @@ import { findShapeByKey } from "../shapes";
|
|||||||
import {
|
import {
|
||||||
AppProps,
|
AppProps,
|
||||||
AppState,
|
AppState,
|
||||||
ExcalidrawImperativeAPI,
|
|
||||||
Gesture,
|
Gesture,
|
||||||
GestureEvent,
|
GestureEvent,
|
||||||
LibraryItems,
|
LibraryItems,
|
||||||
PointerDownState,
|
|
||||||
SceneData,
|
SceneData,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
@@ -182,6 +180,7 @@ import {
|
|||||||
isToolIcon,
|
isToolIcon,
|
||||||
isWritableElement,
|
isWritableElement,
|
||||||
resetCursor,
|
resetCursor,
|
||||||
|
ResolvablePromise,
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
sceneCoordsToViewportCoords,
|
sceneCoordsToViewportCoords,
|
||||||
setCursor,
|
setCursor,
|
||||||
@@ -195,14 +194,12 @@ import LayerUI from "./LayerUI";
|
|||||||
import { Stats } from "./Stats";
|
import { Stats } from "./Stats";
|
||||||
import { Toast } from "./Toast";
|
import { Toast } from "./Toast";
|
||||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
|
||||||
|
|
||||||
const IsMobileContext = React.createContext(false);
|
const IsMobileContext = React.createContext(false);
|
||||||
export const useIsMobile = () => useContext(IsMobileContext);
|
export const useIsMobile = () => useContext(IsMobileContext);
|
||||||
const ExcalidrawContainerContext = React.createContext<{
|
const ExcalidrawContainerContext = React.createContext<HTMLDivElement | null>(
|
||||||
container: HTMLDivElement | null;
|
null,
|
||||||
id: string | null;
|
);
|
||||||
}>({ container: null, id: null });
|
|
||||||
export const useExcalidrawContainer = () =>
|
export const useExcalidrawContainer = () =>
|
||||||
useContext(ExcalidrawContainerContext);
|
useContext(ExcalidrawContainerContext);
|
||||||
|
|
||||||
@@ -225,6 +222,83 @@ const gesture: Gesture = {
|
|||||||
initialScale: null,
|
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> {
|
class App extends React.Component<AppProps, AppState> {
|
||||||
canvas: HTMLCanvasElement | null = null;
|
canvas: HTMLCanvasElement | null = null;
|
||||||
rc: RoughCanvas | null = null;
|
rc: RoughCanvas | null = null;
|
||||||
@@ -247,10 +321,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
public libraryItemsFromStorage: LibraryItems | undefined;
|
public libraryItemsFromStorage: LibraryItems | undefined;
|
||||||
private id: string;
|
private id: string;
|
||||||
private history: History;
|
private history: History;
|
||||||
private excalidrawContainerValue: {
|
|
||||||
container: HTMLDivElement | null;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props: AppProps) {
|
constructor(props: AppProps) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -307,12 +377,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
readyPromise.resolve(api);
|
readyPromise.resolve(api);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.excalidrawContainerValue = {
|
|
||||||
container: this.excalidrawContainerRef.current,
|
|
||||||
id: this.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.scene = new Scene();
|
this.scene = new Scene();
|
||||||
this.library = new Library(this);
|
this.library = new Library(this);
|
||||||
this.history = new History();
|
this.history = new History();
|
||||||
@@ -340,11 +404,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (viewModeEnabled) {
|
if (viewModeEnabled) {
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
className="excalidraw__canvas"
|
id="canvas"
|
||||||
style={{
|
style={{
|
||||||
width: canvasDOMWidth,
|
width: canvasDOMWidth,
|
||||||
height: canvasDOMHeight,
|
height: canvasDOMHeight,
|
||||||
cursor: CURSOR_TYPE.GRAB,
|
cursor: "grabbing",
|
||||||
}}
|
}}
|
||||||
width={canvasWidth}
|
width={canvasWidth}
|
||||||
height={canvasHeight}
|
height={canvasHeight}
|
||||||
@@ -362,7 +426,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
className="excalidraw__canvas"
|
id="canvas"
|
||||||
style={{
|
style={{
|
||||||
width: canvasDOMWidth,
|
width: canvasDOMWidth,
|
||||||
height: canvasDOMHeight,
|
height: canvasDOMHeight,
|
||||||
@@ -388,6 +452,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
onCollabButtonClick,
|
onCollabButtonClick,
|
||||||
|
onExportToBackend,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderFooter,
|
renderFooter,
|
||||||
renderCustomStats,
|
renderCustomStats,
|
||||||
@@ -407,7 +472,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ExcalidrawContainerContext.Provider
|
<ExcalidrawContainerContext.Provider
|
||||||
value={this.excalidrawContainerValue}
|
value={this.excalidrawContainerRef.current}
|
||||||
>
|
>
|
||||||
<IsMobileContext.Provider value={this.isMobile}>
|
<IsMobileContext.Provider value={this.isMobile}>
|
||||||
<LayerUI
|
<LayerUI
|
||||||
@@ -428,6 +493,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
toggleZenMode={this.toggleZenMode}
|
toggleZenMode={this.toggleZenMode}
|
||||||
langCode={getLanguage().code}
|
langCode={getLanguage().code}
|
||||||
isCollaborating={this.props.isCollaborating || false}
|
isCollaborating={this.props.isCollaborating || false}
|
||||||
|
onExportToBackend={onExportToBackend}
|
||||||
renderTopRightUI={renderTopRightUI}
|
renderTopRightUI={renderTopRightUI}
|
||||||
renderCustomFooter={renderFooter}
|
renderCustomFooter={renderFooter}
|
||||||
viewModeEnabled={viewModeEnabled}
|
viewModeEnabled={viewModeEnabled}
|
||||||
@@ -470,9 +536,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public focusContainer = () => {
|
public focusContainer = () => {
|
||||||
if (this.props.autoFocus) {
|
this.excalidrawContainerRef.current?.focus();
|
||||||
this.excalidrawContainerRef.current?.focus();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public getSceneElementsIncludingDeleted = () => {
|
public getSceneElementsIncludingDeleted = () => {
|
||||||
@@ -514,7 +578,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
|
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
|
||||||
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
|
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
|
||||||
let gridSize = actionResult?.appState?.gridSize || null;
|
let gridSize = actionResult?.appState?.gridSize || null;
|
||||||
let theme = actionResult?.appState?.theme || THEME.LIGHT;
|
let theme = actionResult?.appState?.theme || "light";
|
||||||
let name = actionResult?.appState?.name ?? this.state.name;
|
let name = actionResult?.appState?.name ?? this.state.name;
|
||||||
if (typeof this.props.viewModeEnabled !== "undefined") {
|
if (typeof this.props.viewModeEnabled !== "undefined") {
|
||||||
viewModeEnabled = this.props.viewModeEnabled;
|
viewModeEnabled = this.props.viewModeEnabled;
|
||||||
@@ -656,11 +720,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
const fileHandle = launchParams.files[0];
|
const fileHandle = launchParams.files[0];
|
||||||
const blob: Blob = await fileHandle.getFile();
|
const blob: Blob = await fileHandle.getFile();
|
||||||
blob.handle = fileHandle;
|
blob.handle = fileHandle;
|
||||||
loadFromBlob(
|
loadFromBlob(blob, this.state)
|
||||||
blob,
|
|
||||||
this.state,
|
|
||||||
this.scene.getElementsIncludingDeleted(),
|
|
||||||
)
|
|
||||||
.then(({ elements, appState }) =>
|
.then(({ elements, appState }) =>
|
||||||
this.syncActionResult({
|
this.syncActionResult({
|
||||||
elements,
|
elements,
|
||||||
@@ -687,7 +747,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (initialData?.libraryItems) {
|
if (initialData?.libraryItems) {
|
||||||
this.libraryItemsFromStorage = initialData.libraryItems;
|
this.libraryItemsFromStorage = initialData.libraryItems;
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
initialData = {
|
initialData = {
|
||||||
appState: {
|
appState: {
|
||||||
@@ -698,7 +758,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const scene = restore(initialData, null, null);
|
const scene = restore(initialData, null);
|
||||||
scene.appState = {
|
scene.appState = {
|
||||||
...scene.appState,
|
...scene.appState,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -742,8 +802,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
public async componentDidMount() {
|
public async componentDidMount() {
|
||||||
this.excalidrawContainerValue.container = this.excalidrawContainerRef.current;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
process.env.NODE_ENV === ENV.TEST ||
|
process.env.NODE_ENV === ENV.TEST ||
|
||||||
process.env.NODE_ENV === ENV.DEVELOPMENT
|
process.env.NODE_ENV === ENV.DEVELOPMENT
|
||||||
@@ -832,7 +890,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
private removeEventListeners() {
|
private removeEventListeners() {
|
||||||
document.removeEventListener(EVENT.POINTER_UP, this.removePointer);
|
|
||||||
document.removeEventListener(EVENT.COPY, this.onCopy);
|
document.removeEventListener(EVENT.COPY, this.onCopy);
|
||||||
document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
|
document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
|
||||||
document.removeEventListener(EVENT.CUT, this.onCut);
|
document.removeEventListener(EVENT.CUT, this.onCut);
|
||||||
@@ -874,7 +931,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
private addEventListeners() {
|
private addEventListeners() {
|
||||||
this.removeEventListeners();
|
this.removeEventListeners();
|
||||||
document.addEventListener(EVENT.POINTER_UP, this.removePointer); // #3553
|
|
||||||
document.addEventListener(EVENT.COPY, this.onCopy);
|
document.addEventListener(EVENT.COPY, this.onCopy);
|
||||||
if (this.props.handleKeyboardGlobally) {
|
if (this.props.handleKeyboardGlobally) {
|
||||||
document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
|
document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
|
||||||
@@ -930,12 +986,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
|
if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
|
||||||
this.setState({ viewModeEnabled: !!this.props.viewModeEnabled });
|
this.setState(
|
||||||
|
{ viewModeEnabled: !!this.props.viewModeEnabled },
|
||||||
|
this.addEventListeners,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevState.viewModeEnabled !== this.state.viewModeEnabled) {
|
if (prevState.viewModeEnabled !== this.state.viewModeEnabled) {
|
||||||
this.addEventListeners();
|
this.addEventListeners();
|
||||||
this.deselectElements();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevProps.zenModeEnabled !== this.props.zenModeEnabled) {
|
if (prevProps.zenModeEnabled !== this.props.zenModeEnabled) {
|
||||||
@@ -1194,12 +1252,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
const data = await parseClipboard(event);
|
const data = await parseClipboard(event);
|
||||||
if (this.props.onPaste) {
|
if (this.props.onPaste) {
|
||||||
try {
|
if (await this.props.onPaste(data, event)) {
|
||||||
if ((await this.props.onPaste(data, event)) === false) {
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data.errorMessage) {
|
if (data.errorMessage) {
|
||||||
@@ -1213,7 +1267,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
} else if (data.elements) {
|
} else if (data.elements) {
|
||||||
this.addElementsFromPasteOrLibrary({
|
this.addElementsFromPasteOrLibrary({
|
||||||
elements: data.elements,
|
elements: restoreElements(data.elements),
|
||||||
position: "cursor",
|
position: "cursor",
|
||||||
});
|
});
|
||||||
} else if (data.text) {
|
} else if (data.text) {
|
||||||
@@ -1228,7 +1282,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
position: { clientX: number; clientY: number } | "cursor" | "center";
|
position: { clientX: number; clientY: number } | "cursor" | "center";
|
||||||
}) => {
|
}) => {
|
||||||
const elements = restoreElements(opts.elements, null);
|
const elements = restoreElements(opts.elements);
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
|
|
||||||
const elementsCenterX = distance(minX, maxX) / 2;
|
const elementsCenterX = distance(minX, maxX) / 2;
|
||||||
@@ -1294,7 +1348,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.getElements(),
|
this.scene.getElements(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
this.selectShapeTool("selection");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private addTextFromPaste(text: any) {
|
private addTextFromPaste(text: any) {
|
||||||
@@ -1335,7 +1388,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState(obj);
|
this.setState(obj);
|
||||||
};
|
};
|
||||||
|
|
||||||
removePointer = (event: React.PointerEvent<HTMLElement> | PointerEvent) => {
|
removePointer = (event: React.PointerEvent<HTMLElement>) => {
|
||||||
// remove touch handler for context menu on touch devices
|
// remove touch handler for context menu on touch devices
|
||||||
if (event.pointerType === "touch" && touchTimeout) {
|
if (event.pointerType === "touch" && touchTimeout) {
|
||||||
clearTimeout(touchTimeout);
|
clearTimeout(touchTimeout);
|
||||||
@@ -1401,7 +1454,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
await webShareTargetCache.delete("shared-file");
|
await webShareTargetCache.delete("shared-file");
|
||||||
window.history.replaceState(null, APP_NAME, window.location.pathname);
|
window.history.replaceState(null, APP_NAME, window.location.pathname);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
this.setState({ errorMessage: error.message });
|
this.setState({ errorMessage: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1592,38 +1645,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
isHoldingSpace = true;
|
isHoldingSpace = true;
|
||||||
setCursor(this.canvas, CURSOR_TYPE.GRABBING);
|
setCursor(this.canvas, CURSOR_TYPE.GRABBING);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === KEYS.G || event.key === KEYS.S) {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
this.scene.getElements(),
|
|
||||||
this.state,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
this.state.elementType === "selection" &&
|
|
||||||
!selectedElements.length
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.key === KEYS.G &&
|
|
||||||
(hasBackground(this.state.elementType) ||
|
|
||||||
selectedElements.some((element) => hasBackground(element.type)))
|
|
||||||
) {
|
|
||||||
this.setState({ openPopup: "backgroundColorPicker" });
|
|
||||||
}
|
|
||||||
if (event.key === KEYS.S) {
|
|
||||||
this.setState({ openPopup: "strokeColorPicker" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
|
private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
|
||||||
if (event.key === KEYS.SPACE) {
|
if (event.key === KEYS.SPACE) {
|
||||||
if (this.state.viewModeEnabled) {
|
if (this.state.elementType === "selection") {
|
||||||
setCursor(this.canvas, CURSOR_TYPE.GRAB);
|
|
||||||
} else if (this.state.elementType === "selection") {
|
|
||||||
resetCursor(this.canvas);
|
resetCursor(this.canvas);
|
||||||
} else {
|
} else {
|
||||||
setCursorForShape(this.canvas, this.state.elementType);
|
setCursorForShape(this.canvas, this.state.elementType);
|
||||||
@@ -1771,8 +1798,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
[element.id]: true,
|
[element.id]: true,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
} else {
|
||||||
if (isDeleted) {
|
|
||||||
fixBindingsAfterDeletion(this.scene.getElements(), [element]);
|
fixBindingsAfterDeletion(this.scene.getElements(), [element]);
|
||||||
}
|
}
|
||||||
if (!isDeleted || isExistingElement) {
|
if (!isDeleted || isExistingElement) {
|
||||||
@@ -1793,19 +1819,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
excalidrawContainer: this.excalidrawContainerRef.current,
|
excalidrawContainer: this.excalidrawContainerRef.current,
|
||||||
});
|
});
|
||||||
// deselect all other elements when inserting text
|
// 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({
|
this.setState({
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
editingGroupId: null,
|
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(
|
private getTextElementAtPosition(
|
||||||
@@ -1823,21 +1845,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
private getElementAtPosition(
|
private getElementAtPosition(
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
opts?: {
|
|
||||||
/** if true, returns the first selected element (with highest z-index)
|
|
||||||
of all hit elements */
|
|
||||||
preferSelected?: boolean;
|
|
||||||
},
|
|
||||||
): NonDeleted<ExcalidrawElement> | null {
|
): NonDeleted<ExcalidrawElement> | null {
|
||||||
const allHitElements = this.getElementsAtPosition(x, y);
|
const allHitElements = this.getElementsAtPosition(x, y);
|
||||||
if (allHitElements.length > 1) {
|
if (allHitElements.length > 1) {
|
||||||
if (opts?.preferSelected) {
|
|
||||||
for (let index = allHitElements.length - 1; index > -1; index--) {
|
|
||||||
if (this.state.selectedElementIds[allHitElements[index].id]) {
|
|
||||||
return allHitElements[index];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const elementWithHighestZIndex =
|
const elementWithHighestZIndex =
|
||||||
allHitElements[allHitElements.length - 1];
|
allHitElements[allHitElements.length - 1];
|
||||||
// If we're hitting element with highest z-index only on its bounding box
|
// If we're hitting element with highest z-index only on its bounding box
|
||||||
@@ -2248,8 +2258,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.canvas,
|
this.canvas,
|
||||||
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
|
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
|
||||||
);
|
);
|
||||||
} else if (this.state.viewModeEnabled) {
|
|
||||||
setCursor(this.canvas, CURSOR_TYPE.GRAB);
|
|
||||||
} else if (isOverScrollBar) {
|
} else if (isOverScrollBar) {
|
||||||
setCursor(this.canvas, CURSOR_TYPE.AUTO);
|
setCursor(this.canvas, CURSOR_TYPE.AUTO);
|
||||||
} else if (
|
} else if (
|
||||||
@@ -2272,113 +2280,119 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
invalidateContextMenu = true;
|
invalidateContextMenu = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleCanvasPointerDown = (
|
private handleCanvasPointerDown = withBatchedUpdates(
|
||||||
event: React.PointerEvent<HTMLCanvasElement>,
|
(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)
|
|
||||||
const selection = document.getSelection();
|
|
||||||
if (selection?.anchorNode) {
|
|
||||||
selection.removeAllRanges();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event);
|
if (this.state.openMenu === "canvas") {
|
||||||
this.maybeCleanupAfterMissingPointerUp(event);
|
this.setState({ openMenu: null });
|
||||||
|
}
|
||||||
|
|
||||||
if (isPanning) {
|
// remove any active selection when we start to interact with canvas
|
||||||
return;
|
// (mainly, we care about removing selection outside the component which
|
||||||
}
|
// would prevent our copy handling otherwise)
|
||||||
|
const selection = document.getSelection();
|
||||||
|
if (selection?.anchorNode) {
|
||||||
|
selection.removeAllRanges();
|
||||||
|
}
|
||||||
|
|
||||||
this.setState({
|
this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event);
|
||||||
lastPointerDownWith: event.pointerType,
|
this.maybeCleanupAfterMissingPointerUp(event);
|
||||||
cursorButton: "down",
|
|
||||||
});
|
|
||||||
this.savePointer(event.clientX, event.clientY, "down");
|
|
||||||
|
|
||||||
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
|
if (isPanning) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// only handle left mouse button or touch
|
this.setState({
|
||||||
if (
|
lastPointerDownWith: event.pointerType,
|
||||||
event.button !== POINTER_BUTTON.MAIN &&
|
cursorButton: "down",
|
||||||
event.button !== POINTER_BUTTON.TOUCH
|
});
|
||||||
) {
|
this.savePointer(event.clientX, event.clientY, "down");
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateGestureOnPointerDown(event);
|
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// don't select while panning
|
// only handle left mouse button or touch
|
||||||
if (gesture.pointers.size > 1) {
|
if (
|
||||||
return;
|
event.button !== POINTER_BUTTON.MAIN &&
|
||||||
}
|
event.button !== POINTER_BUTTON.TOUCH
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// State for the duration of a pointer interaction, which starts with a
|
this.updateGestureOnPointerDown(event);
|
||||||
// pointerDown event, ends with a pointerUp event (or another pointerDown)
|
|
||||||
const pointerDownState = this.initialPointerDownState(event);
|
|
||||||
|
|
||||||
if (this.handleDraggingScrollBar(event, pointerDownState)) {
|
// don't select while panning
|
||||||
return;
|
if (gesture.pointers.size > 1) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.clearSelectionIfNotUsingSelection();
|
// State for the duration of a pointer interaction, which starts with a
|
||||||
this.updateBindingEnabledOnPointerMove(event);
|
// pointerDown event, ends with a pointerUp event (or another pointerDown)
|
||||||
|
const pointerDownState = this.initialPointerDownState(event);
|
||||||
|
|
||||||
if (this.handleSelectionOnPointerDown(event, pointerDownState)) {
|
if (this.handleDraggingScrollBar(event, pointerDownState)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.elementType === "text") {
|
this.clearSelectionIfNotUsingSelection();
|
||||||
this.handleTextOnPointerDown(event, pointerDownState);
|
this.updateBindingEnabledOnPointerMove(event);
|
||||||
return;
|
|
||||||
} else if (
|
if (this.handleSelectionOnPointerDown(event, pointerDownState)) {
|
||||||
this.state.elementType === "arrow" ||
|
return;
|
||||||
this.state.elementType === "line"
|
}
|
||||||
) {
|
|
||||||
this.handleLinearElementOnPointerDown(
|
if (this.state.elementType === "text") {
|
||||||
event,
|
this.handleTextOnPointerDown(event, pointerDownState);
|
||||||
this.state.elementType,
|
return;
|
||||||
|
} else if (
|
||||||
|
this.state.elementType === "arrow" ||
|
||||||
|
this.state.elementType === "line"
|
||||||
|
) {
|
||||||
|
this.handleLinearElementOnPointerDown(
|
||||||
|
event,
|
||||||
|
this.state.elementType,
|
||||||
|
pointerDownState,
|
||||||
|
);
|
||||||
|
} else if (this.state.elementType === "freedraw") {
|
||||||
|
this.handleFreeDrawElementOnPointerDown(
|
||||||
|
event,
|
||||||
|
this.state.elementType,
|
||||||
|
pointerDownState,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.createGenericElementOnPointerDown(
|
||||||
|
this.state.elementType,
|
||||||
|
pointerDownState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerMove = this.onPointerMoveFromPointerDownHandler(
|
||||||
pointerDownState,
|
pointerDownState,
|
||||||
);
|
);
|
||||||
} else if (this.state.elementType === "freedraw") {
|
|
||||||
this.handleFreeDrawElementOnPointerDown(
|
const onPointerUp = this.onPointerUpFromPointerDownHandler(
|
||||||
event,
|
|
||||||
this.state.elementType,
|
|
||||||
pointerDownState,
|
pointerDownState,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
this.createGenericElementOnPointerDown(
|
|
||||||
this.state.elementType,
|
|
||||||
pointerDownState,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPointerMove = this.onPointerMoveFromPointerDownHandler(
|
const onKeyDown = this.onKeyDownFromPointerDownHandler(pointerDownState);
|
||||||
pointerDownState,
|
const onKeyUp = this.onKeyUpFromPointerDownHandler(pointerDownState);
|
||||||
);
|
|
||||||
|
|
||||||
const onPointerUp = this.onPointerUpFromPointerDownHandler(
|
lastPointerUp = onPointerUp;
|
||||||
pointerDownState,
|
|
||||||
);
|
|
||||||
|
|
||||||
const onKeyDown = this.onKeyDownFromPointerDownHandler(pointerDownState);
|
if (!this.state.viewModeEnabled) {
|
||||||
const onKeyUp = this.onKeyUpFromPointerDownHandler(pointerDownState);
|
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
||||||
|
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
|
||||||
lastPointerUp = onPointerUp;
|
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
|
||||||
|
window.addEventListener(EVENT.KEYUP, onKeyUp);
|
||||||
if (!this.state.viewModeEnabled) {
|
pointerDownState.eventListeners.onMove = onPointerMove;
|
||||||
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
pointerDownState.eventListeners.onUp = onPointerUp;
|
||||||
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
|
pointerDownState.eventListeners.onKeyUp = onKeyUp;
|
||||||
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
|
pointerDownState.eventListeners.onKeyDown = onKeyDown;
|
||||||
window.addEventListener(EVENT.KEYUP, onKeyUp);
|
}
|
||||||
pointerDownState.eventListeners.onMove = onPointerMove;
|
},
|
||||||
pointerDownState.eventListeners.onUp = onPointerUp;
|
);
|
||||||
pointerDownState.eventListeners.onKeyUp = onKeyUp;
|
|
||||||
pointerDownState.eventListeners.onKeyDown = onKeyDown;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private maybeOpenContextMenuAfterPointerDownOnTouchDevices = (
|
private maybeOpenContextMenuAfterPointerDownOnTouchDevices = (
|
||||||
event: React.PointerEvent<HTMLCanvasElement>,
|
event: React.PointerEvent<HTMLCanvasElement>,
|
||||||
@@ -2487,11 +2501,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
lastPointerUp = null;
|
lastPointerUp = null;
|
||||||
isPanning = false;
|
isPanning = false;
|
||||||
if (!isHoldingSpace) {
|
if (!isHoldingSpace) {
|
||||||
if (this.state.viewModeEnabled) {
|
setCursorForShape(this.canvas, this.state.elementType);
|
||||||
setCursor(this.canvas, CURSOR_TYPE.GRAB);
|
|
||||||
} else {
|
|
||||||
setCursorForShape(this.canvas, this.state.elementType);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.setState({
|
this.setState({
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
@@ -3495,7 +3505,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
mutateElement(draggingElement, {
|
mutateElement(draggingElement, {
|
||||||
points: [...points, [dx, dy]],
|
points: [...points, [dx, dy]],
|
||||||
pressures,
|
pressures,
|
||||||
lastCommittedPoint: [dx, dy],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.actionManager.executeAction(actionFinalize);
|
this.actionManager.executeAction(actionFinalize);
|
||||||
@@ -3835,22 +3844,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
try {
|
try {
|
||||||
const file = event.dataTransfer.files[0];
|
const file = event.dataTransfer.files[0];
|
||||||
if (file?.type === "image/png" || file?.type === "image/svg+xml") {
|
if (file?.type === "image/png" || file?.type === "image/svg+xml") {
|
||||||
if (nativeFileSystemSupported) {
|
const { elements, appState } = await loadFromBlob(file, this.state);
|
||||||
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({
|
this.syncActionResult({
|
||||||
elements,
|
elements,
|
||||||
appState: {
|
appState: {
|
||||||
@@ -3861,7 +3855,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
return this.setState({
|
return this.setState({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
@@ -3895,13 +3889,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
// default: assume an Excalidraw file regardless of extension/MimeType
|
// default: assume an Excalidraw file regardless of extension/MimeType
|
||||||
} else {
|
} else {
|
||||||
this.setState({ isLoading: true });
|
this.setState({ isLoading: true });
|
||||||
if (nativeFileSystemSupported) {
|
if (fsSupported) {
|
||||||
try {
|
try {
|
||||||
// This will only work as of Chrome 86,
|
// This will only work as of Chrome 86,
|
||||||
// but can be safely ignored on older releases.
|
// but can be safely ignored on older releases.
|
||||||
const item = event.dataTransfer.items[0];
|
const item = event.dataTransfer.items[0];
|
||||||
(file as any).handle = await (item as any).getAsFileSystemHandle();
|
(file as any).handle = await (item as any).getAsFileSystemHandle();
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.warn(error.name, error.message);
|
console.warn(error.name, error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3910,7 +3904,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadFileToCanvas = (file: Blob) => {
|
loadFileToCanvas = (file: Blob) => {
|
||||||
loadFromBlob(file, this.state, this.scene.getElementsIncludingDeleted())
|
loadFromBlob(file, this.state)
|
||||||
.then(({ elements, appState }) =>
|
.then(({ elements, appState }) =>
|
||||||
this.syncActionResult({
|
this.syncActionResult({
|
||||||
elements,
|
elements,
|
||||||
@@ -3932,7 +3926,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const { x, y } = viewportCoordsToSceneCoords(event, this.state);
|
const { x, y } = viewportCoordsToSceneCoords(event, this.state);
|
||||||
const element = this.getElementAtPosition(x, y, { preferSelected: true });
|
const element = this.getElementAtPosition(x, y);
|
||||||
|
|
||||||
const type = element ? "element" : "canvas";
|
const type = element ? "element" : "canvas";
|
||||||
|
|
||||||
@@ -4094,112 +4088,116 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
actionToggleStats,
|
actionToggleStats,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
ContextMenu.push({
|
||||||
|
options: viewModeOptions,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
actionManager: this.actionManager,
|
||||||
|
appState: this.state,
|
||||||
|
container: this.excalidrawContainerRef.current!,
|
||||||
|
});
|
||||||
|
|
||||||
if (this.state.viewModeEnabled) {
|
if (this.state.viewModeEnabled) {
|
||||||
ContextMenu.push({
|
return;
|
||||||
options: viewModeOptions,
|
|
||||||
top,
|
|
||||||
left,
|
|
||||||
actionManager: this.actionManager,
|
|
||||||
appState: this.state,
|
|
||||||
container: this.excalidrawContainerRef.current!,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ContextMenu.push({
|
|
||||||
options: [
|
|
||||||
this.isMobile &&
|
|
||||||
navigator.clipboard && {
|
|
||||||
name: "paste",
|
|
||||||
perform: (elements, appStates) => {
|
|
||||||
this.pasteFromClipboard(null);
|
|
||||||
return {
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
contextItemLabel: "labels.paste",
|
|
||||||
},
|
|
||||||
this.isMobile && navigator.clipboard && separator,
|
|
||||||
probablySupportsClipboardBlob &&
|
|
||||||
elements.length > 0 &&
|
|
||||||
actionCopyAsPng,
|
|
||||||
probablySupportsClipboardWriteText &&
|
|
||||||
elements.length > 0 &&
|
|
||||||
actionCopyAsSvg,
|
|
||||||
((probablySupportsClipboardBlob && elements.length > 0) ||
|
|
||||||
(probablySupportsClipboardWriteText && elements.length > 0)) &&
|
|
||||||
separator,
|
|
||||||
actionSelectAll,
|
|
||||||
separator,
|
|
||||||
typeof this.props.gridModeEnabled === "undefined" &&
|
|
||||||
actionToggleGridMode,
|
|
||||||
typeof this.props.zenModeEnabled === "undefined" &&
|
|
||||||
actionToggleZenMode,
|
|
||||||
typeof this.props.viewModeEnabled === "undefined" &&
|
|
||||||
actionToggleViewMode,
|
|
||||||
actionToggleStats,
|
|
||||||
],
|
|
||||||
top,
|
|
||||||
left,
|
|
||||||
actionManager: this.actionManager,
|
|
||||||
appState: this.state,
|
|
||||||
container: this.excalidrawContainerRef.current!,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else if (type === "element") {
|
|
||||||
if (this.state.viewModeEnabled) {
|
ContextMenu.push({
|
||||||
ContextMenu.push({
|
options: [
|
||||||
options: [navigator.clipboard && actionCopy, ...options],
|
this.isMobile &&
|
||||||
top,
|
navigator.clipboard && {
|
||||||
left,
|
name: "paste",
|
||||||
actionManager: this.actionManager,
|
perform: (elements, appStates) => {
|
||||||
appState: this.state,
|
this.pasteFromClipboard(null);
|
||||||
container: this.excalidrawContainerRef.current!,
|
return {
|
||||||
});
|
commitToHistory: false,
|
||||||
} else {
|
};
|
||||||
ContextMenu.push({
|
|
||||||
options: [
|
|
||||||
this.isMobile && actionCut,
|
|
||||||
this.isMobile && navigator.clipboard && actionCopy,
|
|
||||||
this.isMobile &&
|
|
||||||
navigator.clipboard && {
|
|
||||||
name: "paste",
|
|
||||||
perform: (elements, appStates) => {
|
|
||||||
this.pasteFromClipboard(null);
|
|
||||||
return {
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
contextItemLabel: "labels.paste",
|
|
||||||
},
|
},
|
||||||
this.isMobile && separator,
|
contextItemLabel: "labels.paste",
|
||||||
...options,
|
},
|
||||||
|
this.isMobile && navigator.clipboard && separator,
|
||||||
|
probablySupportsClipboardBlob &&
|
||||||
|
elements.length > 0 &&
|
||||||
|
actionCopyAsPng,
|
||||||
|
probablySupportsClipboardWriteText &&
|
||||||
|
elements.length > 0 &&
|
||||||
|
actionCopyAsSvg,
|
||||||
|
((probablySupportsClipboardBlob && elements.length > 0) ||
|
||||||
|
(probablySupportsClipboardWriteText && elements.length > 0)) &&
|
||||||
separator,
|
separator,
|
||||||
actionCopyStyles,
|
actionSelectAll,
|
||||||
actionPasteStyles,
|
separator,
|
||||||
separator,
|
typeof this.props.gridModeEnabled === "undefined" &&
|
||||||
maybeGroupAction && actionGroup,
|
actionToggleGridMode,
|
||||||
maybeUngroupAction && actionUngroup,
|
typeof this.props.zenModeEnabled === "undefined" &&
|
||||||
(maybeGroupAction || maybeUngroupAction) && separator,
|
actionToggleZenMode,
|
||||||
actionAddToLibrary,
|
typeof this.props.viewModeEnabled === "undefined" &&
|
||||||
separator,
|
actionToggleViewMode,
|
||||||
actionSendBackward,
|
actionToggleStats,
|
||||||
actionBringForward,
|
],
|
||||||
actionSendToBack,
|
top,
|
||||||
actionBringToFront,
|
left,
|
||||||
separator,
|
actionManager: this.actionManager,
|
||||||
maybeFlipHorizontal && actionFlipHorizontal,
|
appState: this.state,
|
||||||
maybeFlipVertical && actionFlipVertical,
|
container: this.excalidrawContainerRef.current!,
|
||||||
(maybeFlipHorizontal || maybeFlipVertical) && separator,
|
});
|
||||||
actionDuplicateSelection,
|
return;
|
||||||
actionDeleteSelected,
|
|
||||||
],
|
|
||||||
top,
|
|
||||||
left,
|
|
||||||
actionManager: this.actionManager,
|
|
||||||
appState: this.state,
|
|
||||||
container: this.excalidrawContainerRef.current!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.state.viewModeEnabled) {
|
||||||
|
ContextMenu.push({
|
||||||
|
options: [navigator.clipboard && actionCopy, ...options],
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
actionManager: this.actionManager,
|
||||||
|
appState: this.state,
|
||||||
|
container: this.excalidrawContainerRef.current!,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ContextMenu.push({
|
||||||
|
options: [
|
||||||
|
this.isMobile && actionCut,
|
||||||
|
this.isMobile && navigator.clipboard && actionCopy,
|
||||||
|
this.isMobile &&
|
||||||
|
navigator.clipboard && {
|
||||||
|
name: "paste",
|
||||||
|
perform: (elements, appStates) => {
|
||||||
|
this.pasteFromClipboard(null);
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
contextItemLabel: "labels.paste",
|
||||||
|
},
|
||||||
|
this.isMobile && separator,
|
||||||
|
...options,
|
||||||
|
separator,
|
||||||
|
actionCopyStyles,
|
||||||
|
actionPasteStyles,
|
||||||
|
separator,
|
||||||
|
maybeGroupAction && actionGroup,
|
||||||
|
maybeUngroupAction && actionUngroup,
|
||||||
|
(maybeGroupAction || maybeUngroupAction) && separator,
|
||||||
|
actionAddToLibrary,
|
||||||
|
separator,
|
||||||
|
actionSendBackward,
|
||||||
|
actionBringForward,
|
||||||
|
actionSendToBack,
|
||||||
|
actionBringToFront,
|
||||||
|
separator,
|
||||||
|
maybeFlipHorizontal && actionFlipHorizontal,
|
||||||
|
maybeFlipVertical && actionFlipVertical,
|
||||||
|
(maybeFlipHorizontal || maybeFlipVertical) && separator,
|
||||||
|
actionDuplicateSelection,
|
||||||
|
actionDeleteSelected,
|
||||||
|
],
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
actionManager: this.actionManager,
|
||||||
|
appState: this.state,
|
||||||
|
container: this.excalidrawContainerRef.current!,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleWheel = withBatchedUpdates((event: WheelEvent) => {
|
private handleWheel = withBatchedUpdates((event: WheelEvent) => {
|
||||||
|
|||||||
@@ -16,5 +16,10 @@ export const BackgroundPickerAndDarkModeToggle = ({
|
|||||||
<div style={{ display: "flex" }}>
|
<div style={{ display: "flex" }}>
|
||||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||||
{showThemeBtn && actionManager.renderAction("toggleTheme")}
|
{showThemeBtn && actionManager.renderAction("toggleTheme")}
|
||||||
|
{appState.fileHandle && (
|
||||||
|
<div style={{ marginInlineStart: "0.25rem" }}>
|
||||||
|
{actionManager.renderAction("saveScene")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const ButtonSelect = <T extends Object>({
|
export const ButtonSelect = <T extends Object>({
|
||||||
|
|||||||
@@ -2,20 +2,16 @@
|
|||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Checkbox {
|
.Checkbox {
|
||||||
margin: 4px 0.3em;
|
margin: 3px 0.3em;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
&:hover:not(.is-checked) .Checkbox-box {
|
||||||
|
|
||||||
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
|
|
||||||
box-shadow: 0 0 0 2px #{$oc-blue-4};
|
box-shadow: 0 0 0 2px #{$oc-blue-4};
|
||||||
}
|
|
||||||
|
|
||||||
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
|
|
||||||
svg {
|
svg {
|
||||||
display: block;
|
display: block;
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
@@ -81,7 +77,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.excalidraw-tooltip-icon {
|
.Tooltip-icon {
|
||||||
width: 1em;
|
width: 1em;
|
||||||
height: 1em;
|
height: 1em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { checkIcon } from "./icons";
|
import { checkIcon } from "./icons";
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
import { isTransparent } from "../utils";
|
|
||||||
|
|
||||||
import "./ColorPicker.scss";
|
import "./ColorPicker.scss";
|
||||||
import { isArrowKey, KEYS } from "../keys";
|
import { isArrowKey, KEYS } from "../keys";
|
||||||
@@ -15,7 +14,7 @@ const isValidColor = (color: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getColor = (color: string): string | null => {
|
const getColor = (color: string): string | null => {
|
||||||
if (isTransparent(color)) {
|
if (color === "transparent") {
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,41 +137,36 @@ const Picker = ({
|
|||||||
}}
|
}}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{colors.map((_color, i) => {
|
{colors.map((_color, i) => (
|
||||||
const _colorWithoutHash = _color.replace("#", "");
|
<button
|
||||||
return (
|
className="color-picker-swatch"
|
||||||
<button
|
onClick={(event) => {
|
||||||
className="color-picker-swatch"
|
(event.currentTarget as HTMLButtonElement).focus();
|
||||||
onClick={(event) => {
|
onChange(_color);
|
||||||
(event.currentTarget as HTMLButtonElement).focus();
|
}}
|
||||||
onChange(_color);
|
title={`${_color} — ${keyBindings[i].toUpperCase()}`}
|
||||||
}}
|
aria-label={_color}
|
||||||
title={`${t(`colors.${_colorWithoutHash}`)}${
|
aria-keyshortcuts={keyBindings[i]}
|
||||||
!isTransparent(_color) ? ` (${_color})` : ""
|
style={{ color: _color }}
|
||||||
} — ${keyBindings[i].toUpperCase()}`}
|
key={_color}
|
||||||
aria-label={t(`colors.${_colorWithoutHash}`)}
|
ref={(el) => {
|
||||||
aria-keyshortcuts={keyBindings[i]}
|
if (el && i === 0) {
|
||||||
style={{ color: _color }}
|
firstItem.current = el;
|
||||||
key={_color}
|
}
|
||||||
ref={(el) => {
|
if (el && _color === color) {
|
||||||
if (el && i === 0) {
|
activeItem.current = el;
|
||||||
firstItem.current = el;
|
}
|
||||||
}
|
}}
|
||||||
if (el && _color === color) {
|
onFocus={() => {
|
||||||
activeItem.current = el;
|
onChange(_color);
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
onFocus={() => {
|
{_color === "transparent" ? (
|
||||||
onChange(_color);
|
<div className="color-picker-transparent"></div>
|
||||||
}}
|
) : undefined}
|
||||||
>
|
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
||||||
{isTransparent(_color) ? (
|
</button>
|
||||||
<div className="color-picker-transparent"></div>
|
))}
|
||||||
) : undefined}
|
|
||||||
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{showInput && (
|
{showInput && (
|
||||||
<ColorInput
|
<ColorInput
|
||||||
color={color}
|
color={color}
|
||||||
@@ -244,16 +238,13 @@ export const ColorPicker = ({
|
|||||||
color,
|
color,
|
||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
isActive,
|
|
||||||
setActive,
|
|
||||||
}: {
|
}: {
|
||||||
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
||||||
color: string | null;
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
isActive: boolean;
|
|
||||||
setActive: (active: boolean) => void;
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isActive, setActive] = React.useState(false);
|
||||||
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { render, unmountComponentAtNode } from "react-dom";
|
import { render, unmountComponentAtNode } from "react-dom";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import "./ToolIcon.scss";
|
import "./ToolIcon.scss";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { THEME } from "../constants";
|
|
||||||
import { Theme } from "../element/types";
|
export type Appearence = "light" | "dark";
|
||||||
|
|
||||||
// We chose to use only explicit toggle and not a third option for system value,
|
// We chose to use only explicit toggle and not a third option for system value,
|
||||||
// but this could be added in the future.
|
// but this could be added in the future.
|
||||||
export const DarkModeToggle = (props: {
|
export const DarkModeToggle = (props: {
|
||||||
value: Theme;
|
value: Appearence;
|
||||||
onChange: (value: Theme) => void;
|
onChange: (value: Appearence) => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const title =
|
const title =
|
||||||
@@ -19,12 +20,10 @@ export const DarkModeToggle = (props: {
|
|||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="icon"
|
type="icon"
|
||||||
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
|
icon={props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
||||||
title={title}
|
title={title}
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
onClick={() =>
|
onClick={() => props.onChange(props.value === "dark" ? "light" : "dark")}
|
||||||
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
|
|
||||||
}
|
|
||||||
data-testid="toggle-dark-mode"
|
data-testid="toggle-dark-mode"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import clsx from "clsx";
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useExcalidrawContainer, useIsMobile } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import "./Dialog.scss";
|
import "./Dialog.scss";
|
||||||
import { back, close } from "./icons";
|
import { back, close } from "./icons";
|
||||||
@@ -21,7 +21,6 @@ export const Dialog = (props: {
|
|||||||
}) => {
|
}) => {
|
||||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||||
const [lastActiveElement] = useState(document.activeElement);
|
const [lastActiveElement] = useState(document.activeElement);
|
||||||
const { id } = useExcalidrawContainer();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!islandNode) {
|
if (!islandNode) {
|
||||||
@@ -83,7 +82,7 @@ export const Dialog = (props: {
|
|||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
>
|
>
|
||||||
<Island ref={setIslandNode}>
|
<Island ref={setIslandNode}>
|
||||||
<h2 id={`${id}-dialog-title`} className="Dialog__title">
|
<h2 id="dialog-title" className="Dialog__title">
|
||||||
<span className="Dialog__titleContent">{props.title}</span>
|
<span className="Dialog__titleContent">{props.title}</span>
|
||||||
<button
|
<button
|
||||||
className="Modal__close"
|
className="Modal__close"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const ErrorDialog = ({
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [modalIsShown, setModalIsShown] = useState(!!message);
|
const [modalIsShown, setModalIsShown] = useState(!!message);
|
||||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
const excalidrawContainer = useExcalidrawContainer();
|
||||||
|
|
||||||
const handleClose = React.useCallback(() => {
|
const handleClose = React.useCallback(() => {
|
||||||
setModalIsShown(false);
|
setModalIsShown(false);
|
||||||
|
|||||||
@@ -97,8 +97,7 @@
|
|||||||
|
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background-color: var(--button-color);
|
background-color: var(--button-color);
|
||||||
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.28),
|
box-shadow: 0 3px 5px -1px rgb(0 0 0 / 28%), 0 6px 10px 0 rgb(0 0 0 / 14%);
|
||||||
0 6px 10px 0 rgba(0, 0, 0, 0.14);
|
|
||||||
|
|
||||||
font-family: Cascadia;
|
font-family: Cascadia;
|
||||||
font-size: 1.8em;
|
font-size: 1.8em;
|
||||||
@@ -109,7 +108,7 @@
|
|||||||
}
|
}
|
||||||
&:active {
|
&:active {
|
||||||
background-color: var(--button-color-darkest);
|
background-color: var(--button-color-darkest);
|
||||||
box-shadow: none;
|
box-shadow: 0 3px 5px -1px rgb(0 0 0 / 20%), 0 6px 10px 0 rgb(0 0 0 / 14%);
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
|||||||
@@ -157,13 +157,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
shortcuts={["Shift+P", "7"]}
|
shortcuts={["Shift+P", "7"]}
|
||||||
/>
|
/>
|
||||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||||
<Shortcut
|
|
||||||
label={t("helpDialog.editSelectedShape")}
|
|
||||||
shortcuts={[
|
|
||||||
getShortcutKey("Enter"),
|
|
||||||
t("helpDialog.doubleClick"),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("helpDialog.textNewLine")}
|
label={t("helpDialog.textNewLine")}
|
||||||
shortcuts={[
|
shortcuts={[
|
||||||
@@ -372,14 +365,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("labels.flipVertical")}
|
label={t("labels.flipVertical")}
|
||||||
shortcuts={[getShortcutKey("Shift+V")]}
|
shortcuts={[getShortcutKey("Shift+V")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
|
||||||
label={t("labels.showStroke")}
|
|
||||||
shortcuts={[getShortcutKey("S")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.showBackground")}
|
|
||||||
shortcuts={[getShortcutKey("G")]}
|
|
||||||
/>
|
|
||||||
</ShortcutIsland>
|
</ShortcutIsland>
|
||||||
</Column>
|
</Column>
|
||||||
</Columns>
|
</Columns>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { questionCircle } from "../components/icons";
|
import { questionCircle } from "../components/icons";
|
||||||
|
|
||||||
type HelpIconProps = {
|
type HelpIconProps = {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
|
||||||
import "./HintViewer.scss";
|
import "./HintViewer.scss";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isLinearElement, isTextElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
|
|
||||||
interface Hint {
|
interface Hint {
|
||||||
@@ -56,14 +57,6 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
return t("hints.lineEditor_info");
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,17 +8,20 @@ import { CanvasError } from "../errors";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "./App";
|
import { useIsMobile } from "./App";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { exportToCanvas } from "../scene/export";
|
import { exportToCanvas, getExportSize } from "../scene/export";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { clipboard, exportImage } from "./icons";
|
import { clipboard, exportImage } from "./icons";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
import "./ExportDialog.scss";
|
import "./ExportDialog.scss";
|
||||||
|
import { supported as fsSupported } from "browser-fs-access";
|
||||||
import OpenColor from "open-color";
|
import OpenColor from "open-color";
|
||||||
import { CheckboxItem } from "./CheckboxItem";
|
import { CheckboxItem } from "./CheckboxItem";
|
||||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
|
||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
const scales = [1, 2, 3];
|
||||||
|
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
|
||||||
|
|
||||||
const supportsContextFilters =
|
const supportsContextFilters =
|
||||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||||
@@ -79,7 +82,7 @@ const ExportButton: React.FC<{
|
|||||||
const ImageExportModal = ({
|
const ImageExportModal = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = 10,
|
||||||
actionManager,
|
actionManager,
|
||||||
onExportToPng,
|
onExportToPng,
|
||||||
onExportToSvg,
|
onExportToSvg,
|
||||||
@@ -95,9 +98,14 @@ const ImageExportModal = ({
|
|||||||
onCloseRequest: () => void;
|
onCloseRequest: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||||
|
const [scale, setScale] = useState(defaultScale);
|
||||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
const { exportBackground, viewBackgroundColor } = appState;
|
const {
|
||||||
|
exportBackground,
|
||||||
|
viewBackgroundColor,
|
||||||
|
shouldAddWatermark,
|
||||||
|
} = appState;
|
||||||
|
|
||||||
const exportedElements = exportSelected
|
const exportedElements = exportSelected
|
||||||
? getSelectedElements(elements, appState)
|
? getSelectedElements(elements, appState)
|
||||||
@@ -117,6 +125,8 @@ const ImageExportModal = ({
|
|||||||
exportBackground,
|
exportBackground,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
exportPadding,
|
exportPadding,
|
||||||
|
scale,
|
||||||
|
shouldAddWatermark,
|
||||||
});
|
});
|
||||||
|
|
||||||
// if converting to blob fails, there's some problem that will
|
// if converting to blob fails, there's some problem that will
|
||||||
@@ -139,6 +149,8 @@ const ImageExportModal = ({
|
|||||||
exportBackground,
|
exportBackground,
|
||||||
exportPadding,
|
exportPadding,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
|
scale,
|
||||||
|
shouldAddWatermark,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -169,8 +181,34 @@ const ImageExportModal = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
|
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
|
||||||
<Stack.Row gap={2}>
|
<Stack.Row gap={2} justifyContent={"center"}>
|
||||||
{actionManager.renderAction("changeExportScale")}
|
{scales.map((_scale) => {
|
||||||
|
const [width, height] = getExportSize(
|
||||||
|
exportedElements,
|
||||||
|
exportPadding,
|
||||||
|
shouldAddWatermark,
|
||||||
|
_scale,
|
||||||
|
);
|
||||||
|
|
||||||
|
const scaleButtonTitle = `${t(
|
||||||
|
"buttons.scale",
|
||||||
|
)} ${_scale}x (${width}x${height})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolButton
|
||||||
|
key={_scale}
|
||||||
|
size="s"
|
||||||
|
type="radio"
|
||||||
|
icon={`${_scale}x`}
|
||||||
|
name="export-canvas-scale"
|
||||||
|
title={scaleButtonTitle}
|
||||||
|
aria-label={scaleButtonTitle}
|
||||||
|
id="export-canvas-scale"
|
||||||
|
checked={_scale === scale}
|
||||||
|
onChange={() => setScale(_scale)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
|
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,15 +220,14 @@ const ImageExportModal = ({
|
|||||||
margin: ".6em 0",
|
margin: ".6em 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!nativeFileSystemSupported &&
|
{!fsSupported && actionManager.renderAction("changeProjectName")}
|
||||||
actionManager.renderAction("changeProjectName")}
|
|
||||||
</div>
|
</div>
|
||||||
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
|
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
|
||||||
<ExportButton
|
<ExportButton
|
||||||
color="indigo"
|
color="indigo"
|
||||||
title={t("buttons.exportToPng")}
|
title={t("buttons.exportToPng")}
|
||||||
aria-label={t("buttons.exportToPng")}
|
aria-label={t("buttons.exportToPng")}
|
||||||
onClick={() => onExportToPng(exportedElements)}
|
onClick={() => onExportToPng(exportedElements, scale)}
|
||||||
>
|
>
|
||||||
PNG
|
PNG
|
||||||
</ExportButton>
|
</ExportButton>
|
||||||
@@ -198,14 +235,14 @@ const ImageExportModal = ({
|
|||||||
color="red"
|
color="red"
|
||||||
title={t("buttons.exportToSvg")}
|
title={t("buttons.exportToSvg")}
|
||||||
aria-label={t("buttons.exportToSvg")}
|
aria-label={t("buttons.exportToSvg")}
|
||||||
onClick={() => onExportToSvg(exportedElements)}
|
onClick={() => onExportToSvg(exportedElements, scale)}
|
||||||
>
|
>
|
||||||
SVG
|
SVG
|
||||||
</ExportButton>
|
</ExportButton>
|
||||||
{probablySupportsClipboardBlob && (
|
{probablySupportsClipboardBlob && (
|
||||||
<ExportButton
|
<ExportButton
|
||||||
title={t("buttons.copyPngToClipboard")}
|
title={t("buttons.copyPngToClipboard")}
|
||||||
onClick={() => onExportToClipboard(exportedElements)}
|
onClick={() => onExportToClipboard(exportedElements, scale)}
|
||||||
color="gray"
|
color="gray"
|
||||||
shade={7}
|
shade={7}
|
||||||
>
|
>
|
||||||
@@ -220,7 +257,7 @@ const ImageExportModal = ({
|
|||||||
export const ImageExportDialog = ({
|
export const ImageExportDialog = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = 10,
|
||||||
actionManager,
|
actionManager,
|
||||||
onExportToPng,
|
onExportToPng,
|
||||||
onExportToSvg,
|
onExportToSvg,
|
||||||
|
|||||||
@@ -1,25 +1,30 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
children: React.ReactElement;
|
|
||||||
}
|
}
|
||||||
|
interface State {
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
export class InitializeApp extends React.Component<Props, State> {
|
||||||
|
public state: { isLoading: boolean } = {
|
||||||
|
isLoading: true,
|
||||||
|
};
|
||||||
|
|
||||||
export const InitializeApp = (props: Props) => {
|
async componentDidMount() {
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateLang = async () => {
|
|
||||||
await setLanguage(currentLang);
|
|
||||||
};
|
|
||||||
const currentLang =
|
const currentLang =
|
||||||
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
languages.find((lang) => lang.code === this.props.langCode) ||
|
||||||
updateLang();
|
defaultLang;
|
||||||
setLoading(false);
|
await setLanguage(currentLang);
|
||||||
}, [props.langCode]);
|
this.setState({
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return loading ? <LoadingMessage /> : props.children;
|
public render() {
|
||||||
};
|
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ import { ActionsManagerInterface } from "../actions/types";
|
|||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "./App";
|
import { useIsMobile } from "./App";
|
||||||
import { AppState, ExportOpts } from "../types";
|
import { AppState } from "../types";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { actionSaveFileToDisk } from "../actions/actionExport";
|
import { actionSaveAsScene } from "../actions/actionExport";
|
||||||
import { Card } from "./Card";
|
import { Card } from "./Card";
|
||||||
|
|
||||||
import "./ExportDialog.scss";
|
import "./ExportDialog.scss";
|
||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
import { supported as fsSupported } from "browser-fs-access";
|
||||||
|
|
||||||
export type ExportCB = (
|
export type ExportCB = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
@@ -22,41 +22,35 @@ const JSONExportModal = ({
|
|||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
actionManager,
|
actionManager,
|
||||||
exportOpts,
|
onExportToBackend,
|
||||||
canvas,
|
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionsManagerInterface;
|
||||||
|
onExportToBackend?: ExportCB;
|
||||||
onCloseRequest: () => void;
|
onCloseRequest: () => void;
|
||||||
exportOpts: ExportOpts;
|
|
||||||
canvas: HTMLCanvasElement | null;
|
|
||||||
}) => {
|
}) => {
|
||||||
const { onExportToBackend } = exportOpts;
|
|
||||||
return (
|
return (
|
||||||
<div className="ExportDialog ExportDialog--json">
|
<div className="ExportDialog ExportDialog--json">
|
||||||
<div className="ExportDialog-cards">
|
<div className="ExportDialog-cards">
|
||||||
{exportOpts.saveFileToDisk && (
|
<Card color="lime">
|
||||||
<Card color="lime">
|
<div className="Card-icon">{exportToFileIcon}</div>
|
||||||
<div className="Card-icon">{exportToFileIcon}</div>
|
<h2>{t("exportDialog.disk_title")}</h2>
|
||||||
<h2>{t("exportDialog.disk_title")}</h2>
|
<div className="Card-details">
|
||||||
<div className="Card-details">
|
{t("exportDialog.disk_details")}
|
||||||
{t("exportDialog.disk_details")}
|
{!fsSupported && actionManager.renderAction("changeProjectName")}
|
||||||
{!nativeFileSystemSupported &&
|
</div>
|
||||||
actionManager.renderAction("changeProjectName")}
|
<ToolButton
|
||||||
</div>
|
className="Card-button"
|
||||||
<ToolButton
|
type="button"
|
||||||
className="Card-button"
|
title={t("exportDialog.disk_button")}
|
||||||
type="button"
|
aria-label={t("exportDialog.disk_button")}
|
||||||
title={t("exportDialog.disk_button")}
|
showAriaLabel={true}
|
||||||
aria-label={t("exportDialog.disk_button")}
|
onClick={() => {
|
||||||
showAriaLabel={true}
|
actionManager.executeAction(actionSaveAsScene);
|
||||||
onClick={() => {
|
}}
|
||||||
actionManager.executeAction(actionSaveFileToDisk);
|
/>
|
||||||
}}
|
</Card>
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
{onExportToBackend && (
|
{onExportToBackend && (
|
||||||
<Card color="pink">
|
<Card color="pink">
|
||||||
<div className="Card-icon">{link}</div>
|
<div className="Card-icon">{link}</div>
|
||||||
@@ -68,12 +62,10 @@ const JSONExportModal = ({
|
|||||||
title={t("exportDialog.link_button")}
|
title={t("exportDialog.link_button")}
|
||||||
aria-label={t("exportDialog.link_button")}
|
aria-label={t("exportDialog.link_button")}
|
||||||
showAriaLabel={true}
|
showAriaLabel={true}
|
||||||
onClick={() => onExportToBackend(elements, appState, canvas)}
|
onClick={() => onExportToBackend(elements)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{exportOpts.renderCustomUI &&
|
|
||||||
exportOpts.renderCustomUI(elements, appState, canvas)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -83,14 +75,12 @@ export const JSONExportDialog = ({
|
|||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
actionManager,
|
actionManager,
|
||||||
exportOpts,
|
onExportToBackend,
|
||||||
canvas,
|
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionsManagerInterface;
|
||||||
exportOpts: ExportOpts;
|
onExportToBackend?: ExportCB;
|
||||||
canvas: HTMLCanvasElement | null;
|
|
||||||
}) => {
|
}) => {
|
||||||
const [modalIsShown, setModalIsShown] = useState(false);
|
const [modalIsShown, setModalIsShown] = useState(false);
|
||||||
|
|
||||||
@@ -117,9 +107,8 @@ export const JSONExportDialog = ({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
|
onExportToBackend={onExportToBackend}
|
||||||
onCloseRequest={handleClose}
|
onCloseRequest={handleClose}
|
||||||
exportOpts={exportOpts}
|
|
||||||
canvas={canvas}
|
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -73,10 +73,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
|
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||||
transform: translate(-76px, 0);
|
transform: translate(-92px, 0);
|
||||||
}
|
}
|
||||||
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
|
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||||
transform: translate(76px, 0);
|
transform: translate(92px, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.layer-ui__wrapper__footer-left--transition-bottom {
|
&.layer-ui__wrapper__footer-left--transition-bottom {
|
||||||
@@ -116,19 +116,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.layer-ui__wrapper__footer-left,
|
.layer-ui__wrapper__footer-left,
|
||||||
.layer-ui__wrapper__footer-right,
|
|
||||||
.disable-zen-mode--visible {
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__wrapper__footer-left {
|
|
||||||
margin-bottom: 0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__wrapper__footer-right {
|
.layer-ui__wrapper__footer-right {
|
||||||
margin-top: auto;
|
pointer-events: all;
|
||||||
margin-bottom: auto;
|
|
||||||
margin-inline-end: 1em;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-49
@@ -36,7 +36,7 @@ import { Island } from "./Island";
|
|||||||
import "./LayerUI.scss";
|
import "./LayerUI.scss";
|
||||||
import { LibraryUnit } from "./LibraryUnit";
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { LockButton } from "./LockButton";
|
import { LockIcon } from "./LockIcon";
|
||||||
import { MobileMenu } from "./MobileMenu";
|
import { MobileMenu } from "./MobileMenu";
|
||||||
import { PasteChartDialog } from "./PasteChartDialog";
|
import { PasteChartDialog } from "./PasteChartDialog";
|
||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
@@ -47,8 +47,6 @@ import { Tooltip } from "./Tooltip";
|
|||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import Library from "../data/library";
|
import Library from "../data/library";
|
||||||
import { JSONExportDialog } from "./JSONExportDialog";
|
import { JSONExportDialog } from "./JSONExportDialog";
|
||||||
import { LibraryButton } from "./LibraryButton";
|
|
||||||
import { isImageFileHandle } from "../data/blob";
|
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
@@ -65,6 +63,11 @@ interface LayerUIProps {
|
|||||||
toggleZenMode: () => void;
|
toggleZenMode: () => void;
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
|
onExportToBackend?: (
|
||||||
|
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
canvas: HTMLCanvasElement | null,
|
||||||
|
) => void;
|
||||||
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||||
viewModeEnabled: boolean;
|
viewModeEnabled: boolean;
|
||||||
@@ -109,7 +112,6 @@ const LibraryMenuItems = ({
|
|||||||
onAddToLibrary,
|
onAddToLibrary,
|
||||||
onInsertShape,
|
onInsertShape,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
theme,
|
|
||||||
setAppState,
|
setAppState,
|
||||||
setLibraryItems,
|
setLibraryItems,
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
@@ -122,7 +124,6 @@ const LibraryMenuItems = ({
|
|||||||
onRemoveFromLibrary: (index: number) => void;
|
onRemoveFromLibrary: (index: number) => void;
|
||||||
onInsertShape: (elements: LibraryItem) => void;
|
onInsertShape: (elements: LibraryItem) => void;
|
||||||
onAddToLibrary: (elements: LibraryItem) => void;
|
onAddToLibrary: (elements: LibraryItem) => void;
|
||||||
theme: AppState["theme"];
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
setLibraryItems: (library: LibraryItems) => void;
|
setLibraryItems: (library: LibraryItems) => void;
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
@@ -196,7 +197,7 @@ const LibraryMenuItems = ({
|
|||||||
<a
|
<a
|
||||||
href={`https://libraries.excalidraw.com?target=${
|
href={`https://libraries.excalidraw.com?target=${
|
||||||
window.name || "_blank"
|
window.name || "_blank"
|
||||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
|
}&referrer=${referrer}&useHash=true&token=${id}`}
|
||||||
target="_excalidraw_libraries"
|
target="_excalidraw_libraries"
|
||||||
>
|
>
|
||||||
{t("labels.libraries")}
|
{t("labels.libraries")}
|
||||||
@@ -250,7 +251,6 @@ const LibraryMenu = ({
|
|||||||
onInsertShape,
|
onInsertShape,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
onAddToLibrary,
|
onAddToLibrary,
|
||||||
theme,
|
|
||||||
setAppState,
|
setAppState,
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
focusContainer,
|
focusContainer,
|
||||||
@@ -261,7 +261,6 @@ const LibraryMenu = ({
|
|||||||
onClickOutside: (event: MouseEvent) => void;
|
onClickOutside: (event: MouseEvent) => void;
|
||||||
onInsertShape: (elements: LibraryItem) => void;
|
onInsertShape: (elements: LibraryItem) => void;
|
||||||
onAddToLibrary: () => void;
|
onAddToLibrary: () => void;
|
||||||
theme: AppState["theme"];
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
focusContainer: () => void;
|
focusContainer: () => void;
|
||||||
@@ -351,7 +350,6 @@ const LibraryMenu = ({
|
|||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
focusContainer={focusContainer}
|
focusContainer={focusContainer}
|
||||||
library={library}
|
library={library}
|
||||||
theme={theme}
|
|
||||||
id={id}
|
id={id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -373,6 +371,7 @@ const LayerUI = ({
|
|||||||
showThemeBtn,
|
showThemeBtn,
|
||||||
toggleZenMode,
|
toggleZenMode,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
|
onExportToBackend,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
viewModeEnabled,
|
viewModeEnabled,
|
||||||
@@ -394,38 +393,39 @@ const LayerUI = ({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
exportOpts={UIOptions.canvasActions.export}
|
onExportToBackend={
|
||||||
canvas={canvas}
|
onExportToBackend
|
||||||
|
? (elements) => {
|
||||||
|
onExportToBackend &&
|
||||||
|
onExportToBackend(elements, appState, canvas);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderImageExportDialog = () => {
|
const renderImageExportDialog = () => {
|
||||||
if (!UIOptions.canvasActions.saveAsImage) {
|
if (!UIOptions.canvasActions.export) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createExporter = (type: ExportType): ExportCB => async (
|
const createExporter = (type: ExportType): ExportCB => async (
|
||||||
exportedElements,
|
exportedElements,
|
||||||
|
scale,
|
||||||
) => {
|
) => {
|
||||||
const fileHandle = await exportCanvas(type, exportedElements, appState, {
|
await exportCanvas(type, exportedElements, appState, {
|
||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
name: appState.name,
|
name: appState.name,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
|
scale,
|
||||||
|
shouldAddWatermark: appState.shouldAddWatermark,
|
||||||
})
|
})
|
||||||
.catch(muteFSAbortError)
|
.catch(muteFSAbortError)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setAppState({ errorMessage: error.message });
|
setAppState({ errorMessage: error.message });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
|
||||||
appState.exportEmbedScene &&
|
|
||||||
fileHandle &&
|
|
||||||
isImageFileHandle(fileHandle)
|
|
||||||
) {
|
|
||||||
setAppState({ fileHandle });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -497,9 +497,6 @@ const LayerUI = ({
|
|||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
showThemeBtn={showThemeBtn}
|
showThemeBtn={showThemeBtn}
|
||||||
/>
|
/>
|
||||||
{appState.fileHandle && (
|
|
||||||
<>{actionManager.renderAction("saveToActiveFile")}</>
|
|
||||||
)}
|
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</Island>
|
</Island>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -518,8 +515,7 @@ const LayerUI = ({
|
|||||||
style={{
|
style={{
|
||||||
// we want to make sure this doesn't overflow so substracting 200
|
// 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
|
// which is approximately height of zoom footer and top left menu items with some buffer
|
||||||
// if active file name is displayed, subtracting 248 to account for its height
|
maxHeight: `${appState.height - 200}px`,
|
||||||
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectedShapeActions
|
<SelectedShapeActions
|
||||||
@@ -556,7 +552,6 @@ const LayerUI = ({
|
|||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
focusContainer={focusContainer}
|
focusContainer={focusContainer}
|
||||||
library={library}
|
library={library}
|
||||||
theme={appState.theme}
|
|
||||||
id={id}
|
id={id}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
@@ -584,12 +579,6 @@ const LayerUI = ({
|
|||||||
{(heading) => (
|
{(heading) => (
|
||||||
<Stack.Col gap={4} align="start">
|
<Stack.Col gap={4} align="start">
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<LockButton
|
|
||||||
zenModeEnabled={zenModeEnabled}
|
|
||||||
checked={appState.elementLocked}
|
|
||||||
onChange={onLockToggle}
|
|
||||||
title={t("toolBar.lock")}
|
|
||||||
/>
|
|
||||||
<Island
|
<Island
|
||||||
padding={1}
|
padding={1}
|
||||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
className={clsx({ "zen-mode": zenModeEnabled })}
|
||||||
@@ -601,12 +590,15 @@ const LayerUI = ({
|
|||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
elementType={appState.elementType}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
|
isLibraryOpen={appState.isLibraryOpen}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
<LibraryButton
|
<LockIcon
|
||||||
appState={appState}
|
zenModeEnabled={zenModeEnabled}
|
||||||
setAppState={setAppState}
|
checked={appState.elementLocked}
|
||||||
|
onChange={onLockToggle}
|
||||||
|
title={t("toolBar.lock")}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{libraryMenu}
|
{libraryMenu}
|
||||||
@@ -632,9 +624,7 @@ const LayerUI = ({
|
|||||||
label={client.username || "Unknown user"}
|
label={client.username || "Unknown user"}
|
||||||
key={clientId}
|
key={clientId}
|
||||||
>
|
>
|
||||||
{actionManager.renderAction("goToCollaborator", {
|
{actionManager.renderAction("goToCollaborator", clientId)}
|
||||||
id: clientId,
|
|
||||||
})}
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</UserList>
|
</UserList>
|
||||||
@@ -667,16 +657,6 @@ const LayerUI = ({
|
|||||||
zoom={appState.zoom}
|
zoom={appState.zoom}
|
||||||
/>
|
/>
|
||||||
</Island>
|
</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>
|
</Section>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { capitalizeString } from "../utils";
|
|
||||||
|
|
||||||
const LIBRARY_ICON = (
|
|
||||||
<svg viewBox="0 0 576 512">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M542.22 32.05c-54.8 3.11-163.72 14.43-230.96 55.59-4.64 2.84-7.27 7.89-7.27 13.17v363.87c0 11.55 12.63 18.85 23.28 13.49 69.18-34.82 169.23-44.32 218.7-46.92 16.89-.89 30.02-14.43 30.02-30.66V62.75c.01-17.71-15.35-31.74-33.77-30.7zM264.73 87.64C197.5 46.48 88.58 35.17 33.78 32.05 15.36 31.01 0 45.04 0 62.75V400.6c0 16.24 13.13 29.78 30.02 30.66 49.49 2.6 149.59 12.11 218.77 46.95 10.62 5.35 23.21-1.94 23.21-13.46V100.63c0-5.29-2.62-10.14-7.27-12.99z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const LibraryButton: React.FC<{
|
|
||||||
appState: AppState;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
}> = ({ appState, setAppState }) => {
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
className={clsx(
|
|
||||||
"ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
|
|
||||||
`ToolIcon_size_medium`,
|
|
||||||
{
|
|
||||||
"zen-mode-visibility--hidden": appState.zenModeEnabled,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
title={`${capitalizeString(t("toolBar.library"))} — 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 clsx from "clsx";
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { close } from "../components/icons";
|
import { close } from "../components/icons";
|
||||||
import { MIME_TYPES } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -36,27 +36,22 @@ export const LibraryUnit = ({
|
|||||||
if (!elementsToRender) {
|
if (!elementsToRender) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let svg: SVGSVGElement;
|
const svg = exportToSvg(elementsToRender, {
|
||||||
|
exportBackground: false,
|
||||||
|
viewBackgroundColor: oc.white,
|
||||||
|
shouldAddWatermark: false,
|
||||||
|
});
|
||||||
|
for (const child of ref.current!.children) {
|
||||||
|
if (child.tagName !== "svg") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ref.current!.removeChild(child);
|
||||||
|
}
|
||||||
|
ref.current!.appendChild(svg);
|
||||||
|
|
||||||
const current = ref.current!;
|
const current = ref.current!;
|
||||||
|
|
||||||
(async () => {
|
|
||||||
svg = await exportToSvg(elementsToRender, {
|
|
||||||
exportBackground: false,
|
|
||||||
viewBackgroundColor: oc.white,
|
|
||||||
});
|
|
||||||
for (const child of ref.current!.children) {
|
|
||||||
if (child.tagName !== "svg") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
current!.removeChild(child);
|
|
||||||
}
|
|
||||||
current!.appendChild(svg);
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (svg) {
|
current.removeChild(svg);
|
||||||
current.removeChild(svg);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [elements, pendingElements]);
|
}, [elements, pendingElements]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
export const LoadingMessage = () => {
|
export const LoadingMessage = () => {
|
||||||
|
|||||||
@@ -2,17 +2,20 @@ import "./ToolIcon.scss";
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ToolButtonSize } from "./ToolButton";
|
|
||||||
|
type LockIconSize = "s" | "m";
|
||||||
|
|
||||||
type LockIconProps = {
|
type LockIconProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
id?: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange?(): void;
|
onChange?(): void;
|
||||||
|
size?: LockIconSize;
|
||||||
zenModeEnabled?: boolean;
|
zenModeEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
const DEFAULT_SIZE: LockIconSize = "m";
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
CHECKED: (
|
CHECKED: (
|
||||||
@@ -38,12 +41,12 @@ const ICONS = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LockButton = (props: LockIconProps) => {
|
export const LockIcon = (props: LockIconProps) => {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
|
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
|
||||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
`ToolIcon_size_${props.size || DEFAULT_SIZE}`,
|
||||||
{
|
{
|
||||||
"zen-mode-visibility--hidden": props.zenModeEnabled,
|
"zen-mode-visibility--hidden": props.zenModeEnabled,
|
||||||
},
|
},
|
||||||
@@ -54,6 +57,7 @@ export const LockButton = (props: LockIconProps) => {
|
|||||||
className="ToolIcon_type_checkbox"
|
className="ToolIcon_type_checkbox"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name={props.name}
|
name={props.name}
|
||||||
|
id={props.id}
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
checked={props.checked}
|
checked={props.checked}
|
||||||
aria-label={props.title}
|
aria-label={props.title}
|
||||||
@@ -13,10 +13,9 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
|||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import CollabButton from "./CollabButton";
|
import CollabButton from "./CollabButton";
|
||||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||||
import { LockButton } from "./LockButton";
|
import { LockIcon } from "./LockIcon";
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
import { LibraryButton } from "./LibraryButton";
|
|
||||||
|
|
||||||
type MobileMenuProps = {
|
type MobileMenuProps = {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
@@ -65,15 +64,15 @@ export const MobileMenu = ({
|
|||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
elementType={appState.elementType}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
|
isLibraryOpen={appState.isLibraryOpen}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
<LockButton
|
<LockIcon
|
||||||
checked={appState.elementLocked}
|
checked={appState.elementLocked}
|
||||||
onChange={onLockToggle}
|
onChange={onLockToggle}
|
||||||
title={t("toolBar.lock")}
|
title={t("toolBar.lock")}
|
||||||
/>
|
/>
|
||||||
<LibraryButton appState={appState} setAppState={setAppState} />
|
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{libraryMenu}
|
{libraryMenu}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
@@ -168,9 +167,10 @@ export const MobileMenu = ({
|
|||||||
)
|
)
|
||||||
.map(([clientId, client]) => (
|
.map(([clientId, client]) => (
|
||||||
<React.Fragment key={clientId}>
|
<React.Fragment key={clientId}>
|
||||||
{actionManager.renderAction("goToCollaborator", {
|
{actionManager.renderAction(
|
||||||
id: clientId,
|
"goToCollaborator",
|
||||||
})}
|
clientId,
|
||||||
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</UserList>
|
</UserList>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import clsx from "clsx";
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { useExcalidrawContainer, useIsMobile } from "./App";
|
import { useExcalidrawContainer, useIsMobile } from "./App";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { THEME } from "../constants";
|
|
||||||
|
|
||||||
export const Modal = (props: {
|
export const Modal = (props: {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -16,7 +15,7 @@ export const Modal = (props: {
|
|||||||
labelledBy: string;
|
labelledBy: string;
|
||||||
theme?: AppState["theme"];
|
theme?: AppState["theme"];
|
||||||
}) => {
|
}) => {
|
||||||
const { theme = THEME.LIGHT } = props;
|
const { theme = "light" } = props;
|
||||||
const modalRoot = useBodyRoot(theme);
|
const modalRoot = useBodyRoot(theme);
|
||||||
|
|
||||||
if (!modalRoot) {
|
if (!modalRoot) {
|
||||||
@@ -59,7 +58,7 @@ const useBodyRoot = (theme: AppState["theme"]) => {
|
|||||||
const isMobileRef = useRef(isMobile);
|
const isMobileRef = useRef(isMobile);
|
||||||
isMobileRef.current = isMobile;
|
isMobileRef.current = isMobile;
|
||||||
|
|
||||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
const excalidrawContainer = useExcalidrawContainer();
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (div) {
|
if (div) {
|
||||||
|
|||||||
@@ -34,21 +34,20 @@ const ChartPreviewBtn = (props: {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
setChartElements(elements);
|
setChartElements(elements);
|
||||||
let svg: SVGSVGElement;
|
|
||||||
|
const svg = exportToSvg(elements, {
|
||||||
|
exportBackground: false,
|
||||||
|
viewBackgroundColor: oc.white,
|
||||||
|
shouldAddWatermark: false,
|
||||||
|
});
|
||||||
|
|
||||||
const previewNode = previewRef.current!;
|
const previewNode = previewRef.current!;
|
||||||
|
|
||||||
(async () => {
|
previewNode.appendChild(svg);
|
||||||
svg = await exportToSvg(elements, {
|
|
||||||
exportBackground: false,
|
|
||||||
viewBackgroundColor: oc.white,
|
|
||||||
});
|
|
||||||
|
|
||||||
previewNode.appendChild(svg);
|
if (props.selected) {
|
||||||
|
(previewNode.parentNode as HTMLDivElement).focus();
|
||||||
if (props.selected) {
|
}
|
||||||
(previewNode.parentNode as HTMLDivElement).focus();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
previewNode.removeChild(svg);
|
previewNode.removeChild(svg);
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import "./TextInput.scss";
|
import "./TextInput.scss";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { Component } from "react";
|
||||||
import { focusNearestParent } from "../utils";
|
import { focusNearestParent } from "../utils";
|
||||||
|
|
||||||
import "./ProjectName.scss";
|
import "./ProjectName.scss";
|
||||||
import { useExcalidrawContainer } from "./App";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -13,19 +12,22 @@ type Props = {
|
|||||||
isNameEditable: boolean;
|
isNameEditable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProjectName = (props: Props) => {
|
type State = {
|
||||||
const { id } = useExcalidrawContainer();
|
fileName: string;
|
||||||
const [fileName, setFileName] = useState<string>(props.value);
|
};
|
||||||
|
export class ProjectName extends Component<Props, State> {
|
||||||
const handleBlur = (event: any) => {
|
state = {
|
||||||
|
fileName: this.props.value,
|
||||||
|
};
|
||||||
|
private handleBlur = (event: any) => {
|
||||||
focusNearestParent(event.target);
|
focusNearestParent(event.target);
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
if (value !== props.value) {
|
if (value !== this.props.value) {
|
||||||
props.onChange(value);
|
this.props.onChange(value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (event.nativeEvent.isComposing || event.keyCode === 229) {
|
if (event.nativeEvent.isComposing || event.keyCode === 229) {
|
||||||
@@ -35,25 +37,29 @@ export const ProjectName = (props: Props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
public render() {
|
||||||
<div className="ProjectName">
|
return (
|
||||||
<label className="ProjectName-label" htmlFor="filename">
|
<div className="ProjectName">
|
||||||
{`${props.label}${props.isNameEditable ? "" : ":"}`}
|
<label className="ProjectName-label" htmlFor="filename">
|
||||||
</label>
|
{`${this.props.label}${this.props.isNameEditable ? "" : ":"}`}
|
||||||
{props.isNameEditable ? (
|
</label>
|
||||||
<input
|
{this.props.isNameEditable ? (
|
||||||
className="TextInput"
|
<input
|
||||||
onBlur={handleBlur}
|
className="TextInput"
|
||||||
onKeyDown={handleKeyDown}
|
onBlur={this.handleBlur}
|
||||||
id={`${id}-filename`}
|
onKeyDown={this.handleKeyDown}
|
||||||
value={fileName}
|
id="filename"
|
||||||
onChange={(event) => setFileName(event.target.value)}
|
value={this.state.fileName}
|
||||||
/>
|
onChange={(event) =>
|
||||||
) : (
|
this.setState({ fileName: event.target.value })
|
||||||
<span className="TextInput TextInput--readonly" id={`${id}-filename`}>
|
}
|
||||||
{props.value}
|
/>
|
||||||
</span>
|
) : (
|
||||||
)}
|
<span className="TextInput TextInput--readonly" id="filename">
|
||||||
</div>
|
{this.props.value}
|
||||||
);
|
</span>
|
||||||
};
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useExcalidrawContainer } from "./App";
|
|
||||||
|
|
||||||
interface SectionProps extends React.HTMLProps<HTMLElement> {
|
interface SectionProps extends React.HTMLProps<HTMLElement> {
|
||||||
heading: string;
|
heading: string;
|
||||||
@@ -8,14 +7,13 @@ interface SectionProps extends React.HTMLProps<HTMLElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Section = ({ heading, children, ...props }: SectionProps) => {
|
export const Section = ({ heading, children, ...props }: SectionProps) => {
|
||||||
const { id } = useExcalidrawContainer();
|
|
||||||
const header = (
|
const header = (
|
||||||
<h2 className="visually-hidden" id={`${id}-${heading}-title`}>
|
<h2 className="visually-hidden" id={`${heading}-title`}>
|
||||||
{t(`headings.${heading}`)}
|
{t(`headings.${heading}`)}
|
||||||
</h2>
|
</h2>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<section {...props} aria-labelledby={`${id}-${heading}-title`}>
|
<section {...props} aria-labelledby={`${heading}-title`}>
|
||||||
{typeof children === "function" ? (
|
{typeof children === "function" ? (
|
||||||
children(header)
|
children(header)
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import React, { useCallback, useEffect, useRef } from "react";
|
||||||
import { TOAST_TIMEOUT } from "../constants";
|
import { TOAST_TIMEOUT } from "../constants";
|
||||||
import "./Toast.scss";
|
import "./Toast.scss";
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import "./ToolIcon.scss";
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useExcalidrawContainer } from "./App";
|
|
||||||
|
|
||||||
export type ToolButtonSize = "small" | "medium";
|
type ToolIconSize = "s" | "m";
|
||||||
|
|
||||||
type ToolButtonBaseProps = {
|
type ToolButtonBaseProps = {
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
@@ -15,7 +14,7 @@ type ToolButtonBaseProps = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
size?: ToolButtonSize;
|
size?: ToolIconSize;
|
||||||
keyBindingLabel?: string;
|
keyBindingLabel?: string;
|
||||||
showAriaLabel?: boolean;
|
showAriaLabel?: boolean;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
@@ -41,11 +40,12 @@ type ToolButtonProps =
|
|||||||
onChange?(): void;
|
onChange?(): void;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DEFAULT_SIZE: ToolIconSize = "m";
|
||||||
|
|
||||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||||
const { id: excalId } = useExcalidrawContainer();
|
|
||||||
const innerRef = React.useRef(null);
|
const innerRef = React.useRef(null);
|
||||||
React.useImperativeHandle(ref, () => innerRef.current);
|
React.useImperativeHandle(ref, () => innerRef.current);
|
||||||
const sizeCn = `ToolIcon_size_${props.size}`;
|
const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
|
||||||
|
|
||||||
if (props.type === "button" || props.type === "icon") {
|
if (props.type === "button" || props.type === "icon") {
|
||||||
return (
|
return (
|
||||||
@@ -98,7 +98,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
aria-label={props["aria-label"]}
|
aria-label={props["aria-label"]}
|
||||||
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
||||||
data-testid={props["data-testid"]}
|
data-testid={props["data-testid"]}
|
||||||
id={`${excalId}-${props.id}`}
|
id={props.id}
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
checked={props.checked}
|
checked={props.checked}
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
@@ -116,5 +116,4 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
ToolButton.defaultProps = {
|
ToolButton.defaultProps = {
|
||||||
visible: true,
|
visible: true,
|
||||||
className: "",
|
className: "",
|
||||||
size: "medium",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,18 +8,10 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
font-family: Cascadia;
|
font-family: Cascadia;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
background-color: var(--button-gray-1);
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
border-radius: var(--space-factor);
|
border-radius: var(--space-factor);
|
||||||
user-select: none;
|
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 {
|
.ToolIcon--plain {
|
||||||
@@ -60,9 +52,9 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon_size_small .ToolIcon__icon {
|
.ToolIcon_size_s .ToolIcon__icon {
|
||||||
width: 2rem;
|
width: 1.4rem;
|
||||||
height: 2rem;
|
height: 1.4rem;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +66,14 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--button-gray-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: var(--button-gray-2);
|
||||||
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
}
|
}
|
||||||
@@ -86,14 +86,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--button-gray-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background-color: var(--button-gray-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--show {
|
&--show {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
@@ -111,9 +103,6 @@
|
|||||||
|
|
||||||
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
|
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
|
||||||
background-color: var(--button-gray-2);
|
background-color: var(--button-gray-2);
|
||||||
&:active {
|
|
||||||
background-color: var(--button-gray-3);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus + .ToolIcon__icon {
|
&:focus + .ToolIcon__icon {
|
||||||
@@ -141,21 +130,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
background-color: var(--button-gray-1);
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--button-gray-2);
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: var(--button-gray-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2em;
|
height: 2em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon.ToolIcon__lock {
|
.ToolIcon.ToolIcon__lock {
|
||||||
margin-inline-end: var(--space-factor);
|
|
||||||
&.ToolIcon_type_floating {
|
&.ToolIcon_type_floating {
|
||||||
margin-left: 0.1rem;
|
margin-left: 0.1rem;
|
||||||
}
|
}
|
||||||
@@ -186,9 +166,10 @@
|
|||||||
// move the lock button out of the way on small viewports
|
// 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
|
// it begins to collide with the GitHub icon before we switch to mobile mode
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.ToolIcon.ToolIcon_type_floating {
|
.ToolIcon.ToolIcon__lock {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
top: 60px;
|
||||||
right: -8px;
|
right: -8px;
|
||||||
|
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
@@ -213,14 +194,6 @@
|
|||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.ToolIcon.ToolIcon__library {
|
|
||||||
top: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon.ToolIcon__lock {
|
|
||||||
margin-inline-end: 0;
|
|
||||||
top: 60px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.unlocked-icon {
|
.unlocked-icon {
|
||||||
|
|||||||
+10
-16
@@ -1,6 +1,4 @@
|
|||||||
@import "../css/variables.module";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
// container in body where the actual tooltip is appended to
|
|
||||||
.excalidraw-tooltip {
|
.excalidraw-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -26,20 +24,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wraps the element we want to apply the tooltip to
|
.excalidraw {
|
||||||
.excalidraw-tooltip-wrapper {
|
.Tooltip-icon {
|
||||||
display: flex;
|
width: 0.9em;
|
||||||
height: 100%;
|
height: 0.9em;
|
||||||
}
|
margin-left: 5px;
|
||||||
|
margin-top: 1px;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
.excalidraw-tooltip-icon {
|
@include isMobile {
|
||||||
width: 0.9em;
|
display: none;
|
||||||
height: 0.9em;
|
}
|
||||||
margin-left: 5px;
|
|
||||||
margin-top: 1px;
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
@include isMobile {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="excalidraw-tooltip-wrapper"
|
|
||||||
onPointerEnter={(event) =>
|
onPointerEnter={(event) =>
|
||||||
updateTooltip(
|
updateTooltip(
|
||||||
event.currentTarget as HTMLDivElement,
|
event.currentTarget as HTMLDivElement,
|
||||||
|
|||||||
+435
-411
File diff suppressed because it is too large
Load Diff
+10
-20
@@ -1,6 +1,6 @@
|
|||||||
|
import { FontFamily } from "./element/types";
|
||||||
import cssVariables from "./css/variables.module.scss";
|
import cssVariables from "./css/variables.module.scss";
|
||||||
import { AppProps } from "./types";
|
import { AppProps } from "./types";
|
||||||
import { FontFamilyValues } from "./element/types";
|
|
||||||
|
|
||||||
export const APP_NAME = "Excalidraw";
|
export const APP_NAME = "Excalidraw";
|
||||||
|
|
||||||
@@ -14,7 +14,6 @@ export const CURSOR_TYPE = {
|
|||||||
TEXT: "text",
|
TEXT: "text",
|
||||||
CROSSHAIR: "crosshair",
|
CROSSHAIR: "crosshair",
|
||||||
GRABBING: "grabbing",
|
GRABBING: "grabbing",
|
||||||
GRAB: "grab",
|
|
||||||
POINTER: "pointer",
|
POINTER: "pointer",
|
||||||
MOVE: "move",
|
MOVE: "move",
|
||||||
AUTO: "",
|
AUTO: "",
|
||||||
@@ -35,7 +34,6 @@ export enum EVENT {
|
|||||||
MOUSE_MOVE = "mousemove",
|
MOUSE_MOVE = "mousemove",
|
||||||
RESIZE = "resize",
|
RESIZE = "resize",
|
||||||
UNLOAD = "unload",
|
UNLOAD = "unload",
|
||||||
FOCUS = "focus",
|
|
||||||
BLUR = "blur",
|
BLUR = "blur",
|
||||||
DRAG_OVER = "dragover",
|
DRAG_OVER = "dragover",
|
||||||
DROP = "drop",
|
DROP = "drop",
|
||||||
@@ -65,20 +63,15 @@ export const CLASSES = {
|
|||||||
|
|
||||||
// 1-based in case we ever do `if(element.fontFamily)`
|
// 1-based in case we ever do `if(element.fontFamily)`
|
||||||
export const FONT_FAMILY = {
|
export const FONT_FAMILY = {
|
||||||
Virgil: 1,
|
1: "Virgil",
|
||||||
Helvetica: 2,
|
2: "Helvetica",
|
||||||
Cascadia: 3,
|
3: "Cascadia",
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
export const THEME = {
|
|
||||||
LIGHT: "light",
|
|
||||||
DARK: "dark",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||||
|
|
||||||
export const DEFAULT_FONT_SIZE = 20;
|
export const DEFAULT_FONT_SIZE = 20;
|
||||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
|
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
|
||||||
export const DEFAULT_TEXT_ALIGN = "left";
|
export const DEFAULT_TEXT_ALIGN = "left";
|
||||||
export const DEFAULT_VERTICAL_ALIGN = "top";
|
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||||
export const DEFAULT_VERSION = "{version}";
|
export const DEFAULT_VERSION = "{version}";
|
||||||
@@ -90,7 +83,7 @@ export const GRID_SIZE = 20; // TODO make it configurable?
|
|||||||
export const MIME_TYPES = {
|
export const MIME_TYPES = {
|
||||||
excalidraw: "application/vnd.excalidraw+json",
|
excalidraw: "application/vnd.excalidraw+json",
|
||||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export const EXPORT_DATA_TYPES = {
|
export const EXPORT_DATA_TYPES = {
|
||||||
excalidraw: "excalidraw",
|
excalidraw: "excalidraw",
|
||||||
@@ -138,11 +131,11 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
|||||||
canvasActions: {
|
canvasActions: {
|
||||||
changeViewBackgroundColor: true,
|
changeViewBackgroundColor: true,
|
||||||
clearCanvas: true,
|
clearCanvas: true,
|
||||||
export: { saveFileToDisk: true },
|
export: true,
|
||||||
loadScene: true,
|
loadScene: true,
|
||||||
saveToActiveFile: true,
|
saveAsScene: true,
|
||||||
|
saveScene: true,
|
||||||
theme: true,
|
theme: true,
|
||||||
saveAsImage: true,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,6 +144,3 @@ export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
|||||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||||
|
|
||||||
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||||
|
|
||||||
export const EXPORT_SCALES = [1, 2, 3];
|
|
||||||
export const DEFAULT_EXPORT_PADDING = 10; // px
|
|
||||||
|
|||||||
+27
-24
@@ -51,12 +51,11 @@
|
|||||||
image-rendering: -moz-crisp-edges; // FF
|
image-rendering: -moz-crisp-edges; // FF
|
||||||
|
|
||||||
z-index: var(--zIndex-canvas);
|
z-index: var(--zIndex-canvas);
|
||||||
|
|
||||||
// Remove the main canvas from document flow to avoid resizeObserver
|
|
||||||
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__canvas {
|
#canvas {
|
||||||
|
// Remove the main canvas from document flow to avoid resizeObserver
|
||||||
|
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +413,22 @@
|
|||||||
&:active {
|
&:active {
|
||||||
background-color: var(--button-gray-2);
|
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 {
|
.zIndexButton {
|
||||||
@@ -440,38 +455,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.help-icon {
|
.help-icon {
|
||||||
display: flex;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
fill: $oc-gray-6;
|
fill: $oc-gray-6;
|
||||||
|
width: 1.5rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
margin-top: 5px;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--icon-fill-color);
|
color: var(--icon-fill-color);
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.reset-zoom-button {
|
:root[dir="ltr"] & {
|
||||||
padding: 0.2em;
|
margin-right: 14px;
|
||||||
background: transparent;
|
}
|
||||||
color: var(--text-primary-color);
|
|
||||||
font-family: var(--ui-font);
|
|
||||||
}
|
|
||||||
|
|
||||||
.undo-redo-buttons {
|
:root[dir="rtl"] & {
|
||||||
display: grid;
|
margin-left: 14px;
|
||||||
grid-auto-flow: column;
|
}
|
||||||
gap: 0.4em;
|
|
||||||
margin-top: auto;
|
|
||||||
margin-bottom: auto;
|
|
||||||
margin-inline-start: 0.6em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@include isMobile {
|
@include isMobile {
|
||||||
|
|||||||
+1
-24
@@ -1,12 +1,10 @@
|
|||||||
import { cleanAppStateForExport } from "../appState";
|
import { cleanAppStateForExport } from "../appState";
|
||||||
import { EXPORT_DATA_TYPES } from "../constants";
|
import { EXPORT_DATA_TYPES } from "../constants";
|
||||||
import { clearElementsForExport } from "../element";
|
import { clearElementsForExport } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { CanvasError } from "../errors";
|
import { CanvasError } from "../errors";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { FileSystemHandle } from "./filesystem";
|
|
||||||
import { isValidExcalidrawData } from "./json";
|
import { isValidExcalidrawData } from "./json";
|
||||||
import { restore } from "./restore";
|
import { restore } from "./restore";
|
||||||
import { ImportedLibraryData } from "./types";
|
import { ImportedLibraryData } from "./types";
|
||||||
@@ -81,30 +79,10 @@ export const getMimeType = (blob: Blob | string): string => {
|
|||||||
return "";
|
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 (
|
export const loadFromBlob = async (
|
||||||
blob: Blob,
|
blob: Blob,
|
||||||
/** @see restore.localAppState */
|
/** @see restore.localAppState */
|
||||||
localAppState: AppState | null,
|
localAppState: AppState | null,
|
||||||
localElements: readonly ExcalidrawElement[] | null,
|
|
||||||
) => {
|
) => {
|
||||||
const contents = await parseFileContents(blob);
|
const contents = await parseFileContents(blob);
|
||||||
try {
|
try {
|
||||||
@@ -117,7 +95,7 @@ export const loadFromBlob = async (
|
|||||||
elements: clearElementsForExport(data.elements || []),
|
elements: clearElementsForExport(data.elements || []),
|
||||||
appState: {
|
appState: {
|
||||||
theme: localAppState?.theme,
|
theme: localAppState?.theme,
|
||||||
fileHandle: blob.handle || null,
|
fileHandle: (!blob.type.startsWith("image/") && blob.handle) || null,
|
||||||
...cleanAppStateForExport(data.appState || {}),
|
...cleanAppStateForExport(data.appState || {}),
|
||||||
...(localAppState
|
...(localAppState
|
||||||
? calculateScrollCenter(data.elements || [], localAppState, null)
|
? calculateScrollCenter(data.elements || [], localAppState, null)
|
||||||
@@ -125,7 +103,6 @@ export const loadFromBlob = async (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
localAppState,
|
localAppState,
|
||||||
localElements,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
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 };
|
|
||||||
+29
-21
@@ -1,15 +1,14 @@
|
|||||||
|
import { fileSave } from "browser-fs-access";
|
||||||
import {
|
import {
|
||||||
copyBlobToClipboardAsPng,
|
copyBlobToClipboardAsPng,
|
||||||
copyTextToSystemClipboard,
|
copyTextToSystemClipboard,
|
||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { canvasToBlob } from "./blob";
|
import { canvasToBlob } from "./blob";
|
||||||
import { fileSave, FileSystemHandle } from "./filesystem";
|
|
||||||
import { serializeAsJSON } from "./json";
|
import { serializeAsJSON } from "./json";
|
||||||
|
|
||||||
export { loadFromBlob } from "./blob";
|
export { loadFromBlob } from "./blob";
|
||||||
@@ -21,41 +20,48 @@ export const exportCanvas = async (
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
{
|
{
|
||||||
exportBackground,
|
exportBackground,
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = 10,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
name,
|
name,
|
||||||
fileHandle = null,
|
scale = 1,
|
||||||
|
shouldAddWatermark,
|
||||||
}: {
|
}: {
|
||||||
exportBackground: boolean;
|
exportBackground: boolean;
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
viewBackgroundColor: string;
|
viewBackgroundColor: string;
|
||||||
name: string;
|
name: string;
|
||||||
fileHandle?: FileSystemHandle | null;
|
scale?: number;
|
||||||
|
shouldAddWatermark: boolean;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
if (elements.length === 0) {
|
if (elements.length === 0) {
|
||||||
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||||
}
|
}
|
||||||
if (type === "svg" || type === "clipboard-svg") {
|
if (type === "svg" || type === "clipboard-svg") {
|
||||||
const tempSvg = await exportToSvg(elements, {
|
const tempSvg = exportToSvg(elements, {
|
||||||
exportBackground,
|
exportBackground,
|
||||||
exportWithDarkMode: appState.exportWithDarkMode,
|
exportWithDarkMode: appState.exportWithDarkMode,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
exportPadding,
|
exportPadding,
|
||||||
exportScale: appState.exportScale,
|
scale,
|
||||||
exportEmbedScene: appState.exportEmbedScene && type === "svg",
|
shouldAddWatermark,
|
||||||
|
metadata:
|
||||||
|
appState.exportEmbedScene && type === "svg"
|
||||||
|
? await (
|
||||||
|
await import(/* webpackChunkName: "image" */ "./image")
|
||||||
|
).encodeSvgMetadata({
|
||||||
|
text: serializeAsJSON(elements, appState),
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
if (type === "svg") {
|
if (type === "svg") {
|
||||||
return await fileSave(
|
await fileSave(new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }), {
|
||||||
new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }),
|
fileName: `${name}.svg`,
|
||||||
{
|
extensions: [".svg"],
|
||||||
name,
|
});
|
||||||
extension: "svg",
|
return;
|
||||||
fileHandle,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else if (type === "clipboard-svg") {
|
} else if (type === "clipboard-svg") {
|
||||||
await copyTextToSystemClipboard(tempSvg.outerHTML);
|
copyTextToSystemClipboard(tempSvg.outerHTML);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,6 +70,8 @@ export const exportCanvas = async (
|
|||||||
exportBackground,
|
exportBackground,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
exportPadding,
|
exportPadding,
|
||||||
|
scale,
|
||||||
|
shouldAddWatermark,
|
||||||
});
|
});
|
||||||
tempCanvas.style.display = "none";
|
tempCanvas.style.display = "none";
|
||||||
document.body.appendChild(tempCanvas);
|
document.body.appendChild(tempCanvas);
|
||||||
@@ -71,6 +79,7 @@ export const exportCanvas = async (
|
|||||||
tempCanvas.remove();
|
tempCanvas.remove();
|
||||||
|
|
||||||
if (type === "png") {
|
if (type === "png") {
|
||||||
|
const fileName = `${name}.png`;
|
||||||
if (appState.exportEmbedScene) {
|
if (appState.exportEmbedScene) {
|
||||||
blob = await (
|
blob = await (
|
||||||
await import(/* webpackChunkName: "image" */ "./image")
|
await import(/* webpackChunkName: "image" */ "./image")
|
||||||
@@ -80,10 +89,9 @@ export const exportCanvas = async (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return await fileSave(blob, {
|
await fileSave(blob, {
|
||||||
name,
|
fileName,
|
||||||
extension: "png",
|
extensions: [".png"],
|
||||||
fileHandle,
|
|
||||||
});
|
});
|
||||||
} else if (type === "clipboard") {
|
} else if (type === "clipboard") {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+23
-26
@@ -1,10 +1,10 @@
|
|||||||
import { fileOpen, fileSave } from "./filesystem";
|
import { fileOpen, fileSave } from "browser-fs-access";
|
||||||
import { cleanAppStateForExport } from "../appState";
|
import { cleanAppStateForExport } from "../appState";
|
||||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
|
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
|
||||||
import { clearElementsForExport } from "../element";
|
import { clearElementsForExport } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isImageFileHandle, loadFromBlob } from "./blob";
|
import { loadFromBlob } from "./blob";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ExportedDataState,
|
ExportedDataState,
|
||||||
@@ -15,7 +15,7 @@ import Library from "./library";
|
|||||||
|
|
||||||
export const serializeAsJSON = (
|
export const serializeAsJSON = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: Partial<AppState>,
|
appState: AppState,
|
||||||
): string => {
|
): string => {
|
||||||
const data: ExportedDataState = {
|
const data: ExportedDataState = {
|
||||||
type: EXPORT_DATA_TYPES.excalidraw,
|
type: EXPORT_DATA_TYPES.excalidraw,
|
||||||
@@ -37,21 +37,19 @@ export const saveAsJSON = async (
|
|||||||
type: MIME_TYPES.excalidraw,
|
type: MIME_TYPES.excalidraw,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileHandle = await fileSave(blob, {
|
const fileHandle = await fileSave(
|
||||||
name: appState.name,
|
blob,
|
||||||
extension: "excalidraw",
|
{
|
||||||
description: "Excalidraw file",
|
fileName: `${appState.name}.excalidraw`,
|
||||||
fileHandle: isImageFileHandle(appState.fileHandle)
|
description: "Excalidraw file",
|
||||||
? null
|
extensions: [".excalidraw"],
|
||||||
: appState.fileHandle,
|
},
|
||||||
});
|
appState.fileHandle,
|
||||||
|
);
|
||||||
return { fileHandle };
|
return { fileHandle };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFromJSON = async (
|
export const loadFromJSON = async (localAppState: AppState) => {
|
||||||
localAppState: AppState,
|
|
||||||
localElements: readonly ExcalidrawElement[] | null,
|
|
||||||
) => {
|
|
||||||
const blob = await fileOpen({
|
const blob = await fileOpen({
|
||||||
description: "Excalidraw files",
|
description: "Excalidraw files",
|
||||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||||
@@ -66,7 +64,7 @@ export const loadFromJSON = async (
|
|||||||
],
|
],
|
||||||
*/
|
*/
|
||||||
});
|
});
|
||||||
return loadFromBlob(blob, localAppState, localElements);
|
return loadFromBlob(blob, localAppState);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isValidExcalidrawData = (data?: {
|
export const isValidExcalidrawData = (data?: {
|
||||||
@@ -100,16 +98,15 @@ export const saveLibraryAsJSON = async (library: Library) => {
|
|||||||
library: libraryItems,
|
library: libraryItems,
|
||||||
};
|
};
|
||||||
const serialized = JSON.stringify(data, null, 2);
|
const serialized = JSON.stringify(data, null, 2);
|
||||||
await fileSave(
|
const fileName = "library.excalidrawlib";
|
||||||
new Blob([serialized], {
|
const blob = new Blob([serialized], {
|
||||||
type: MIME_TYPES.excalidrawlib,
|
type: MIME_TYPES.excalidrawlib,
|
||||||
}),
|
});
|
||||||
{
|
await fileSave(blob, {
|
||||||
name: "library",
|
fileName,
|
||||||
extension: "excalidrawlib",
|
description: "Excalidraw library file",
|
||||||
description: "Excalidraw library file",
|
extensions: [".excalidrawlib"],
|
||||||
},
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const importLibraryFromJSON = async (library: Library) => {
|
export const importLibraryFromJSON = async (library: Library) => {
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@ import { loadLibraryFromBlob } from "./blob";
|
|||||||
import { LibraryItems, LibraryItem } from "../types";
|
import { LibraryItems, LibraryItem } from "../types";
|
||||||
import { restoreElements } from "./restore";
|
import { restoreElements } from "./restore";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import type App from "../components/App";
|
import App from "../components/App";
|
||||||
|
|
||||||
class Library {
|
class Library {
|
||||||
private libraryCache: LibraryItems | null = null;
|
private libraryCache: LibraryItems | null = null;
|
||||||
@@ -18,7 +18,7 @@ class Library {
|
|||||||
};
|
};
|
||||||
|
|
||||||
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
|
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
|
||||||
const elements = getNonDeletedElements(restoreElements(libraryItem, null));
|
const elements = getNonDeletedElements(restoreElements(libraryItem));
|
||||||
return elements.length ? elements : null;
|
return elements.length ? elements : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
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 };
|
|
||||||
};
|
|
||||||
+16
-29
@@ -1,26 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
FontFamily,
|
||||||
ExcalidrawSelectionElement,
|
ExcalidrawSelectionElement,
|
||||||
FontFamilyValues,
|
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { AppState, NormalizedZoomValue } from "../types";
|
import { AppState, NormalizedZoomValue } from "../types";
|
||||||
import { ImportedDataState } from "./types";
|
import { ImportedDataState } from "./types";
|
||||||
import {
|
import { isInvisiblySmallElement, getNormalizedDimensions } from "../element";
|
||||||
getElementMap,
|
|
||||||
getNormalizedDimensions,
|
|
||||||
isInvisiblySmallElement,
|
|
||||||
} from "../element";
|
|
||||||
import { isLinearElementType } from "../element/typeChecks";
|
import { isLinearElementType } from "../element/typeChecks";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import {
|
import {
|
||||||
|
FONT_FAMILY,
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
DEFAULT_VERTICAL_ALIGN,
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
FONT_FAMILY,
|
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { bumpVersion } from "../element/mutateElement";
|
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
AppState,
|
AppState,
|
||||||
@@ -46,11 +41,11 @@ export type RestoredDataState = {
|
|||||||
appState: RestoredAppState;
|
appState: RestoredAppState;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
|
||||||
if (Object.keys(FONT_FAMILY).includes(fontFamilyName)) {
|
for (const [id, fontFamilyString] of Object.entries(FONT_FAMILY)) {
|
||||||
return FONT_FAMILY[
|
if (fontFamilyString.includes(fontFamilyName)) {
|
||||||
fontFamilyName as keyof typeof FONT_FAMILY
|
return parseInt(id) as FontFamily;
|
||||||
] as FontFamilyValues;
|
}
|
||||||
}
|
}
|
||||||
return DEFAULT_FONT_FAMILY;
|
return DEFAULT_FONT_FAMILY;
|
||||||
};
|
};
|
||||||
@@ -186,20 +181,13 @@ const restoreElement = (
|
|||||||
|
|
||||||
export const restoreElements = (
|
export const restoreElements = (
|
||||||
elements: ImportedDataState["elements"],
|
elements: ImportedDataState["elements"],
|
||||||
/** NOTE doesn't serve for reconciliation */
|
|
||||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const localElementsMap = localElements ? getElementMap(localElements) : null;
|
|
||||||
return (elements || []).reduce((elements, element) => {
|
return (elements || []).reduce((elements, element) => {
|
||||||
// filtering out selection, which is legacy, no longer kept in elements,
|
// filtering out selection, which is legacy, no longer kept in elements,
|
||||||
// and causing issues if retained
|
// and causing issues if retained
|
||||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||||
let migratedElement: ExcalidrawElement = restoreElement(element);
|
const migratedElement = restoreElement(element);
|
||||||
if (migratedElement) {
|
if (migratedElement) {
|
||||||
const localElement = localElementsMap?.[element.id];
|
|
||||||
if (localElement && localElement.version > migratedElement.version) {
|
|
||||||
migratedElement = bumpVersion(migratedElement, localElement.version);
|
|
||||||
}
|
|
||||||
elements.push(migratedElement);
|
elements.push(migratedElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,25 +197,25 @@ export const restoreElements = (
|
|||||||
|
|
||||||
export const restoreAppState = (
|
export const restoreAppState = (
|
||||||
appState: ImportedDataState["appState"],
|
appState: ImportedDataState["appState"],
|
||||||
localAppState: Partial<AppState> | null | undefined,
|
localAppState: Partial<AppState> | null,
|
||||||
): RestoredAppState => {
|
): RestoredAppState => {
|
||||||
appState = appState || {};
|
appState = appState || {};
|
||||||
|
|
||||||
const defaultAppState = getDefaultAppState();
|
const defaultAppState = getDefaultAppState();
|
||||||
const nextAppState = {} as typeof defaultAppState;
|
const nextAppState = {} as typeof defaultAppState;
|
||||||
|
|
||||||
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
|
for (const [key, val] of Object.entries(defaultAppState) as [
|
||||||
keyof typeof defaultAppState,
|
keyof typeof defaultAppState,
|
||||||
any,
|
any,
|
||||||
][]) {
|
][]) {
|
||||||
const suppliedValue = appState[key];
|
const restoredValue = appState[key];
|
||||||
const localValue = localAppState ? localAppState[key] : undefined;
|
const localValue = localAppState ? localAppState[key] : undefined;
|
||||||
(nextAppState as any)[key] =
|
(nextAppState as any)[key] =
|
||||||
suppliedValue !== undefined
|
restoredValue !== undefined
|
||||||
? suppliedValue
|
? restoredValue
|
||||||
: localValue !== undefined
|
: localValue !== undefined
|
||||||
? localValue
|
? localValue
|
||||||
: defaultValue;
|
: val;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -255,10 +243,9 @@ export const restore = (
|
|||||||
* Supply `null` if you can't get access to it.
|
* Supply `null` if you can't get access to it.
|
||||||
*/
|
*/
|
||||||
localAppState: Partial<AppState> | null | undefined,
|
localAppState: Partial<AppState> | null | undefined,
|
||||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
|
||||||
): RestoredDataState => {
|
): RestoredDataState => {
|
||||||
return {
|
return {
|
||||||
elements: restoreElements(data?.elements, localElements),
|
elements: restoreElements(data?.elements),
|
||||||
appState: restoreAppState(data?.appState, localAppState || null),
|
appState: restoreAppState(data?.appState, localAppState || null),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -328,15 +328,15 @@ const hitTestFreeDrawElement = (
|
|||||||
let P: readonly [number, number];
|
let P: readonly [number, number];
|
||||||
|
|
||||||
// For freedraw dots
|
// For freedraw dots
|
||||||
if (
|
if (element.points.length === 2) {
|
||||||
distance2d(A[0], A[1], x, y) < threshold ||
|
return (
|
||||||
distance2d(B[0], B[1], x, y) < threshold
|
distance2d(A[0], A[1], x, y) < threshold ||
|
||||||
) {
|
distance2d(B[0], B[1], x, y) < threshold
|
||||||
return true;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For freedraw lines
|
// For freedraw lines
|
||||||
for (let i = 0; i < element.points.length; i++) {
|
for (let i = 1; i < element.points.length - 1; i++) {
|
||||||
const delta = [B[0] - A[0], B[1] - A[1]];
|
const delta = [B[0] - A[0], B[1] - A[1]];
|
||||||
const length = Math.hypot(delta[1], delta[0]);
|
const length = Math.hypot(delta[1], delta[0]);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { mutateElement } from "./mutateElement";
|
|||||||
import { getPerfectElementSize } from "./sizeHelpers";
|
import { getPerfectElementSize } from "./sizeHelpers";
|
||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import { NonDeletedExcalidrawElement } from "./types";
|
import { NonDeletedExcalidrawElement } from "./types";
|
||||||
import { PointerDownState } from "../types";
|
import { PointerDownState } from "../components/App";
|
||||||
|
|
||||||
export const dragSelectedElements = (
|
export const dragSelectedElements = (
|
||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
|
|||||||
@@ -120,11 +120,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
|||||||
*
|
*
|
||||||
* NOTE: does not trigger re-render.
|
* NOTE: does not trigger re-render.
|
||||||
*/
|
*/
|
||||||
export const bumpVersion = (
|
export const bumpVersion = (element: Mutable<ExcalidrawElement>) => {
|
||||||
element: Mutable<ExcalidrawElement>,
|
element.version = element.version + 1;
|
||||||
version?: ExcalidrawElement["version"],
|
|
||||||
) => {
|
|
||||||
element.version = (version ?? element.version) + 1;
|
|
||||||
element.versionNonce = randomInteger();
|
element.versionNonce = randomInteger();
|
||||||
return element;
|
return element;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { duplicateElement } from "./newElement";
|
import { duplicateElement } from "./newElement";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { API } from "../tests/helpers/api";
|
import { API } from "../tests/helpers/api";
|
||||||
import { FONT_FAMILY } from "../constants";
|
|
||||||
|
|
||||||
const isPrimitive = (val: any) => {
|
const isPrimitive = (val: any) => {
|
||||||
const type = typeof val;
|
const type = typeof val;
|
||||||
@@ -80,7 +79,7 @@ it("clones text element", () => {
|
|||||||
opacity: 100,
|
opacity: 100,
|
||||||
text: "hello",
|
text: "hello",
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontFamily: FONT_FAMILY.Virgil,
|
fontFamily: 1,
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
verticalAlign: "top",
|
verticalAlign: "top",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import {
|
|||||||
ExcalidrawGenericElement,
|
ExcalidrawGenericElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
TextAlign,
|
TextAlign,
|
||||||
|
FontFamily,
|
||||||
GroupId,
|
GroupId,
|
||||||
VerticalAlign,
|
VerticalAlign,
|
||||||
Arrowhead,
|
Arrowhead,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
FontFamilyValues,
|
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { measureText, getFontString } from "../utils";
|
import { measureText, getFontString } from "../utils";
|
||||||
import { randomInteger, randomId } from "../random";
|
import { randomInteger, randomId } from "../random";
|
||||||
@@ -109,7 +109,7 @@ export const newTextElement = (
|
|||||||
opts: {
|
opts: {
|
||||||
text: string;
|
text: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
fontFamily: FontFamilyValues;
|
fontFamily: FontFamily;
|
||||||
textAlign: TextAlign;
|
textAlign: TextAlign;
|
||||||
verticalAlign: VerticalAlign;
|
verticalAlign: VerticalAlign;
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
@@ -307,19 +307,7 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
overrides?: Partial<TElement>,
|
overrides?: Partial<TElement>,
|
||||||
): TElement => {
|
): TElement => {
|
||||||
let copy: TElement = deepCopyElement(element);
|
let copy: TElement = deepCopyElement(element);
|
||||||
if (process.env.NODE_ENV === "test") {
|
copy.id = process.env.NODE_ENV === "test" ? `${copy.id}_copy` : randomId();
|
||||||
copy.id = `${copy.id}_copy`;
|
|
||||||
// `window.h` may not be defined in some unit tests
|
|
||||||
if (
|
|
||||||
window.h?.app
|
|
||||||
?.getSceneElementsIncludingDeleted()
|
|
||||||
.find((el) => el.id === copy.id)
|
|
||||||
) {
|
|
||||||
copy.id += "_copy";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
copy.id = randomId();
|
|
||||||
}
|
|
||||||
copy.seed = randomInteger();
|
copy.seed = randomInteger();
|
||||||
copy.groupIds = getNewGroupIdsForDuplication(
|
copy.groupIds = getNewGroupIdsForDuplication(
|
||||||
copy.groupIds,
|
copy.groupIds,
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ import {
|
|||||||
MaybeTransformHandleType,
|
MaybeTransformHandleType,
|
||||||
TransformHandleDirection,
|
TransformHandleDirection,
|
||||||
} from "./transformHandles";
|
} from "./transformHandles";
|
||||||
import { Point, PointerDownState } from "../types";
|
import { PointerDownState } from "../components/App";
|
||||||
|
import { Point } from "../types";
|
||||||
|
|
||||||
export const normalizeAngle = (angle: number): number => {
|
export const normalizeAngle = (angle: number): number => {
|
||||||
if (angle >= 2 * Math.PI) {
|
if (angle >= 2 * Math.PI) {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Point } from "../types";
|
import { Point } from "../types";
|
||||||
import { FONT_FAMILY, THEME } from "../constants";
|
import { FONT_FAMILY } from "../constants";
|
||||||
|
|
||||||
export type ChartType = "bar" | "line";
|
export type ChartType = "bar" | "line";
|
||||||
export type FillStyle = "hachure" | "cross-hatch" | "solid";
|
export type FillStyle = "hachure" | "cross-hatch" | "solid";
|
||||||
export type FontFamilyKeys = keyof typeof FONT_FAMILY;
|
export type FontFamily = 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 FontString = string & { _brand: "fontString" };
|
||||||
export type GroupId = string;
|
export type GroupId = string;
|
||||||
export type PointerType = "mouse" | "pen" | "touch";
|
export type PointerType = "mouse" | "pen" | "touch";
|
||||||
@@ -93,7 +91,7 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
|||||||
Readonly<{
|
Readonly<{
|
||||||
type: "text";
|
type: "text";
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
fontFamily: FontFamilyValues;
|
fontFamily: FontFamily;
|
||||||
text: string;
|
text: string;
|
||||||
baseline: number;
|
baseline: number;
|
||||||
textAlign: TextAlign;
|
textAlign: TextAlign;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
type CANVAS_ERROR_NAMES = "CANVAS_ERROR" | "CANVAS_POSSIBLY_TOO_BIG";
|
type CANVAS_ERROR_NAMES = "CANVAS_ERROR" | "CANVAS_POSSIBLY_TOO_BIG";
|
||||||
|
|
||||||
export class CanvasError extends Error {
|
export class CanvasError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string = "Couldn't export canvas.",
|
message: string = "Couldn't export canvas.",
|
||||||
@@ -10,9 +9,3 @@ export class CanvasError extends Error {
|
|||||||
this.message = message;
|
this.message = message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AbortError extends DOMException {
|
|
||||||
constructor(message: string = "Request Aborted") {
|
|
||||||
super(message, "AbortError");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { debounce, getVersion, nFormatter } from "../utils";
|
import { debounce, getVersion, nFormatter } from "../utils";
|
||||||
import {
|
import {
|
||||||
getElementsStorageSize,
|
getElementsStorageSize,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
import { PureComponent } from "react";
|
import React, { PureComponent } from "react";
|
||||||
import { ExcalidrawImperativeAPI } from "../../types";
|
import { ExcalidrawImperativeAPI } from "../../components/App";
|
||||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||||
import { APP_NAME, ENV, EVENT } from "../../constants";
|
import { APP_NAME, ENV, EVENT } from "../../constants";
|
||||||
import { ImportedDataState } from "../../data/types";
|
import { ImportedDataState } from "../../data/types";
|
||||||
@@ -41,7 +41,6 @@ import { UserIdleState } from "../../types";
|
|||||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
||||||
import { trackEvent } from "../../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
import { isInvisiblySmallElement } from "../../element";
|
import { isInvisiblySmallElement } from "../../element";
|
||||||
import { getRandomUsername } from "@excalidraw/random-username";
|
|
||||||
|
|
||||||
interface CollabState {
|
interface CollabState {
|
||||||
modalIsShown: boolean;
|
modalIsShown: boolean;
|
||||||
@@ -224,10 +223,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.state.username) {
|
|
||||||
this.updateUsername(getRandomUsername());
|
|
||||||
}
|
|
||||||
|
|
||||||
let roomId;
|
let roomId;
|
||||||
let roomKey;
|
let roomKey;
|
||||||
|
|
||||||
@@ -598,7 +593,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
this.setState({ modalIsShown: false });
|
this.setState({ modalIsShown: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
updateUsername = (username: string) => {
|
onUsernameChange = (username: string) => {
|
||||||
this.setState({ username });
|
this.setState({ username });
|
||||||
saveUsernameToLocalStorage(username);
|
saveUsernameToLocalStorage(username);
|
||||||
};
|
};
|
||||||
@@ -640,7 +635,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
handleClose={this.handleClose}
|
handleClose={this.handleClose}
|
||||||
activeRoomLink={activeRoomLink}
|
activeRoomLink={activeRoomLink}
|
||||||
username={username}
|
username={username}
|
||||||
onUsernameChange={this.updateUsername}
|
onUsernameChange={this.onUsernameChange}
|
||||||
onRoomCreate={this.openPortal}
|
onRoomCreate={this.openPortal}
|
||||||
onRoomDestroy={this.closePortal}
|
onRoomDestroy={this.closePortal}
|
||||||
setErrorMessage={(errorMessage) => {
|
setErrorMessage={(errorMessage) => {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { t } from "../../i18n";
|
|||||||
import "./RoomDialog.scss";
|
import "./RoomDialog.scss";
|
||||||
import Stack from "../../components/Stack";
|
import Stack from "../../components/Stack";
|
||||||
import { AppState } from "../../types";
|
import { AppState } from "../../types";
|
||||||
import { getRandomUsername } from "@excalidraw/random-username";
|
|
||||||
|
|
||||||
const getShareIcon = () => {
|
const getShareIcon = () => {
|
||||||
const navigator = window.navigator as any;
|
const navigator = window.navigator as any;
|
||||||
@@ -138,14 +137,9 @@ const RoomDialog = ({
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
value={username}
|
value={username || ""}
|
||||||
className="RoomDialog-username TextInput"
|
className="RoomDialog-username TextInput"
|
||||||
onChange={(event) => onUsernameChange(event.target.value)}
|
onChange={(event) => onUsernameChange(event.target.value)}
|
||||||
onBlur={(event) => {
|
|
||||||
if (!event.target.value.trim()) {
|
|
||||||
onUsernameChange(getRandomUsername());
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyPress={(event) => event.key === "Enter" && handleClose()}
|
onKeyPress={(event) => event.key === "Enter" && handleClose()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
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,11 +1,9 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { THEME } from "../../constants";
|
|
||||||
import { Theme } from "../../element/types";
|
|
||||||
|
|
||||||
// https://github.com/tholman/github-corners
|
// https://github.com/tholman/github-corners
|
||||||
export const GitHubCorner = React.memo(
|
export const GitHubCorner = React.memo(
|
||||||
({ theme, dir }: { theme: Theme; dir: string }) => (
|
({ theme, dir }: { theme: "light" | "dark"; dir: string }) => (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="40"
|
width="40"
|
||||||
@@ -27,18 +25,18 @@ export const GitHubCorner = React.memo(
|
|||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||||
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
|
fill={theme === "light" ? oc.gray[6] : oc.gray[7]}
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
className="octo-arm"
|
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"
|
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" }}
|
style={{ transformOrigin: "130px 106px" }}
|
||||||
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
fill={theme === "light" ? oc.white : "var(--default-bg-color)"}
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
className="octo-body"
|
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"
|
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
|
||||||
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
fill={theme === "light" ? oc.white : "var(--default-bg-color)"}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
import * as i18n from "../../i18n";
|
import * as i18n from "../../i18n";
|
||||||
|
|
||||||
export const LanguageList = ({
|
export const LanguageList = ({
|
||||||
onChange,
|
onChange,
|
||||||
languages = i18n.languages,
|
languages = i18n.languages,
|
||||||
currentLangCode = i18n.getLanguage().code,
|
currentLangCode = i18n.getLanguage().code,
|
||||||
|
floating,
|
||||||
}: {
|
}: {
|
||||||
languages?: { code: string; label: string }[];
|
languages?: { code: string; label: string }[];
|
||||||
onChange: (langCode: i18n.Language["code"]) => void;
|
onChange: (langCode: i18n.Language["code"]) => void;
|
||||||
currentLangCode?: i18n.Language["code"];
|
currentLangCode?: i18n.Language["code"];
|
||||||
|
floating?: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<select
|
<select
|
||||||
className="dropdown-select dropdown-select__language"
|
className={clsx("dropdown-select dropdown-select__language", {
|
||||||
|
"dropdown-select--floating": floating,
|
||||||
|
})}
|
||||||
onChange={({ target }) => onChange(target.value)}
|
onChange={({ target }) => onChange(target.value)}
|
||||||
value={currentLangCode}
|
value={currentLangCode}
|
||||||
aria-label={i18n.t("buttons.selectLanguage")}
|
aria-label={i18n.t("buttons.selectLanguage")}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -5,19 +5,15 @@ import { getSceneVersion } from "../../element";
|
|||||||
import Portal from "../collab/Portal";
|
import Portal from "../collab/Portal";
|
||||||
import { restoreElements } from "../../data/restore";
|
import { restoreElements } from "../../data/restore";
|
||||||
|
|
||||||
// private
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
let firebasePromise: Promise<
|
let firebasePromise: Promise<
|
||||||
typeof import("firebase/app").default
|
typeof import("firebase/app").default
|
||||||
> | null = null;
|
> | null = null;
|
||||||
let firestorePromise: Promise<any> | null = null;
|
|
||||||
let firebseStoragePromise: Promise<any> | null = null;
|
|
||||||
|
|
||||||
const _loadFirebase = async () => {
|
const loadFirebase = async () => {
|
||||||
const firebase = (
|
const firebase = (
|
||||||
await import(/* webpackChunkName: "firebase" */ "firebase/app")
|
await import(/* webpackChunkName: "firebase" */ "firebase/app")
|
||||||
).default;
|
).default;
|
||||||
|
await import(/* webpackChunkName: "firestore" */ "firebase/firestore");
|
||||||
|
|
||||||
const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
|
const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
|
||||||
firebase.initializeApp(firebaseConfig);
|
firebase.initializeApp(firebaseConfig);
|
||||||
@@ -25,37 +21,13 @@ const _loadFirebase = async () => {
|
|||||||
return firebase;
|
return firebase;
|
||||||
};
|
};
|
||||||
|
|
||||||
const _getFirebase = async (): Promise<
|
const getFirebase = async (): Promise<
|
||||||
typeof import("firebase/app").default
|
typeof import("firebase/app").default
|
||||||
> => {
|
> => {
|
||||||
if (!firebasePromise) {
|
if (!firebasePromise) {
|
||||||
firebasePromise = _loadFirebase();
|
firebasePromise = loadFirebase();
|
||||||
}
|
}
|
||||||
return firebasePromise;
|
return await 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 {
|
interface FirebaseStoredScene {
|
||||||
@@ -136,7 +108,7 @@ export const saveToFirebase = async (
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firebase = await loadFirestore();
|
const firebase = await getFirebase();
|
||||||
const sceneVersion = getSceneVersion(elements);
|
const sceneVersion = getSceneVersion(elements);
|
||||||
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
||||||
|
|
||||||
@@ -178,7 +150,7 @@ export const loadFromFirebase = async (
|
|||||||
roomKey: string,
|
roomKey: string,
|
||||||
socket: SocketIOClient.Socket | null,
|
socket: SocketIOClient.Socket | null,
|
||||||
): Promise<readonly ExcalidrawElement[] | null> => {
|
): Promise<readonly ExcalidrawElement[] | null> => {
|
||||||
const firebase = await loadFirestore();
|
const firebase = await getFirebase();
|
||||||
const db = firebase.firestore();
|
const db = firebase.firestore();
|
||||||
|
|
||||||
const docRef = db.collection("scenes").doc(roomId);
|
const docRef = db.collection("scenes").doc(roomId);
|
||||||
@@ -196,5 +168,5 @@ export const loadFromFirebase = async (
|
|||||||
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
|
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
|
||||||
}
|
}
|
||||||
|
|
||||||
return restoreElements(elements, null);
|
return restoreElements(elements);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const generateRandomID = async () => {
|
|||||||
return Array.from(arr, byteToHex).join("");
|
return Array.from(arr, byteToHex).join("");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateEncryptionKey = async () => {
|
const generateEncryptionKey = async () => {
|
||||||
const key = await window.crypto.subtle.generateKey(
|
const key = await window.crypto.subtle.generateKey(
|
||||||
{
|
{
|
||||||
name: "AES-GCM",
|
name: "AES-GCM",
|
||||||
@@ -137,10 +137,6 @@ export const decryptAESGEM = async (
|
|||||||
export const getCollaborationLinkData = (link: string) => {
|
export const getCollaborationLinkData = (link: string) => {
|
||||||
const hash = new URL(link).hash;
|
const hash = new URL(link).hash;
|
||||||
const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
|
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;
|
return match ? { roomId: match[1], roomKey: match[2] } : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -180,7 +176,7 @@ export const getImportedKey = (key: string, usage: KeyUsage) =>
|
|||||||
[usage],
|
[usage],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const decryptImported = async (
|
const decryptImported = async (
|
||||||
iv: ArrayBuffer,
|
iv: ArrayBuffer,
|
||||||
encrypted: ArrayBuffer,
|
encrypted: ArrayBuffer,
|
||||||
privateKey: string,
|
privateKey: string,
|
||||||
@@ -261,10 +257,9 @@ export const loadScene = async (
|
|||||||
data = restore(
|
data = restore(
|
||||||
await importFromBackend(id, privateKey),
|
await importFromBackend(id, privateKey),
|
||||||
localDataState?.appState,
|
localDataState?.appState,
|
||||||
localDataState?.elements,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
data = restore(localDataState || null, null, null);
|
data = restore(localDataState || null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,18 +2,12 @@
|
|||||||
.layer-ui__wrapper__footer-center {
|
.layer-ui__wrapper__footer-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: auto;
|
|
||||||
margin-bottom: auto;
|
|
||||||
margin-inline-start: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.encrypted-icon {
|
.encrypted-icon {
|
||||||
border-radius: var(--space-factor);
|
border-radius: var(--space-factor);
|
||||||
color: var(--icon-green-fill-color);
|
color: var(--icon-green-fill-color);
|
||||||
margin-top: auto;
|
margin-top: 13px;
|
||||||
margin-bottom: auto;
|
|
||||||
margin-inline-start: auto;
|
|
||||||
margin-inline-end: 0.6em;
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 1.2rem;
|
width: 1.2rem;
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
|
import { ExcalidrawImperativeAPI } from "../components/App";
|
||||||
import { ErrorDialog } from "../components/ErrorDialog";
|
import { ErrorDialog } from "../components/ErrorDialog";
|
||||||
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +31,7 @@ import Excalidraw, {
|
|||||||
defaultLang,
|
defaultLang,
|
||||||
languages,
|
languages,
|
||||||
} from "../packages/excalidraw/index";
|
} from "../packages/excalidraw/index";
|
||||||
import { AppState, LibraryItems, ExcalidrawImperativeAPI } from "../types";
|
import { AppState, LibraryItems } from "../types";
|
||||||
import {
|
import {
|
||||||
debounce,
|
debounce,
|
||||||
getVersion,
|
getVersion,
|
||||||
@@ -49,7 +56,6 @@ import { Tooltip } from "../components/Tooltip";
|
|||||||
import { shield } from "../components/icons";
|
import { shield } from "../components/icons";
|
||||||
|
|
||||||
import "./index.scss";
|
import "./index.scss";
|
||||||
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
|
|
||||||
|
|
||||||
const languageDetector = new LanguageDetector();
|
const languageDetector = new LanguageDetector();
|
||||||
languageDetector.init({
|
languageDetector.init({
|
||||||
@@ -135,7 +141,7 @@ const initializeScene = async (opts: {
|
|||||||
const url = externalUrlMatch[1];
|
const url = externalUrlMatch[1];
|
||||||
try {
|
try {
|
||||||
const request = await fetch(window.decodeURIComponent(url));
|
const request = await fetch(window.decodeURIComponent(url));
|
||||||
const data = await loadFromBlob(await request.blob(), null, null);
|
const data = await loadFromBlob(await request.blob(), null);
|
||||||
if (
|
if (
|
||||||
!scene.elements.length ||
|
!scene.elements.length ||
|
||||||
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
||||||
@@ -342,8 +348,11 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
const renderLanguageList = () => (
|
const renderLanguageList = () => (
|
||||||
<LanguageList
|
<LanguageList
|
||||||
onChange={(langCode) => setLangCode(langCode)}
|
onChange={(langCode) => {
|
||||||
|
setLangCode(langCode);
|
||||||
|
}}
|
||||||
languages={languages}
|
languages={languages}
|
||||||
|
floating={!isMobile}
|
||||||
currentLangCode={langCode}
|
currentLangCode={langCode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -415,28 +424,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
onCollabButtonClick={collabAPI?.onCollabButtonClick}
|
onCollabButtonClick={collabAPI?.onCollabButtonClick}
|
||||||
isCollaborating={collabAPI?.isCollaborating()}
|
isCollaborating={collabAPI?.isCollaborating()}
|
||||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||||
UIOptions={{
|
onExportToBackend={onExportToBackend}
|
||||||
canvasActions: {
|
|
||||||
export: {
|
|
||||||
onExportToBackend,
|
|
||||||
renderCustomUI: (elements, appState) => {
|
|
||||||
return (
|
|
||||||
<ExportToExcalidrawPlus
|
|
||||||
elements={elements}
|
|
||||||
appState={appState}
|
|
||||||
onError={(error) => {
|
|
||||||
excalidrawAPI?.updateScene({
|
|
||||||
appState: {
|
|
||||||
errorMessage: error.message,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
renderTopRightUI={renderTopRightUI}
|
renderTopRightUI={renderTopRightUI}
|
||||||
renderFooter={renderFooter}
|
renderFooter={renderFooter}
|
||||||
langCode={langCode}
|
langCode={langCode}
|
||||||
@@ -444,7 +432,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
detectScroll={false}
|
detectScroll={false}
|
||||||
handleKeyboardGlobally={true}
|
handleKeyboardGlobally={true}
|
||||||
onLibraryChange={onLibraryChange}
|
onLibraryChange={onLibraryChange}
|
||||||
autoFocus={true}
|
|
||||||
/>
|
/>
|
||||||
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
|
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
|
|||||||
+1
-2
@@ -21,7 +21,6 @@ interface DehydratedHistoryEntry {
|
|||||||
const clearAppStatePropertiesForHistory = (appState: AppState) => {
|
const clearAppStatePropertiesForHistory = (appState: AppState) => {
|
||||||
return {
|
return {
|
||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
selectedGroupIds: appState.selectedGroupIds,
|
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
editingLinearElement: appState.editingLinearElement,
|
editingLinearElement: appState.editingLinearElement,
|
||||||
editingGroupId: appState.editingGroupId,
|
editingGroupId: appState.editingGroupId,
|
||||||
@@ -170,7 +169,7 @@ class History {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (key === "selectedElementIds" || key === "selectedGroupIds") {
|
if (key === "selectedElementIds") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (nextEntry.appState[key] !== lastEntry.appState[key]) {
|
if (nextEntry.appState[key] !== lastEntry.appState[key]) {
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ const allLanguages: Language[] = [
|
|||||||
{ code: "zh-CN", label: "简体中文" },
|
{ code: "zh-CN", label: "简体中文" },
|
||||||
{ code: "zh-TW", label: "繁體中文" },
|
{ code: "zh-TW", label: "繁體中文" },
|
||||||
{ code: "lv-LV", label: "Latviešu" },
|
{ code: "lv-LV", label: "Latviešu" },
|
||||||
{ code: "cs-CZ", label: "Česky" },
|
|
||||||
{ code: "kk-KZ", label: "Қазақ тілі" },
|
|
||||||
].concat([defaultLang]);
|
].concat([defaultLang]);
|
||||||
|
|
||||||
export const languages: Language[] = allLanguages
|
export const languages: Language[] = allLanguages
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ const canvas = exportToCanvas(
|
|||||||
{
|
{
|
||||||
exportBackground: true,
|
exportBackground: true,
|
||||||
viewBackgroundColor: "#ffffff",
|
viewBackgroundColor: "#ffffff",
|
||||||
|
shouldAddWatermark: false,
|
||||||
|
scale: 1,
|
||||||
},
|
},
|
||||||
createCanvas,
|
createCanvas,
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user