Compare commits

..

14 Commits

Author SHA1 Message Date
Arun Kumar
eb206cc932 Fix name input style in dark mode 2021-03-20 16:02:15 +05:30
Aakansha Doshi
16c287c848 Give preference to name prop when initialData.appState.name is present and update specs 2021-03-20 14:25:02 +05:30
Aakansha Doshi
78024873e5 no border and on hover no background change 2021-03-20 13:52:15 +05:30
Aakansha Doshi
4e41bd9dbb Merge remote-tracking branch 'origin/master' into updatescene-name 2021-03-20 13:43:01 +05:30
Aakansha Doshi
edc23b854f minor fixes 2021-03-20 13:41:12 +05:30
Arun Kumar
4843c49556 remove eempty lines 2021-03-20 01:15:01 +05:30
Arun Kumar
d565413082 Add tests, update changelog and minor fixes 2021-03-20 01:07:49 +05:30
Aakansha Doshi
dcda7184d0 Merge remote-tracking branch 'origin/master' into updatescene-name 2021-03-20 00:24:24 +05:30
Arun Kumar
8d413670c8 Remove redundant if statement 2021-03-19 01:32:14 +05:30
Arun Kumar
f774452124 Remove customName from state 2021-03-19 01:30:27 +05:30
Arun Kumar
db4ed1ecb1 Make requested changes 2021-03-18 22:49:37 +05:30
Arun Kumar
489f45b910 Make requested changes 2021-03-18 16:36:12 +05:30
Arun Kumar
a17be085b0 Revert "Allow updating name on updateScene"
This reverts commit 4e07a608d3.
2021-03-18 16:06:28 +05:30
Arun Kumar
4e07a608d3 Allow updating name on updateScene 2021-03-18 01:04:02 +05:30
263 changed files with 12819 additions and 25566 deletions

View File

@@ -1,26 +0,0 @@
name: Auto release @excalidraw/excalidraw-next
on:
push:
branches:
- master
jobs:
Auto-release-excalidraw-next:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 2
- name: Setup Node.js 14.x
uses: actions/setup-node@v2
with:
node-version: 14.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release
run: |
yarn autorelease

3
.gitignore vendored
View File

@@ -5,11 +5,9 @@
.env.test.local
.envrc
.eslintcache
.history
.idea
.vercel
.vscode
.yarn
*.log
*.tgz
build
@@ -22,4 +20,3 @@ package-lock.json
static
yarn-debug.log*
yarn-error.log*
src/packages/excalidraw/types

View File

@@ -10,7 +10,7 @@ ARG NODE_ENV=production
COPY . .
RUN yarn build:app:docker
FROM nginx:1.21-alpine
FROM nginx:1.17-alpine
COPY --from=build /opt/node_app/build /usr/share/nginx/html

View File

@@ -70,8 +70,6 @@ The first set of digits is the room. This is visible from the server thats go
The second set of digits is the encryption key. The Excalidraw server doesnt know about it. This is what all the participants use to encrypt/decrypt the messages.
> Note: Please ensure that the encryption key is 22 characters long.
## Shape libraries
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
@@ -95,7 +93,7 @@ These instructions will get you a copy of the project up and running on your loc
#### Requirements
- [Node.js](https://nodejs.org/en/)
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
- [Yarn](https://yarnpkg.com/getting-started/install)
- [Git](https://git-scm.com/downloads)
#### Clone the repo
@@ -104,20 +102,6 @@ These instructions will get you a copy of the project up and running on your loc
git clone https://github.com/excalidraw/excalidraw.git
```
#### Install the dependencies
```bash
yarn
```
#### Start the server
```bash
yarn start
```
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
#### Commands
| Command | Description |

View File

@@ -2,8 +2,5 @@
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"storage": {
"rules": "storage.rules"
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -19,37 +19,34 @@
]
},
"dependencies": {
"@dwelle/browser-fs-access": "0.21.1",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.11.10",
"@testing-library/react": "11.2.6",
"@tldraw/vec": "0.0.106",
"@types/jest": "26.0.22",
"@types/react": "17.0.3",
"@types/react-dom": "17.0.3",
"@sentry/browser": "6.2.2",
"@sentry/integrations": "6.2.1",
"@testing-library/jest-dom": "5.11.9",
"@testing-library/react": "11.2.5",
"@types/jest": "26.0.20",
"@types/react": "17.0.2",
"@types/react-dom": "17.0.1",
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.14.2",
"clsx": "1.1.1",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.0",
"firebase": "8.2.10",
"i18next-browser-languagedetector": "6.0.1",
"lodash.throttle": "4.1.1",
"nanoid": "3.1.22",
"nanoid": "3.1.21",
"open-color": "1.8.0",
"pako": "1.0.11",
"perfect-freehand": "1.0.15",
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0",
"points-on-curve": "0.2.0",
"pwacompat": "2.0.17",
"react": "17.0.2",
"react-dom": "17.0.2",
"react": "17.0.1",
"react-dom": "17.0.1",
"react-scripts": "4.0.3",
"roughjs": "4.4.1",
"sass": "1.32.10",
"roughjs": "4.3.1",
"sass": "1.32.8",
"socket.io-client": "2.3.1",
"typescript": "4.2.4"
"typescript": "4.2.3"
},
"devDependencies": {
"@excalidraw/eslint-config": "1.0.0",
@@ -57,9 +54,9 @@
"@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.1",
"@types/resize-observer-browser": "0.1.5",
"eslint-config-prettier": "8.3.0",
"eslint-config-prettier": "8.1.0",
"eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.9.0",
"firebase-tools": "9.6.1",
"husky": "4.3.8",
"jest-canvas-mock": "2.3.1",
"lint-staged": "10.5.4",
@@ -78,7 +75,7 @@
},
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)"
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
],
"resetMocks": false
},
@@ -106,7 +103,6 @@
"test:other": "yarn prettier --list-different",
"test:typecheck": "tsc",
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
"test": "yarn test:app",
"autorelease": "node scripts/autorelease.js"
"test": "yarn test:app"
}
}

Binary file not shown.

View File

@@ -13,18 +13,6 @@
<meta name="theme-color" content="#000" />
<!-- Declarative Link Capturing (https://web.dev/declarative-link-capturing/) -->
<meta
http-equiv="origin-trial"
content="Ak3VyzTheARtX2CnxBZ3ZKxImB0mNyvDakmMxeAChgxrWFMZ3IGN64VP+uj36VxM5OegsbLmrP258b1xvqp7+Q8AAABbeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJBcHBMaW5rQ2FwdHVyaW5nIiwiZXhwaXJ5IjoxNjM0MDgzMTk5fQ=="
/>
<!-- File Handling (https://web.dev/file-handling/) -->
<meta
http-equiv="origin-trial"
content="AkMQsAnFmKfRfPKQHNCv2WmZREqgwkqhyt2M7aOwQiCStB+hPYnGnv+mNbkPDAsGXrwsj/waFi76wPzTDUaEeQ0AAABUeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJGaWxlSGFuZGxpbmciLCJleHBpcnkiOjE2MzQwODMxOTl9"
/>
<!-- General tags -->
<meta
name="description"
@@ -63,7 +51,8 @@
name="twitter:description"
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<!-- OG tags require absolute url for images -->
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
<!-- Excalidraw version -->
@@ -99,8 +88,6 @@
<link rel="stylesheet" href="fonts.css" type="text/css" />
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script
@@ -119,17 +106,15 @@
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
<style>
body,
html {
body {
margin: 0;
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
-webkit-text-size-adjust: 100%;
width: 100%;
height: 100%;
overflow: hidden;
width: 100vw;
height: 100vh;
}
.visually-hidden {
@@ -139,7 +124,6 @@
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; /* added line */
user-select: none;
}
.LoadingMessage {
@@ -162,24 +146,6 @@
color: var(--popup-text-color);
font-size: 1.3em;
}
#root {
height: 100%;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@media screen and (min-width: 1200px) {
-webkit-touch-callout: default;
-webkit-user-select: auto;
-khtml-user-select: auto;
-moz-user-select: auto;
-ms-user-select: auto;
user-select: auto;
}
}
</style>
</head>

View File

@@ -26,7 +26,7 @@
}
}
],
"capture_links": "new-client",
"capture_links": "new_client",
"share_target": {
"action": "/web-share-target",
"method": "POST",
@@ -39,37 +39,5 @@
}
]
}
},
"screenshots": [
{
"src": "/screenshots/virtual-whiteboard.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/wireframe.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/illustration.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/shapes.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/collaboration.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/export.png",
"type": "image/png",
"sizes": "462x945"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -1,51 +0,0 @@
const fs = require("fs");
const { exec, execSync } = require("child_process");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage);
const getShortCommitHash = () => {
return execSync("git rev-parse --short HEAD").toString().trim();
};
const publish = () => {
try {
execSync(`yarn --frozen-lockfile`);
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
execSync(`yarn --cwd ${excalidrawDir} publish`);
} catch (e) {
console.error(e);
}
};
// get files changed between prev and head commit
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
if (error || stderr) {
console.error(error);
process.exit(1);
}
const changedFiles = stdout.trim().split("\n");
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
const excalidrawPackageFiles = changedFiles.filter((file) => {
return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
});
if (!excalidrawPackageFiles.length) {
process.exit(0);
}
// update package.json
pkg.version = `${pkg.version}-${getShortCommitHash()}`;
pkg.name = "@excalidraw/excalidraw-next";
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
// update readme
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
publish();
});

View File

@@ -37,9 +37,6 @@ const crowdinMap = {
"uk-UA": "en-uk",
"zh-CN": "en-zhcn",
"zh-TW": "en-zhtw",
"lv-LV": "en-lv",
"cs-CZ": "en-cs",
"kk-KZ": "en-kk",
};
const flags = {
@@ -77,9 +74,6 @@ const flags = {
"uk-UA": "🇺🇦",
"zh-CN": "🇨🇳",
"zh-TW": "🇹🇼",
"lv-LV": "🇱🇻",
"cs-CZ": "🇨🇿",
"kk-KZ": "🇰🇿",
};
const languages = {
@@ -117,9 +111,6 @@ const languages = {
"uk-UA": "Українська",
"zh-CN": "简体中文",
"zh-TW": "繁體中文",
"lv-LV": "Latviešu",
"cs-CZ": "Česky",
"kk-KZ": "Қазақ тілі",
};
const percentages = fs.readFileSync(

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -2,20 +2,18 @@ import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import { deepCopyElement } from "../element/newElement";
import { Library } from "../data/library";
export const actionAddToLibrary = register({
name: "addToLibrary",
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
app.library.loadLibrary().then((items) => {
app.library.saveLibrary([
...items,
selectedElements.map(deepCopyElement),
]);
Library.loadLibrary().then((items) => {
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
});
return false;
},

View File

@@ -1,3 +1,4 @@
import React from "react";
import { alignElements, Alignment } from "../align";
import {
AlignBottomIcon,

View File

@@ -1,14 +1,14 @@
import React from "react";
import { getDefaultAppState } from "../appState";
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 { DarkModeToggle } from "../components/DarkModeToggle";
import { THEME, ZOOM_STEP } from "../constants";
import { ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { CODES, KEYS } from "../keys";
import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll";
@@ -16,14 +16,13 @@ import { getNewZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
commitToHistory: !!value.viewBackgroundColor,
appState: { ...appState, viewBackgroundColor: value },
commitToHistory: true,
};
},
PanelComponent: ({ appState, updateData }) => {
@@ -33,12 +32,7 @@ export const actionChangeViewBackgroundColor = register({
label={t("labels.canvasBackground")}
type="canvasBackground"
color={appState.viewBackgroundColor}
onChange={(color) => updateData({ viewBackgroundColor: color })}
isActive={appState.openPopup === "canvasColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "canvasColorPicker" : null })
}
data-testid="canvas-background-picker"
onChange={(color) => updateData(color)}
/>
</div>
);
@@ -59,6 +53,7 @@ export const actionClearCanvas = register({
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
shouldAddWatermark: appState.shouldAddWatermark,
showStats: appState.showStats,
pasteDialog: appState.pasteDialog,
},
@@ -77,7 +72,6 @@ export const actionClearCanvas = register({
updateData(null);
}
}}
data-testid="clear-canvas-button"
/>
),
});
@@ -108,7 +102,6 @@ export const actionZoomIn = register({
onClick={() => {
updateData(null);
}}
size="small"
/>
),
keyTest: (event) =>
@@ -143,7 +136,6 @@ export const actionZoomOut = register({
onClick={() => {
updateData(null);
}}
size="small"
/>
),
keyTest: (event) =>
@@ -170,21 +162,16 @@ export const actionResetZoom = register({
commitToHistory: false,
};
},
PanelComponent: ({ updateData, appState }) => (
<Tooltip label={t("buttons.resetZoom")}>
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
className="reset-zoom-button"
icon={resetZoom}
title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")}
onClick={() => {
updateData(null);
}}
size="small"
>
{(appState.zoom.value * 100).toFixed(0)}%
</ToolButton>
</Tooltip>
/>
),
keyTest: (event) =>
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
@@ -271,28 +258,3 @@ export const actionZoomToFit = register({
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],
});
export const actionToggleTheme = register({
name: "toggleTheme",
perform: (_, appState, value) => {
return {
appState: {
...appState,
theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
},
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
<div style={{ marginInlineStart: "0.25rem" }}>
<DarkModeToggle
value={appState.theme}
onChange={(theme) => {
updateData(theme);
}}
/>
</div>
),
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
});

View File

@@ -50,6 +50,7 @@ export const actionCopyAsSvg = register({
? selectedElements
: getNonDeletedElements(elements),
appState,
app.canvas,
appState,
);
return {
@@ -88,6 +89,7 @@ export const actionCopyAsPng = register({
? selectedElements
: getNonDeletedElements(elements),
appState,
app.canvas,
appState,
);
return {

View File

@@ -1,6 +1,7 @@
import { isSomeElementSelected } from "../scene";
import { KEYS } from "../keys";
import { ToolButton } from "../components/ToolButton";
import React from "react";
import { trash } from "../components/icons";
import { t } from "../i18n";
import { register } from "./register";

View File

@@ -1,3 +1,4 @@
import React from "react";
import {
DistributeHorizontallyIcon,
DistributeVerticallyIcon,

View File

@@ -1,3 +1,4 @@
import React from "react";
import { KEYS } from "../keys";
import { register } from "./register";
import { ExcalidrawElement } from "../element/types";

View File

@@ -1,25 +1,16 @@
import React from "react";
import { trackEvent } from "../analytics";
import { load, questionCircle, saveAs } from "../components/icons";
import { load, questionCircle, save, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton";
import "../components/ToolIcon.scss";
import { Tooltip } from "../components/Tooltip";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data";
import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { KEYS } from "../keys";
import { register } from "./register";
import { CheckboxItem } from "../components/CheckboxItem";
import { getExportSize } from "../scene/export";
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element";
import { ActiveFile } from "../components/ActiveFile";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types";
export const actionChangeProjectName = register({
name: "changeProjectName",
@@ -32,61 +23,11 @@ export const actionChangeProjectName = register({
label={t("labels.fileTitle")}
value={appState.name || "Unnamed"}
onChange={(name: string) => updateData(name)}
isNameEditable={
typeof appProps.name === "undefined" && !appState.viewModeEnabled
}
isNameEditable={typeof appProps.name === "undefined"}
/>
),
});
export const actionChangeExportScale = register({
name: "changeExportScale",
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
commitToHistory: false,
};
},
PanelComponent: ({ elements: allElements, appState, updateData }) => {
const elements = getNonDeletedElements(allElements);
const exportSelected = isSomeElementSelected(elements, appState);
const exportedElements = exportSelected
? getSelectedElements(elements, appState)
: elements;
return (
<>
{EXPORT_SCALES.map((s) => {
const [width, height] = getExportSize(
exportedElements,
DEFAULT_EXPORT_PADDING,
s,
);
const scaleButtonTitle = `${t(
"buttons.scale",
)} ${s}x (${width}x${height})`;
return (
<ToolButton
key={s}
size="small"
type="radio"
icon={`${s}x`}
name="export-canvas-scale"
title={scaleButtonTitle}
aria-label={scaleButtonTitle}
id="export-canvas-scale"
checked={s === appState.exportScale}
onChange={() => updateData(s)}
/>
);
})}
</>
);
},
});
export const actionChangeExportBackground = register({
name: "changeExportBackground",
perform: (_elements, appState, value) => {
@@ -96,12 +37,14 @@ export const actionChangeExportBackground = register({
};
},
PanelComponent: ({ appState, updateData }) => (
<CheckboxItem
<label>
<input
type="checkbox"
checked={appState.exportBackground}
onChange={(checked) => updateData(checked)}
>
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.withBackground")}
</CheckboxItem>
</label>
),
});
@@ -114,35 +57,57 @@ export const actionChangeExportEmbedScene = register({
};
},
PanelComponent: ({ appState, updateData }) => (
<CheckboxItem
<label style={{ display: "flex" }}>
<input
type="checkbox"
checked={appState.exportEmbedScene}
onChange={(checked) => updateData(checked)}
>
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.exportEmbedScene")}
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
<Tooltip
label={t("labels.exportEmbedScene_details")}
position="above"
long={true}
>
<div className="TooltipIcon">{questionCircle}</div>
</Tooltip>
</CheckboxItem>
</label>
),
});
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
export const actionChangeShouldAddWatermark = register({
name: "changeShouldAddWatermark",
perform: (_elements, appState, value) => {
return {
appState: { ...appState, shouldAddWatermark: value },
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
<label>
<input
type="checkbox"
checked={appState.shouldAddWatermark}
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.addWatermark")}
</label>
),
});
export const actionSaveScene = register({
name: "saveScene",
perform: async (elements, appState, value) => {
const fileHandleExists = !!appState.fileHandle;
try {
const { fileHandle } = isImageFileHandle(appState.fileHandle)
? await resaveAsImageWithScene(elements, appState)
: await saveAsJSON(elements, appState);
const { fileHandle } = await saveAsJSON(elements, appState);
return {
commitToHistory: false,
appState: {
...appState,
fileHandle,
toastMessage: fileHandleExists
? fileHandle?.name
? fileHandle.name
? t("toast.fileSavedToFilename").replace(
"{filename}",
`"${fileHandle.name}"`,
@@ -160,16 +125,20 @@ export const actionSaveToActiveFile = register({
},
keyTest: (event) =>
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
PanelComponent: ({ updateData, appState }) => (
<ActiveFile
onSave={() => updateData(null)}
fileName={appState.fileHandle?.name}
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={save}
title={t("buttons.save")}
aria-label={t("buttons.save")}
showAriaLabel={useIsMobile()}
onClick={() => updateData(null)}
/>
),
});
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
export const actionSaveAsScene = register({
name: "saveAsScene",
perform: async (elements, appState, value) => {
try {
const { fileHandle } = await saveAsJSON(elements, {
@@ -193,9 +162,10 @@ export const actionSaveFileToDisk = register({
title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")}
showAriaLabel={useIsMobile()}
hidden={!nativeFileSystemSupported}
hidden={
!("chooseFileSystemEntries" in window || "showOpenFilePicker" in window)
}
onClick={() => updateData(null)}
data-testid="save-as-button"
/>
),
});
@@ -207,7 +177,7 @@ export const actionLoadScene = register({
const {
elements: loadedElements,
appState: loadedAppState,
} = await loadFromJSON(appState, elements);
} = await loadFromJSON(appState);
return {
elements: loadedElements,
appState: loadedAppState,
@@ -233,7 +203,6 @@ export const actionLoadScene = register({
aria-label={t("buttons.load")}
showAriaLabel={useIsMobile()}
onClick={updateData}
data-testid="load-button"
/>
),
});
@@ -256,9 +225,9 @@ export const actionExportWithDarkMode = register({
}}
>
<DarkModeToggle
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
onChange={(theme: Theme) => {
updateData(theme === THEME.DARK);
value={appState.exportWithDarkMode ? "dark" : "light"}
onChange={(theme: Appearence) => {
updateData(theme === "dark");
}}
title={t("labels.toggleExportColorScheme")}
/>

View File

@@ -1,6 +1,7 @@
import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element";
import { resetCursor } from "../utils";
import React from "react";
import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons";
import { t } from "../i18n";
@@ -17,7 +18,7 @@ import { isBindingElement } from "../element/typeChecks";
export const actionFinalize = register({
name: "finalize",
perform: (elements, appState, _, { canvas, focusContainer }) => {
perform: (elements, appState, _, { canvas }) => {
if (appState.editingLinearElement) {
const {
elementId,
@@ -50,19 +51,19 @@ export const actionFinalize = register({
let newElements = elements;
if (window.document.activeElement instanceof HTMLElement) {
focusContainer();
window.document.activeElement.blur();
}
const multiPointElement = appState.multiElement
? appState.multiElement
: appState.editingElement?.type === "freedraw"
: appState.editingElement?.type === "draw"
? appState.editingElement
: null;
if (multiPointElement) {
// pen and mouse have hover
if (
multiPointElement.type !== "freedraw" &&
multiPointElement.type !== "draw" &&
appState.lastPointerDownWith !== "touch"
) {
const { points, lastCommittedPoint } = multiPointElement;
@@ -85,7 +86,7 @@ export const actionFinalize = register({
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
if (
multiPointElement.type === "line" ||
multiPointElement.type === "freedraw"
multiPointElement.type === "draw"
) {
if (isLoop) {
const linePoints = multiPointElement.points;
@@ -117,24 +118,22 @@ export const actionFinalize = register({
);
}
if (!appState.elementLocked && appState.elementType !== "freedraw") {
if (!appState.elementLocked && appState.elementType !== "draw") {
appState.selectedElementIds[multiPointElement.id] = true;
}
}
if (
(!appState.elementLocked && appState.elementType !== "freedraw") ||
(!appState.elementLocked && appState.elementType !== "draw") ||
!multiPointElement
) {
resetCursor(canvas);
}
return {
elements: newElements,
appState: {
...appState,
elementType:
(appState.elementLocked || appState.elementType === "freedraw") &&
(appState.elementLocked || appState.elementType === "draw") &&
multiPointElement
? appState.elementType
: "selection",
@@ -146,14 +145,14 @@ export const actionFinalize = register({
selectedElementIds:
multiPointElement &&
!appState.elementLocked &&
appState.elementType !== "freedraw"
appState.elementType !== "draw"
? {
...appState.selectedElementIds,
[multiPointElement.id]: true,
}
: appState.selectedElementIds,
},
commitToHistory: appState.elementType === "freedraw",
commitToHistory: appState.elementType === "draw",
};
},
keyTest: (event, appState) =>

View File

@@ -1,207 +0,0 @@
import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getElementMap, getNonDeletedElements } from "../element";
import { mutateElement } from "../element/mutateElement";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
import { AppState } from "../types";
import { getTransformHandles } from "../element/transformHandles";
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
import { updateBoundElements } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor";
const enableActionFlipHorizontal = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const eligibleElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return eligibleElements.length === 1 && eligibleElements[0].type !== "text";
};
const enableActionFlipVertical = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const eligibleElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return eligibleElements.length === 1;
};
export const actionFlipHorizontal = register({
name: "flipHorizontal",
perform: (elements, appState) => {
return {
elements: flipSelectedElements(elements, appState, "horizontal"),
appState,
commitToHistory: true,
};
},
keyTest: (event) => event.shiftKey && event.code === "KeyH",
contextItemLabel: "labels.flipHorizontal",
contextItemPredicate: (elements, appState) =>
enableActionFlipHorizontal(elements, appState),
});
export const actionFlipVertical = register({
name: "flipVertical",
perform: (elements, appState) => {
return {
elements: flipSelectedElements(elements, appState, "vertical"),
appState,
commitToHistory: true,
};
},
keyTest: (event) => event.shiftKey && event.code === "KeyV",
contextItemLabel: "labels.flipVertical",
contextItemPredicate: (elements, appState) =>
enableActionFlipVertical(elements, appState),
});
const flipSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
flipDirection: "horizontal" | "vertical",
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
// remove once we allow for groups of elements to be flipped
if (selectedElements.length > 1) {
return elements;
}
const updatedElements = flipElements(
selectedElements,
appState,
flipDirection,
);
const updatedElementsMap = getElementMap(updatedElements);
return elements.map((element) => updatedElementsMap[element.id] || element);
};
const flipElements = (
elements: NonDeleted<ExcalidrawElement>[],
appState: AppState,
flipDirection: "horizontal" | "vertical",
): ExcalidrawElement[] => {
for (let i = 0; i < elements.length; i++) {
flipElement(elements[i], appState);
// If vertical flip, rotate an extra 180
if (flipDirection === "vertical") {
rotateElement(elements[i], Math.PI);
}
}
return elements;
};
const flipElement = (
element: NonDeleted<ExcalidrawElement>,
appState: AppState,
) => {
const originalX = element.x;
const originalY = element.y;
const width = element.width;
const height = element.height;
const originalAngle = normalizeAngle(element.angle);
let finalOffsetX = 0;
if (isLinearElement(element) || isFreeDrawElement(element)) {
finalOffsetX =
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
element.width;
}
// Rotate back to zero, if necessary
mutateElement(element, {
angle: normalizeAngle(0),
});
// Flip unrotated by pulling TransformHandle to opposite side
const transformHandles = getTransformHandles(element, appState.zoom);
let usingNWHandle = true;
let newNCoordsX = 0;
let nHandle = transformHandles.nw;
if (!nHandle) {
// Use ne handle instead
usingNWHandle = false;
nHandle = transformHandles.ne;
if (!nHandle) {
mutateElement(element, {
angle: originalAngle,
});
return;
}
}
if (isLinearElement(element)) {
for (let i = 1; i < element.points.length; i++) {
LinearElementEditor.movePoint(element, i, [
-element.points[i][0],
element.points[i][1],
]);
}
LinearElementEditor.normalizePoints(element);
} else {
// calculate new x-coord for transformation
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
resizeSingleElement(
element,
true,
element,
usingNWHandle ? "nw" : "ne",
false,
newNCoordsX,
nHandle[1],
);
// fix the size to account for handle sizes
mutateElement(element, {
width,
height,
});
}
// Rotate by (360 degrees - original angle)
let angle = normalizeAngle(2 * Math.PI - originalAngle);
if (angle < 0) {
// check, probably unnecessary
angle = normalizeAngle(angle + 2 * Math.PI);
}
mutateElement(element, {
angle,
});
// Move back to original spot to appear "flipped in place"
mutateElement(element, {
x: originalX + finalOffsetX,
y: originalY,
});
updateBoundElements(element);
};
const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
const originalX = element.x;
const originalY = element.y;
let angle = normalizeAngle(element.angle + rotationAngle);
if (angle < 0) {
// check, probably unnecessary
angle = normalizeAngle(2 * Math.PI + angle);
}
mutateElement(element, {
angle,
});
// Move back to original spot
mutateElement(element, {
x: originalX,
y: originalY,
});
};

View File

@@ -1,3 +1,4 @@
import React from "react";
import { CODES, KEYS } from "../keys";
import { t } from "../i18n";
import { getShortcutKey } from "../utils";

View File

@@ -1,8 +1,9 @@
import { Action, ActionResult } from "./types";
import React from "react";
import { undo, redo } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import History, { HistoryEntry } from "../history";
import { SceneHistory, HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { isWindows, KEYS } from "../keys";
@@ -58,7 +59,7 @@ const writeData = (
return { commitToHistory };
};
type ActionCreator = (history: History) => Action;
type ActionCreator = (history: SceneHistory) => Action;
export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
@@ -68,13 +69,12 @@ export const createUndoAction: ActionCreator = (history) => ({
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData, data }) => (
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={undo}
aria-label={t("buttons.undo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,
@@ -89,13 +89,12 @@ export const createRedoAction: ActionCreator = (history) => ({
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData, data }) => (
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={redo}
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,

View File

@@ -1,3 +1,4 @@
import React from "react";
import { menu, palette } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
@@ -69,10 +70,7 @@ export const actionFullScreen = register({
export const actionShortcuts = register({
name: "toggleShortcuts",
perform: (_elements, appState, _, { focusContainer }) => {
if (appState.showHelpDialog) {
focusContainer();
}
perform: (_elements, appState) => {
return {
appState: {
...appState,

View File

@@ -1,3 +1,4 @@
import React from "react";
import { getClientColors, getClientInitials } from "../clients";
import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll";
@@ -29,8 +30,8 @@ export const actionGoToCollaborator = register({
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData, data }) => {
const clientId: string | undefined = data?.id;
PanelComponent: ({ appState, updateData, id }) => {
const clientId = id;
if (!clientId) {
return null;
}

View File

@@ -1,5 +1,7 @@
import React from "react";
import { AppState } from "../../src/types";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ButtonSelect } from "../components/ButtonSelect";
import { ColorPicker } from "../components/ColorPicker";
import { IconPicker } from "../components/IconPicker";
import {
@@ -12,13 +14,6 @@ import {
FillCrossHatchIcon,
FillHachureIcon,
FillSolidIcon,
FontFamilyCodeIcon,
FontFamilyHandDrawnIcon,
FontFamilyNormalIcon,
FontSizeExtraLargeIcon,
FontSizeLargeIcon,
FontSizeMediumIcon,
FontSizeSmallIcon,
SloppinessArchitectIcon,
SloppinessArtistIcon,
SloppinessCartoonistIcon,
@@ -26,15 +21,8 @@ import {
StrokeStyleDottedIcon,
StrokeStyleSolidIcon,
StrokeWidthIcon,
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
} from "../components/icons";
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
} from "../constants";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
import {
getNonDeletedElements,
isTextElement,
@@ -47,7 +35,7 @@ import {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
FontFamilyValues,
FontFamily,
TextAlign,
} from "../element/types";
import { getLanguage, t } from "../i18n";
@@ -102,18 +90,13 @@ export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
perform: (elements, appState, value) => {
return {
...(value.currentItemStrokeColor && {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeColor: value.currentItemStrokeColor,
strokeColor: value,
}),
),
}),
appState: {
...appState,
...value,
},
commitToHistory: !!value.currentItemStrokeColor,
appState: { ...appState, currentItemStrokeColor: value },
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -128,11 +111,7 @@ export const actionChangeStrokeColor = register({
(element) => element.strokeColor,
appState.currentItemStrokeColor,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
isActive={appState.openPopup === "strokeColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null })
}
onChange={updateData}
/>
</>
),
@@ -142,18 +121,13 @@ export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor",
perform: (elements, appState, value) => {
return {
...(value.currentItemBackgroundColor && {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
backgroundColor: value,
}),
),
}),
appState: {
...appState,
...value,
},
commitToHistory: !!value.currentItemBackgroundColor,
appState: { ...appState, currentItemBackgroundColor: value },
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -168,11 +142,7 @@ export const actionChangeBackgroundColor = register({
(element) => element.backgroundColor,
appState.currentItemBackgroundColor,
)}
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
isActive={appState.openPopup === "backgroundColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "backgroundColorPicker" : null })
}
onChange={updateData}
/>
</>
),
@@ -443,29 +413,13 @@ export const actionChangeFontSize = register({
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.fontSize")}</legend>
<ButtonIconSelect
<ButtonSelect
group="font-size"
options={[
{
value: 16,
text: t("labels.small"),
icon: <FontSizeSmallIcon theme={appState.theme} />,
},
{
value: 20,
text: t("labels.medium"),
icon: <FontSizeMediumIcon theme={appState.theme} />,
},
{
value: 28,
text: t("labels.large"),
icon: <FontSizeLargeIcon theme={appState.theme} />,
},
{
value: 36,
text: t("labels.veryLarge"),
icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
},
{ value: 16, text: t("labels.small") },
{ value: 20, text: t("labels.medium") },
{ value: 28, text: t("labels.large") },
{ value: 36, text: t("labels.veryLarge") },
]}
value={getFormValue(
elements,
@@ -502,32 +456,16 @@ export const actionChangeFontFamily = register({
};
},
PanelComponent: ({ elements, appState, updateData }) => {
const options: {
value: FontFamilyValues;
text: string;
icon: JSX.Element;
}[] = [
{
value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"),
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
},
{
value: FONT_FAMILY.Helvetica,
text: t("labels.normal"),
icon: <FontFamilyNormalIcon theme={appState.theme} />,
},
{
value: FONT_FAMILY.Cascadia,
text: t("labels.code"),
icon: <FontFamilyCodeIcon theme={appState.theme} />,
},
const options: { value: FontFamily; text: string }[] = [
{ value: 1, text: t("labels.handDrawn") },
{ value: 2, text: t("labels.normal") },
{ value: 3, text: t("labels.code") },
];
return (
<fieldset>
<legend>{t("labels.fontFamily")}</legend>
<ButtonIconSelect<FontFamilyValues | false>
<ButtonSelect<FontFamily | false>
group="font-family"
options={options}
value={getFormValue(
@@ -568,24 +506,12 @@ export const actionChangeTextAlign = register({
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.textAlign")}</legend>
<ButtonIconSelect<TextAlign | false>
<ButtonSelect<TextAlign | false>
group="text-align"
options={[
{
value: "left",
text: t("labels.left"),
icon: <TextAlignLeftIcon theme={appState.theme} />,
},
{
value: "center",
text: t("labels.center"),
icon: <TextAlignCenterIcon theme={appState.theme} />,
},
{
value: "right",
text: t("labels.right"),
icon: <TextAlignRightIcon theme={appState.theme} />,
},
{ value: "left", text: t("labels.left") },
{ value: "center", text: t("labels.center") },
{ value: "right", text: t("labels.right") },
]}
value={getFormValue(
elements,

View File

@@ -1,5 +1,4 @@
import { register } from "./register";
import { CODES, KEYS } from "../keys";
export const actionToggleStats = register({
name: "stats",
@@ -14,6 +13,4 @@ export const actionToggleStats = register({
},
checked: (appState) => appState.showStats,
contextItemLabel: "stats.title",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
});

View File

@@ -10,6 +10,7 @@ export const actionToggleViewMode = register({
appState: {
...appState,
viewModeEnabled: !this.checked!(appState),
selectedElementIds: {},
},
commitToHistory: false,
};

View File

@@ -26,7 +26,6 @@ export {
actionZoomOut,
actionResetZoom,
actionZoomToFit,
actionToggleTheme,
} from "./actionCanvas";
export { actionFinalize } from "./actionFinalize";
@@ -34,8 +33,8 @@ export { actionFinalize } from "./actionFinalize";
export {
actionChangeProjectName,
actionChangeExportBackground,
actionSaveToActiveFile,
actionSaveFileToDisk,
actionSaveScene,
actionSaveAsScene,
actionLoadScene,
} from "./actionExport";
@@ -67,8 +66,6 @@ export {
distributeVertically,
} from "./actionDistribute";
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
export {
actionCopy,
actionCut,

View File

@@ -5,21 +5,14 @@ import {
UpdaterFn,
ActionName,
ActionResult,
PanelComponentProps,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppProps, AppState } from "../types";
import { AppState, ExcalidrawProps } from "../types";
import { MODES } from "../constants";
import Library from "../data/library";
// This is the <App> component, but for now we don't care about anything but its
// `canvas` state.
type App = {
canvas: HTMLCanvasElement | null;
focusContainer: () => void;
props: AppProps;
library: Library;
};
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
@@ -58,15 +51,11 @@ export class ActionManager implements ActionsManagerInterface {
actions.forEach((action) => this.registerAction(action));
}
handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) {
const canvasActions = this.app.props.UIOptions.canvasActions;
handleKeyDown(event: KeyboardEvent) {
const data = Object.values(this.actions)
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
.filter(
(action) =>
(action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions]
: true) &&
action.keyTest &&
action.keyTest(
event,
@@ -108,19 +97,12 @@ export class ActionManager implements ActionsManagerInterface {
);
}
/**
* @param data additional data sent to the PanelComponent
*/
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
const canvasActions = this.app.props.UIOptions.canvasActions;
if (
this.actions[name] &&
"PanelComponent" in this.actions[name] &&
(name in canvasActions
? canvasActions[name as keyof typeof canvasActions]
: true)
) {
// Id is an attribute that we can use to pass in data like keys.
// This is needed for dynamically generated action components
// like the user list. We can use this key to extract more
// data from app state. This is an alternative to generic prop hell!
renderAction = (name: ActionName, id?: string) => {
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
const action = this.actions[name];
const PanelComponent = action.PanelComponent!;
const updateData = (formState?: any) => {
@@ -139,8 +121,8 @@ export class ActionManager implements ActionsManagerInterface {
elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()}
updateData={updateData}
id={id}
appProps={this.app.props}
data={data}
/>
);
}

View File

@@ -23,9 +23,7 @@ export type ShortcutName =
| "zenMode"
| "stats"
| "addToLibrary"
| "viewMode"
| "flipHorizontal"
| "flipVertical";
| "viewMode";
const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")],
@@ -57,10 +55,8 @@ const shortcutMap: Record<ShortcutName, string[]> = {
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
zenMode: [getShortcutKey("Alt+Z")],
stats: [getShortcutKey("Alt+/")],
stats: [],
addToLibrary: [],
flipHorizontal: [getShortcutKey("Shift+H")],
flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")],
};

View File

@@ -1,33 +1,22 @@
import React from "react";
import { ExcalidrawElement } from "../element/types";
import { AppState, ExcalidrawProps } from "../types";
import Library from "../data/library";
import { ToolButtonSize } from "../components/ToolButton";
/** if false, the action should be prevented */
export type ActionResult =
| {
elements?: readonly ExcalidrawElement[] | null;
appState?: MarkOptional<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
commitToHistory: boolean;
syncHistory?: boolean;
}
| false;
type AppAPI = {
canvas: HTMLCanvasElement | null;
focusContainer(): void;
library: Library;
};
type ActionFn = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
formData: any,
app: AppAPI,
app: { canvas: HTMLCanvasElement | null },
) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void;
@@ -53,7 +42,6 @@ export type ActionName =
| "changeBackgroundColor"
| "changeFillStyle"
| "changeStrokeWidth"
| "changeStrokeShape"
| "changeSloppiness"
| "changeStrokeStyle"
| "changeArrowhead"
@@ -67,9 +55,9 @@ export type ActionName =
| "changeProjectName"
| "changeExportBackground"
| "changeExportEmbedScene"
| "changeExportScale"
| "saveToActiveFile"
| "saveFileToDisk"
| "changeShouldAddWatermark"
| "saveScene"
| "saveAsScene"
| "loadScene"
| "duplicateSelection"
| "deleteSelectedElements"
@@ -97,27 +85,22 @@ export type ActionName =
| "alignHorizontallyCentered"
| "distributeHorizontally"
| "distributeVertically"
| "flipHorizontal"
| "flipVertical"
| "viewMode"
| "exportWithDarkMode"
| "toggleTheme";
| "exportWithDarkMode";
export type PanelComponentProps = {
export interface Action {
name: ActionName;
PanelComponent?: React.FC<{
elements: readonly ExcalidrawElement[];
appState: AppState;
updateData: (formData?: any) => void;
appProps: ExcalidrawProps;
data?: Partial<{ id: string; size: ToolButtonSize }>;
};
export interface Action {
name: ActionName;
PanelComponent?: React.FC<PanelComponentProps>;
id?: string;
}>;
perform: ActionFn;
keyPriority?: number;
keyTest?: (
event: React.KeyboardEvent | KeyboardEvent,
event: KeyboardEvent,
appState: AppState,
elements: readonly ExcalidrawElement[],
) => boolean;
@@ -132,7 +115,6 @@ export interface Action {
export interface ActionsManagerInterface {
actions: Record<ActionName, Action>;
registerAction: (action: Action) => void;
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
handleKeyDown: (event: KeyboardEvent) => boolean;
renderAction: (name: ActionName) => React.ReactElement | null;
executeAction: (action: Action) => void;
}

View File

@@ -3,23 +3,17 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
EXPORT_SCALES,
THEME,
} from "./constants";
import { t } from "./i18n";
import { AppState, NormalizedZoomValue } from "./types";
import { getDateTime } from "./utils";
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
? devicePixelRatio
: 1;
export const getDefaultAppState = (): Omit<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
"offsetTop" | "offsetLeft"
> => {
return {
theme: THEME.LIGHT,
theme: "light",
collaborators: new Map(),
currentChartType: "bar",
currentItemBackgroundColor: "transparent",
@@ -45,11 +39,11 @@ export const getDefaultAppState = (): Omit<
elementType: "selection",
errorMessage: null,
exportBackground: true,
exportScale: defaultExportScale,
exportEmbedScene: false,
exportWithDarkMode: false,
fileHandle: null,
gridSize: null,
height: window.innerHeight,
isBindingEnabled: true,
isLibraryOpen: false,
isLoading: false,
@@ -59,7 +53,6 @@ export const getDefaultAppState = (): Omit<
multiElement: null,
name: `${t("labels.untitled")}-${getDateTime()}`,
openMenu: null,
openPopup: null,
pasteDialog: { shown: false, data: null },
previousSelectedElementIds: {},
resizingElement: null,
@@ -69,6 +62,7 @@ export const getDefaultAppState = (): Omit<
selectedElementIds: {},
selectedGroupIds: {},
selectionElement: null,
shouldAddWatermark: false,
shouldCacheIgnoreZoom: false,
showHelpDialog: false,
showStats: false,
@@ -76,6 +70,7 @@ export const getDefaultAppState = (): Omit<
suggestedBindings: [],
toastMessage: null,
viewBackgroundColor: oc.white,
width: window.innerWidth,
zenModeEnabled: false,
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
viewModeEnabled: false,
@@ -124,7 +119,6 @@ const APP_STATE_STORAGE_CONF = (<
errorMessage: { browser: false, export: false },
exportBackground: { browser: true, export: false },
exportEmbedScene: { browser: true, export: false },
exportScale: { browser: true, export: false },
exportWithDarkMode: { browser: true, export: false },
fileHandle: { browser: false, export: false },
gridSize: { browser: true, export: true },
@@ -140,7 +134,6 @@ const APP_STATE_STORAGE_CONF = (<
offsetLeft: { browser: false, export: false },
offsetTop: { browser: false, export: false },
openMenu: { browser: true, export: false },
openPopup: { browser: false, export: false },
pasteDialog: { browser: false, export: false },
previousSelectedElementIds: { browser: true, export: false },
resizingElement: { browser: false, export: false },
@@ -150,6 +143,7 @@ const APP_STATE_STORAGE_CONF = (<
selectedElementIds: { browser: true, export: false },
selectedGroupIds: { browser: true, export: false },
selectionElement: { browser: false, export: false },
shouldAddWatermark: { browser: true, export: false },
shouldCacheIgnoreZoom: { browser: true, export: false },
showHelpDialog: { browser: false, export: false },
showStats: { browser: true, export: false },

View File

@@ -6,20 +6,16 @@ import { getSelectedElements } from "./scene";
import { AppState } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES } from "./constants";
import { canvasToBlob } from "./data/blob";
const TYPE_ELEMENTS = "excalidraw/elements";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
type: typeof TYPE_ELEMENTS;
created: number;
elements: ExcalidrawElement[];
};
export interface ClipboardData {
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
text?: string;
errorMessage?: string;
}
let CLIPBOARD = "";
let PREFER_APP_CLIPBOARD = false;
@@ -35,16 +31,8 @@ export const probablySupportsClipboardBlob =
"ClipboardItem" in window &&
"toBlob" in HTMLCanvasElement.prototype;
const clipboardContainsElements = (
contents: any,
): contents is { elements: ExcalidrawElement[] } => {
if (
[
EXPORT_DATA_TYPES.excalidraw,
EXPORT_DATA_TYPES.excalidrawClipboard,
].includes(contents?.type) &&
Array.isArray(contents.elements)
) {
const isElementsClipboard = (contents: any): contents is ElementsClipboard => {
if (contents?.type === TYPE_ELEMENTS) {
return true;
}
return false;
@@ -55,7 +43,8 @@ export const copyToClipboard = async (
appState: AppState,
) => {
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
type: TYPE_ELEMENTS,
created: Date.now(),
elements: getSelectedElements(elements, appState),
};
const json = JSON.stringify(contents);
@@ -116,7 +105,12 @@ const getSystemClipboard = async (
*/
export const parseClipboard = async (
event: ClipboardEvent | null,
): Promise<ClipboardData> => {
): Promise<{
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
text?: string;
errorMessage?: string;
}> => {
const systemClipboard = await getSystemClipboard(event);
// if system clipboard empty, couldn't be resolved, or contains previously
@@ -137,9 +131,15 @@ export const parseClipboard = async (
try {
const systemClipboardData = JSON.parse(systemClipboard);
if (clipboardContainsElements(systemClipboardData)) {
// system clipboard elements are newer than in-app clipboard
if (
isElementsClipboard(systemClipboardData) &&
(!appClipboardData?.created ||
appClipboardData.created < systemClipboardData.created)
) {
return { elements: systemClipboardData.elements };
}
// in-app clipboard is newer than system clipboard
return appClipboardData;
} catch {
// system clipboard doesn't contain excalidraw elements → return plaintext
@@ -151,7 +151,8 @@ export const parseClipboard = async (
}
};
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
const blob = await canvasToBlob(canvas);
await navigator.clipboard.write([
new window.ClipboardItem({ "image/png": blob }),
]);

View File

@@ -3,14 +3,13 @@ import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import {
canChangeSharpness,
canHaveArrowheads,
getTargetElements,
hasBackground,
hasStrokeStyle,
hasStrokeWidth,
hasStroke,
hasText,
} from "../scene";
import { SHAPES } from "../shapes";
@@ -54,17 +53,10 @@ export const SelectedShapeActions = ({
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(elementType) ||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")}
{(elementType === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")}
{(hasStrokeStyle(elementType) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
{(hasStroke(elementType) ||
targetElements.some((element) => hasStroke(element.type))) && (
<>
{renderAction("changeStrokeWidth")}
{renderAction("changeStrokeStyle")}
{renderAction("changeSloppiness")}
</>
@@ -151,14 +143,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 = ({
canvas,
elementType,
setAppState,
isLibraryOpen,
}: {
canvas: HTMLCanvasElement | null;
elementType: ExcalidrawElement["type"];
setAppState: React.Component<any, AppState>["setState"];
isLibraryOpen: boolean;
}) => (
<>
{SHAPES.map(({ value, icon, key }, index) => {
@@ -192,6 +193,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 +218,12 @@ export const ZoomActions = ({
}) => (
<Stack.Col gap={1}>
<Stack.Row gap={1} align="center">
{renderAction("zoomOut")}
{renderAction("zoomIn")}
{renderAction("zoomOut")}
{renderAction("resetZoom")}
<div style={{ marginInlineStart: 4 }}>
{(zoom.value * 100).toFixed(0)}%
</div>
</Stack.Row>
</Stack.Col>
);

View File

@@ -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);
}
}
}
}

View File

@@ -1,28 +0,0 @@
import Stack from "../components/Stack";
import { ToolButton } from "../components/ToolButton";
import { save, file } from "../components/icons";
import { t } from "../i18n";
import "./ActiveFile.scss";
type ActiveFileProps = {
fileName?: string;
onSave: () => void;
};
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
<Stack.Row className="ActiveFile" gap={1} align="center">
<span className="ActiveFile__fileName">
{file}
<span>{fileName}</span>
</span>
<ToolButton
type="icon"
icon={save}
title={t("buttons.save")}
aria-label={t("buttons.save")}
onClick={onSave}
data-testid="save-button"
/>
</Stack.Row>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import React from "react";
import { ActionManager } from "../actions/manager";
import { AppState } from "../types";
import { DarkModeToggle } from "./DarkModeToggle";
export const BackgroundPickerAndDarkModeToggle = ({
appState,
@@ -15,6 +16,15 @@ export const BackgroundPickerAndDarkModeToggle = ({
}) => (
<div style={{ display: "flex" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
{showThemeBtn && actionManager.renderAction("toggleTheme")}
{showThemeBtn && (
<div style={{ marginInlineStart: "0.25rem" }}>
<DarkModeToggle
value={appState.theme}
onChange={(theme) => {
setAppState({ theme });
}}
/>
</div>
)}
</div>
);

View File

@@ -1,3 +1,4 @@
import React from "react";
import clsx from "clsx";
export const ButtonIconCycle = <T extends any>({
@@ -13,11 +14,11 @@ export const ButtonIconCycle = <T extends any>({
}) => {
const current = options.find((op) => op.value === value);
const cycle = () => {
function cycle() {
const index = options.indexOf(current!);
const next = (index + 1) % options.length;
onChange(options[next].value);
};
}
return (
<label key={group} className={clsx({ active: current!.value !== null })}>

View File

@@ -1,3 +1,4 @@
import React from "react";
import clsx from "clsx";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />

View File

@@ -1,3 +1,4 @@
import React from "react";
import clsx from "clsx";
export const ButtonSelect = <T extends Object>({

View File

@@ -1,53 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.Card {
display: flex;
flex-direction: column;
align-items: center;
max-width: 290px;
margin: 1em;
text-align: center;
.Card-icon {
font-size: 2.6em;
display: flex;
flex: 0 0 auto;
padding: 1.4rem;
border-radius: 50%;
background: var(--card-color);
color: $oc-white;
svg {
width: 2.8rem;
height: 2.8rem;
}
}
.Card-details {
font-size: 0.96em;
min-height: 90px;
padding: 0 1em;
margin-bottom: auto;
}
& .Card-button.ToolIcon_type_button {
height: 2.5rem;
margin-top: 1em;
margin-bottom: 0.3em;
background-color: var(--card-color);
&:hover {
background-color: var(--card-color-darker);
}
&:active {
background-color: var(--card-color-darkest);
}
.ToolIcon__label {
color: $oc-white;
}
}
}
}

View File

@@ -1,20 +0,0 @@
import OpenColor from "open-color";
import "./Card.scss";
export const Card: React.FC<{
color: keyof OpenColor;
}> = ({ children, color }) => {
return (
<div
className="Card"
style={{
["--card-color" as any]: OpenColor[color][7],
["--card-color-darker" as any]: OpenColor[color][8],
["--card-color-darkest" as any]: OpenColor[color][9],
}}
>
{children}
</div>
);
};

View File

@@ -1,89 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.Checkbox {
margin: 4px 0.3em;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
box-shadow: 0 0 0 2px #{$oc-blue-4};
}
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
svg {
display: block;
opacity: 0.3;
}
}
&:active {
.Checkbox-box {
box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
}
}
&:hover {
.Checkbox-box {
background-color: fade-out($oc-blue-1, 0.8);
}
}
&.is-checked {
.Checkbox-box {
background-color: #{$oc-blue-1};
svg {
display: block;
}
}
&:hover .Checkbox-box {
background-color: #{$oc-blue-2};
}
}
.Checkbox-box {
width: 22px;
height: 22px;
padding: 0;
flex: 0 0 auto;
margin: 0 1em;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 0 2px #{$oc-blue-7};
background-color: transparent;
border-radius: 4px;
color: #{$oc-blue-7};
&:focus {
box-shadow: 0 0 0 3px #{$oc-blue-7};
}
svg {
display: none;
width: 16px;
height: 16px;
stroke-width: 3px;
}
}
.Checkbox-label {
display: flex;
align-items: center;
}
.excalidraw-tooltip-icon {
width: 1em;
height: 1em;
}
}
}

View File

@@ -1,27 +0,0 @@
import React from "react";
import clsx from "clsx";
import { checkIcon } from "./icons";
import "./CheckboxItem.scss";
export const CheckboxItem: React.FC<{
checked: boolean;
onChange: (checked: boolean) => void;
}> = ({ children, checked, onChange }) => {
return (
<div
className={clsx("Checkbox", { "is-checked": checked })}
onClick={(event) => {
onChange(!checked);
((event.currentTarget as HTMLDivElement).querySelector(
".Checkbox-box",
) as HTMLButtonElement).focus();
}}
>
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
{checkIcon}
</button>
<div className="Checkbox-label">{children}</div>
</div>
);
};

View File

@@ -1,7 +1,8 @@
import React from "react";
import clsx from "clsx";
import { ToolButton } from "./ToolButton";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { users } from "./icons";
import "./CollabButton.scss";

View File

@@ -160,7 +160,7 @@
}
.color-picker-input {
width: 11ch; /* length of `transparent` */
width: 12ch; /* length of `transparent` + 1 */
margin: 0;
font-size: 1rem;
background-color: var(--input-bg-color);
@@ -218,7 +218,7 @@
left: 2px;
}
@include isMobile {
@media #{$is-mobile-query} {
display: none;
}
}

View File

@@ -1,6 +1,5 @@
import React from "react";
import { Popover } from "./Popover";
import { isTransparent } from "../utils";
import "./ColorPicker.scss";
import { isArrowKey, KEYS } from "../keys";
@@ -15,7 +14,7 @@ const isValidColor = (color: string) => {
};
const getColor = (color: string): string | null => {
if (isTransparent(color)) {
if (color === "transparent") {
return color;
}
@@ -116,7 +115,6 @@ const Picker = ({
onClose();
}
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
};
return (
@@ -138,19 +136,15 @@ const Picker = ({
}}
tabIndex={0}
>
{colors.map((_color, i) => {
const _colorWithoutHash = _color.replace("#", "");
return (
{colors.map((_color, i) => (
<button
className="color-picker-swatch"
onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(_color);
}}
title={`${t(`colors.${_colorWithoutHash}`)}${
!isTransparent(_color) ? ` (${_color})` : ""
}${keyBindings[i].toUpperCase()}`}
aria-label={t(`colors.${_colorWithoutHash}`)}
title={`${_color}${keyBindings[i].toUpperCase()}`}
aria-label={_color}
aria-keyshortcuts={keyBindings[i]}
style={{ color: _color }}
key={_color}
@@ -166,13 +160,12 @@ const Picker = ({
onChange(_color);
}}
>
{isTransparent(_color) ? (
{_color === "transparent" ? (
<div className="color-picker-transparent"></div>
) : undefined}
<span className="color-picker-keybinding">{keyBindings[i]}</span>
</button>
);
})}
))}
{showInput && (
<ColorInput
color={color}
@@ -244,16 +237,13 @@ export const ColorPicker = ({
color,
onChange,
label,
isActive,
setActive,
}: {
type: "canvasBackground" | "elementBackground" | "elementStroke";
color: string | null;
onChange: (color: string) => void;
label: string;
isActive: boolean;
setActive: (active: boolean) => void;
}) => {
const [isActive, setActive] = React.useState(false);
const pickerButton = React.useRef<HTMLButtonElement>(null);
return (

View File

@@ -76,7 +76,7 @@
z-index: 1;
}
@include isMobile {
@media #{$is-mobile-query} {
.context-menu-option {
display: block;

View File

@@ -1,3 +1,4 @@
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import clsx from "clsx";
import { Popover } from "./Popover";
@@ -31,7 +32,15 @@ const ContextMenu = ({
actionManager,
appState,
}: ContextMenuProps) => {
const isDarkTheme = !!document
.querySelector(".excalidraw")
?.classList.contains("theme--dark");
return (
<div
className={clsx("excalidraw", {
"theme--dark theme--dark-background-none": isDarkTheme,
})}
>
<Popover
onCloseRequest={onCloseRequest}
top={top}
@@ -72,22 +81,18 @@ const ContextMenu = ({
})}
</ul>
</Popover>
</div>
);
};
const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>();
const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
let contextMenuNode = contextMenuNodeByContainer.get(container);
let contextMenuNode: HTMLDivElement;
const getContextMenuNode = (): HTMLDivElement => {
if (contextMenuNode) {
return contextMenuNode;
}
contextMenuNode = document.createElement("div");
container
.querySelector(".excalidraw-contextMenuContainer")!
.appendChild(contextMenuNode);
contextMenuNodeByContainer.set(container, contextMenuNode);
return contextMenuNode;
const div = document.createElement("div");
document.body.appendChild(div);
return (contextMenuNode = div);
};
type ContextMenuParams = {
@@ -96,16 +101,10 @@ type ContextMenuParams = {
left: ContextMenuProps["left"];
actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>;
container: HTMLElement;
};
const handleClose = (container: HTMLElement) => {
const contextMenuNode = contextMenuNodeByContainer.get(container);
if (contextMenuNode) {
unmountComponentAtNode(contextMenuNode);
contextMenuNode.remove();
contextMenuNodeByContainer.delete(container);
}
const handleClose = () => {
unmountComponentAtNode(getContextMenuNode());
};
export default {
@@ -122,11 +121,11 @@ export default {
top={params.top}
left={params.left}
options={options}
onCloseRequest={() => handleClose(params.container)}
onCloseRequest={handleClose}
actionManager={params.actionManager}
appState={params.appState}
/>,
getContextMenuNode(params.container),
getContextMenuNode(),
);
}
},

View File

@@ -1,32 +1,42 @@
import "./ToolIcon.scss";
import React from "react";
import { t } from "../i18n";
import { ToolButton } from "./ToolButton";
import { THEME } from "../constants";
import { Theme } from "../element/types";
export type Appearence = "light" | "dark";
// We chose to use only explicit toggle and not a third option for system value,
// but this could be added in the future.
export const DarkModeToggle = (props: {
value: Theme;
onChange: (value: Theme) => void;
value: Appearence;
onChange: (value: Appearence) => void;
title?: string;
}) => {
const title =
props.title ||
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
const title = props.title
? props.title
: props.value === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode");
return (
<ToolButton
type="icon"
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
title={title}
aria-label={title}
onClick={() =>
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
}
<label
className="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
data-testid="toggle-dark-mode"
title={title}
>
<input
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
type="checkbox"
onChange={(event) =>
props.onChange(event.target.checked ? "dark" : "light")
}
checked={props.value === "dark"}
aria-label={title}
/>
<div className="ToolIcon__icon">
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
</div>
</label>
);
};

View File

@@ -31,7 +31,7 @@
padding: 0 16px 16px;
}
@include isMobile {
@media #{$is-mobile-query} {
.Dialog {
--metric: calc(var(--space-factor) * 4);
--inset-left: #{"max(var(--metric), var(--sal))"};

View File

@@ -1,14 +1,13 @@
import clsx from "clsx";
import React, { useEffect, useState } from "react";
import React, { useEffect } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import { useExcalidrawContainer, useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { KEYS } from "../keys";
import "./Dialog.scss";
import { back, close } from "./icons";
import { Island } from "./Island";
import { Modal } from "./Modal";
import { AppState } from "../types";
export const Dialog = (props: {
children: React.ReactNode;
@@ -17,11 +16,8 @@ export const Dialog = (props: {
onCloseRequest(): void;
title: React.ReactNode;
autofocus?: boolean;
theme?: AppState["theme"];
}) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer();
useEffect(() => {
if (!islandNode) {
@@ -69,25 +65,19 @@ export const Dialog = (props: {
return focusableElements ? Array.from(focusableElements) : [];
};
const onClose = () => {
(lastActiveElement as HTMLElement).focus();
props.onCloseRequest();
};
return (
<Modal
className={clsx("Dialog", props.className)}
labelledBy="dialog-title"
maxWidth={props.small ? 550 : 800}
onCloseRequest={onClose}
theme={props.theme}
onCloseRequest={props.onCloseRequest}
>
<Island ref={setIslandNode}>
<h2 id={`${id}-dialog-title`} className="Dialog__title">
<h2 id="dialog-title" className="Dialog__title">
<span className="Dialog__titleContent">{props.title}</span>
<button
className="Modal__close"
onClick={onClose}
onClick={props.onCloseRequest}
aria-label={t("buttons.close")}
>
{useIsMobile() ? back : close}

View File

@@ -2,7 +2,6 @@ import React, { useState } from "react";
import { t } from "../i18n";
import { Dialog } from "./Dialog";
import { useExcalidrawContainer } from "./App";
export const ErrorDialog = ({
message,
@@ -12,7 +11,6 @@ export const ErrorDialog = ({
onClose?: () => void;
}) => {
const [modalIsShown, setModalIsShown] = useState(!!message);
const { container: excalidrawContainer } = useExcalidrawContainer();
const handleClose = React.useCallback(() => {
setModalIsShown(false);
@@ -20,9 +18,7 @@ export const ErrorDialog = ({
if (onClose) {
onClose();
}
// TODO: Fix the A11y issues so this is never needed since we should always focus on last active element
excalidrawContainer?.focus();
}, [onClose, excalidrawContainer]);
}, [onClose]);
return (
<>
@@ -32,7 +28,14 @@ export const ErrorDialog = ({
onCloseRequest={handleClose}
title={t("errorDialog.title")}
>
<div style={{ whiteSpace: "pre-wrap" }}>{message}</div>
<div>
{message.split("\n").map((line) => (
<>
{line}
<br />
</>
))}
</div>
</Dialog>
)}
</>

View File

@@ -28,7 +28,24 @@
justify-content: space-between;
}
@include isMobile {
.ExportDialog__name {
grid-column: project-name;
margin: auto;
.TextInput {
height: calc(1rem - 3px);
&--readonly {
background: none;
border: none;
&:hover {
background: none;
}
}
}
}
@media #{$is-mobile-query} {
.ExportDialog {
display: flex;
flex-direction: column;
@@ -57,63 +74,4 @@
overflow-y: auto;
}
}
.ExportDialog--json {
.ExportDialog-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
justify-items: center;
row-gap: 2em;
@media (max-width: 460px) {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
.Card-details {
min-height: 40px;
}
}
.ProjectName {
width: fit-content;
margin: 1em auto;
align-items: flex-start;
flex-direction: column;
.TextInput {
width: auto;
}
}
.ProjectName-label {
margin: 0.625em 0;
font-weight: bold;
}
}
}
button.ExportDialog-imageExportButton {
width: 5rem;
height: 5rem;
margin: 0 0.2em;
border-radius: 1rem;
background-color: var(--button-color);
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.28),
0 6px 10px 0 rgba(0, 0, 0, 0.14);
font-family: Cascadia;
font-size: 1.8em;
color: $oc-white;
&:hover {
background-color: var(--button-color-darker);
}
&:active {
background-color: var(--button-color-darkest);
box-shadow: none;
}
svg {
width: 0.9em;
}
}
}

View File

@@ -6,19 +6,18 @@ import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import useIsMobile from "../is-mobile";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
import { exportToCanvas, getExportSize } from "../scene/export";
import { AppState } from "../types";
import { Dialog } from "./Dialog";
import { clipboard, exportImage } from "./icons";
import "./ExportDialog.scss";
import { clipboard, exportFile, link } from "./icons";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import "./ExportDialog.scss";
import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem";
const scales = [1, 2, 3];
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@@ -53,37 +52,15 @@ export type ExportCB = (
scale?: number,
) => void;
const ExportButton: React.FC<{
color: keyof OpenColor;
onClick: () => void;
title: string;
shade?: number;
}> = ({ children, title, onClick, color, shade = 6 }) => {
return (
<button
className="ExportDialog-imageExportButton"
style={{
["--button-color" as any]: OpenColor[color][shade],
["--button-color-darker" as any]: OpenColor[color][shade + 1],
["--button-color-darkest" as any]: OpenColor[color][shade + 2],
}}
title={title}
aria-label={title}
onClick={onClick}
>
{children}
</button>
);
};
const ImageExportModal = ({
const ExportModal = ({
elements,
appState,
exportPadding = DEFAULT_EXPORT_PADDING,
exportPadding = 10,
actionManager,
onExportToPng,
onExportToSvg,
onExportToClipboard,
onExportToBackend,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
@@ -92,12 +69,18 @@ const ImageExportModal = ({
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
onExportToBackend?: ExportCB;
onCloseRequest: () => void;
}) => {
const someElementIsSelected = isSomeElementSelected(elements, appState);
const [scale, setScale] = useState(defaultScale);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previewRef = useRef<HTMLDivElement>(null);
const { exportBackground, viewBackgroundColor } = appState;
const {
exportBackground,
viewBackgroundColor,
shouldAddWatermark,
} = appState;
const exportedElements = exportSelected
? getSelectedElements(elements, appState)
@@ -117,6 +100,8 @@ const ImageExportModal = ({
exportBackground,
viewBackgroundColor,
exportPadding,
scale,
shouldAddWatermark,
});
// if converting to blob fails, there's some problem that will
@@ -139,6 +124,8 @@ const ImageExportModal = ({
exportBackground,
exportPadding,
viewBackgroundColor,
scale,
shouldAddWatermark,
]);
return (
@@ -146,85 +133,106 @@ const ImageExportModal = ({
<div className="ExportDialog__preview" ref={previewRef} />
{supportsContextFilters &&
actionManager.renderAction("exportWithDarkMode")}
<div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))",
// dunno why this is needed, but when the items wrap it creates
// an overflow
overflow: "hidden",
}}
>
{actionManager.renderAction("changeExportBackground")}
{someElementIsSelected && (
<CheckboxItem
checked={exportSelected}
onChange={(checked) => setExportSelected(checked)}
>
{t("labels.onlySelected")}
</CheckboxItem>
)}
{actionManager.renderAction("changeExportEmbedScene")}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
<Stack.Col gap={2} align="center">
<div className="ExportDialog__actions">
<Stack.Row gap={2}>
{actionManager.renderAction("changeExportScale")}
</Stack.Row>
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: ".6em 0",
}}
>
{!nativeFileSystemSupported &&
actionManager.renderAction("changeProjectName")}
</div>
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
<ExportButton
color="indigo"
<ToolButton
type="button"
label="PNG"
title={t("buttons.exportToPng")}
aria-label={t("buttons.exportToPng")}
onClick={() => onExportToPng(exportedElements)}
>
PNG
</ExportButton>
<ExportButton
color="red"
onClick={() => onExportToPng(exportedElements, scale)}
/>
<ToolButton
type="button"
label="SVG"
title={t("buttons.exportToSvg")}
aria-label={t("buttons.exportToSvg")}
onClick={() => onExportToSvg(exportedElements)}
>
SVG
</ExportButton>
onClick={() => onExportToSvg(exportedElements, scale)}
/>
{probablySupportsClipboardBlob && (
<ExportButton
<ToolButton
type="button"
icon={clipboard}
title={t("buttons.copyPngToClipboard")}
onClick={() => onExportToClipboard(exportedElements)}
color="gray"
shade={7}
>
{clipboard}
</ExportButton>
aria-label={t("buttons.copyPngToClipboard")}
onClick={() => onExportToClipboard(exportedElements, scale)}
/>
)}
{onExportToBackend && (
<ToolButton
type="button"
icon={link}
title={t("buttons.getShareableLink")}
aria-label={t("buttons.getShareableLink")}
onClick={() => onExportToBackend(exportedElements)}
/>
)}
</Stack.Row>
<div className="ExportDialog__name">
{actionManager.renderAction("changeProjectName")}
</div>
<Stack.Row gap={2}>
{scales.map((s) => {
const [width, height] = getExportSize(
exportedElements,
exportPadding,
shouldAddWatermark,
s,
);
const scaleButtonTitle = `${t(
"buttons.scale",
)} ${s}x (${width}x${height})`;
return (
<ToolButton
key={s}
size="s"
type="radio"
icon={`${s}x`}
name="export-canvas-scale"
title={scaleButtonTitle}
aria-label={scaleButtonTitle}
id="export-canvas-scale"
checked={s === scale}
onChange={() => setScale(s)}
/>
);
})}
</Stack.Row>
</div>
{actionManager.renderAction("changeExportBackground")}
{someElementIsSelected && (
<div>
<label>
<input
type="checkbox"
checked={exportSelected}
onChange={(event) =>
setExportSelected(event.currentTarget.checked)
}
/>{" "}
{t("labels.onlySelected")}
</label>
</div>
)}
{actionManager.renderAction("changeExportEmbedScene")}
{actionManager.renderAction("changeShouldAddWatermark")}
</Stack.Col>
</div>
);
};
export const ImageExportDialog = ({
export const ExportDialog = ({
elements,
appState,
exportPadding = DEFAULT_EXPORT_PADDING,
exportPadding = 10,
actionManager,
onExportToPng,
onExportToSvg,
onExportToClipboard,
onExportToBackend,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
@@ -233,11 +241,14 @@ export const ImageExportDialog = ({
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
onExportToBackend?: ExportCB;
}) => {
const [modalIsShown, setModalIsShown] = useState(false);
const triggerButton = useRef<HTMLButtonElement>(null);
const handleClose = React.useCallback(() => {
setModalIsShown(false);
triggerButton.current?.focus();
}, []);
return (
@@ -246,16 +257,17 @@ export const ImageExportDialog = ({
onClick={() => {
setModalIsShown(true);
}}
data-testid="image-export-button"
icon={exportImage}
data-testid="export-button"
icon={exportFile}
type="button"
aria-label={t("buttons.exportImage")}
aria-label={t("buttons.export")}
showAriaLabel={useIsMobile()}
title={t("buttons.exportImage")}
title={t("buttons.export")}
ref={triggerButton}
/>
{modalIsShown && (
<Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
<ImageExportModal
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<ExportModal
elements={elements}
appState={appState}
exportPadding={exportPadding}
@@ -263,6 +275,7 @@ export const ImageExportDialog = ({
onExportToPng={onExportToPng}
onExportToSvg={onExportToSvg}
onExportToClipboard={onExportToClipboard}
onExportToBackend={onExportToBackend}
onCloseRequest={handleClose}
/>
</Dialog>

View File

@@ -1,5 +1,6 @@
.excalidraw {
.FixedSideContainer {
--margin: 0.25rem;
position: absolute;
pointer-events: none;
}
@@ -9,9 +10,9 @@
}
.FixedSideContainer_side_top {
left: var(--space-factor);
top: var(--space-factor);
right: var(--space-factor);
left: var(--margin);
top: var(--margin);
right: var(--margin);
z-index: 2;
}
@@ -22,16 +23,16 @@
/* TODO: if these are used, make sure to implement RTL support
.FixedSideContainer_side_left {
left: var(--space-factor);
top: var(--space-factor);
bottom: var(--space-factor);
left: var(--margin);
top: var(--margin);
bottom: var(--margin);
z-index: 1;
}
.FixedSideContainer_side_right {
right: var(--space-factor);
top: var(--space-factor);
bottom: var(--space-factor);
right: var(--margin);
top: var(--margin);
bottom: var(--margin);
z-index: 3;
}
*/

View File

@@ -1,23 +1,15 @@
import oc from "open-color";
import React from "react";
import { THEME } from "../../constants";
import { Theme } from "../../element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(
({ theme, dir }: { theme: Theme; dir: string }) => (
({ theme }: { theme: "light" | "dark" }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 250 250"
className="rtl-mirror"
style={{
marginTop: "calc(var(--space-factor) * -1)",
[dir === "rtl"
? "marginLeft"
: "marginRight"]: "calc(var(--space-factor) * -1)",
}}
className="github-corner rtl-mirror"
>
<a
href="https://github.com/excalidraw/excalidraw"
@@ -27,18 +19,18 @@ export const GitHubCorner = React.memo(
>
<path
d="M0 0l115 115h15l12 27 108 108V0z"
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
fill={theme === "light" ? oc.gray[6] : oc.gray[8]}
/>
<path
className="octo-arm"
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
style={{ transformOrigin: "130px 106px" }}
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
fill={theme === "light" ? oc.white : oc.black}
/>
<path
className="octo-body"
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
fill={theme === "light" ? oc.white : oc.black}
/>
</a>
</svg>

View File

@@ -153,17 +153,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
<Shortcut
label={t("toolBar.freedraw")}
label={t("toolBar.draw")}
shortcuts={["Shift+P", "7"]}
/>
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut
label={t("helpDialog.editSelectedShape")}
shortcuts={[
getShortcutKey("Enter"),
t("helpDialog.doubleClick"),
]}
/>
<Shortcut
label={t("helpDialog.textNewLine")}
shortcuts={[
@@ -238,14 +231,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.viewMode")}
shortcuts={[getShortcutKey("Alt+R")]}
/>
<Shortcut
label={t("labels.toggleTheme")}
shortcuts={[getShortcutKey("Alt+Shift+D")]}
/>
<Shortcut
label={t("stats.title")}
shortcuts={[getShortcutKey("Alt+/")]}
/>
</ShortcutIsland>
</Column>
<Column>
@@ -364,22 +349,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.ungroup")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
/>
<Shortcut
label={t("labels.flipHorizontal")}
shortcuts={[getShortcutKey("Shift+H")]}
/>
<Shortcut
label={t("labels.flipVertical")}
shortcuts={[getShortcutKey("Shift+V")]}
/>
<Shortcut
label={t("labels.showStroke")}
shortcuts={[getShortcutKey("S")]}
/>
<Shortcut
label={t("labels.showBackground")}
shortcuts={[getShortcutKey("G")]}
/>
</ShortcutIsland>
</Column>
</Columns>

View File

@@ -1,3 +1,4 @@
import React from "react";
import { questionCircle } from "../components/icons";
type HelpIconProps = {
@@ -8,13 +9,7 @@ type HelpIconProps = {
};
export const HelpIcon = (props: HelpIconProps) => (
<button
className="help-icon"
onClick={props.onClick}
type="button"
title={`${props.title} — ?`}
aria-label={props.title}
>
{questionCircle}
</button>
<label title={`${props.title} — ?`} className="help-icon">
<div onClick={props.onClick}>{questionCircle}</div>
</label>
);

View File

@@ -19,7 +19,7 @@ $wide-viewport-width: 1000px;
color: $oc-gray-6;
font-size: 0.8rem;
@include isMobile {
@media #{$is-mobile-query} {
position: static;
padding-right: 2em;
}

View File

@@ -1,10 +1,11 @@
import React from "react";
import { t } from "../i18n";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene";
import "./HintViewer.scss";
import { AppState } from "../types";
import { isLinearElement, isTextElement } from "../element/typeChecks";
import { isLinearElement } from "../element/typeChecks";
import { getShortcutKey } from "../utils";
interface Hint {
@@ -22,7 +23,7 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.linearElementMulti");
}
if (elementType === "freedraw") {
if (elementType === "draw") {
return t("hints.freeDraw");
}
@@ -56,14 +57,6 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.lineEditor_info");
}
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
return t("hints.text_selected");
}
if (appState.editingElement && isTextElement(appState.editingElement)) {
return t("hints.text_editing");
}
return null;
};

View File

@@ -111,7 +111,7 @@
:root[dir="rtl"] & {
left: 2px;
}
@include isMobile {
@media #{$is-mobile-query} {
display: none;
}
}

View File

@@ -88,7 +88,6 @@ function Picker<T>({
onClose();
}
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
};
return (

View File

@@ -1,25 +1,30 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { LoadingMessage } from "./LoadingMessage";
import { defaultLang, Language, languages, setLanguage } from "../i18n";
interface Props {
langCode: Language["code"];
children: React.ReactElement;
}
interface State {
isLoading: boolean;
}
export class InitializeApp extends React.Component<Props, State> {
public state: { isLoading: boolean } = {
isLoading: true,
};
async componentDidMount() {
const currentLang =
languages.find((lang) => lang.code === this.props.langCode) ||
defaultLang;
await setLanguage(currentLang);
this.setState({
isLoading: false,
});
}
export const InitializeApp = (props: Props) => {
const [loading, setLoading] = useState(true);
useEffect(() => {
const updateLang = async () => {
await setLanguage(currentLang);
};
const currentLang =
languages.find((lang) => lang.code === props.langCode) || defaultLang;
updateLang();
setLoading(false);
}, [props.langCode]);
return loading ? <LoadingMessage /> : props.children;
};
public render() {
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
}
}

View File

@@ -2,6 +2,7 @@
.Island {
--padding: 0;
background-color: var(--island-bg-color);
backdrop-filter: saturate(100%) blur(10px);
box-shadow: var(--shadow-island);
border-radius: 4px;
padding: calc(var(--padding) * var(--space-factor));

View File

@@ -1,128 +0,0 @@
import React, { useState } from "react";
import { ActionsManagerInterface } from "../actions/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { AppState, ExportOpts } from "../types";
import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons";
import { ToolButton } from "./ToolButton";
import { actionSaveFileToDisk } from "../actions/actionExport";
import { Card } from "./Card";
import "./ExportDialog.scss";
import { nativeFileSystemSupported } from "../data/filesystem";
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
scale?: number,
) => void;
const JSONExportModal = ({
elements,
appState,
actionManager,
exportOpts,
canvas,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
onCloseRequest: () => void;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
}) => {
const { onExportToBackend } = exportOpts;
return (
<div className="ExportDialog ExportDialog--json">
<div className="ExportDialog-cards">
{exportOpts.saveFileToDisk && (
<Card color="lime">
<div className="Card-icon">{exportToFileIcon}</div>
<h2>{t("exportDialog.disk_title")}</h2>
<div className="Card-details">
{t("exportDialog.disk_details")}
{!nativeFileSystemSupported &&
actionManager.renderAction("changeProjectName")}
</div>
<ToolButton
className="Card-button"
type="button"
title={t("exportDialog.disk_button")}
aria-label={t("exportDialog.disk_button")}
showAriaLabel={true}
onClick={() => {
actionManager.executeAction(actionSaveFileToDisk);
}}
/>
</Card>
)}
{onExportToBackend && (
<Card color="pink">
<div className="Card-icon">{link}</div>
<h2>{t("exportDialog.link_title")}</h2>
<div className="Card-details">{t("exportDialog.link_details")}</div>
<ToolButton
className="Card-button"
type="button"
title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")}
showAriaLabel={true}
onClick={() => onExportToBackend(elements, appState, canvas)}
/>
</Card>
)}
{exportOpts.renderCustomUI &&
exportOpts.renderCustomUI(elements, appState, canvas)}
</div>
</div>
);
};
export const JSONExportDialog = ({
elements,
appState,
actionManager,
exportOpts,
canvas,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
}) => {
const [modalIsShown, setModalIsShown] = useState(false);
const handleClose = React.useCallback(() => {
setModalIsShown(false);
}, []);
return (
<>
<ToolButton
onClick={() => {
setModalIsShown(true);
}}
data-testid="json-export-button"
icon={exportFile}
type="button"
aria-label={t("buttons.export")}
showAriaLabel={useIsMobile()}
title={t("buttons.export")}
/>
{modalIsShown && (
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<JSONExportModal
elements={elements}
appState={appState}
actionManager={actionManager}
onCloseRequest={handleClose}
exportOpts={exportOpts}
canvas={canvas}
/>
</Dialog>
)}
</>
);
};

View File

@@ -40,17 +40,50 @@
.layer-ui__wrapper {
z-index: var(--zIndex-layerUI);
&__top-right {
.encrypted-icon {
position: relative;
margin-inline-start: 15px;
display: flex;
justify-content: center;
align-items: center;
border-radius: var(--space-factor);
color: $oc-green-9;
svg {
width: 1.2rem;
height: 1.2rem;
}
}
&__github-corner {
top: 0;
:root[dir="ltr"] & {
right: 0;
}
:root[dir="rtl"] & {
left: 0;
}
position: absolute;
width: 40px;
}
&__footer {
width: 100%;
&-right {
position: absolute;
z-index: 100;
display: flex;
bottom: 0;
:root[dir="ltr"] & {
right: 0;
}
:root[dir="rtl"] & {
left: 0;
}
width: 190px;
}
.zen-mode-transition {
@@ -72,15 +105,11 @@
transform: translate(-999px, 0);
}
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
transform: translate(-76px, 0);
:root[dir="ltr"] &.App-menu_bottom--transition-left {
transform: translate(-92px, 0);
}
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
transform: translate(76px, 0);
}
&.layer-ui__wrapper__footer-left--transition-bottom {
transform: translate(0, 92px);
:root[dir="rtl"] &.App-menu_bottom--transition-left {
transform: translate(92px, 0);
}
}
@@ -108,27 +137,5 @@
transition-delay: 0.8s;
}
}
.layer-ui__wrapper__footer-center {
pointer-events: none;
& > * {
pointer-events: all;
}
}
.layer-ui__wrapper__footer-left,
.layer-ui__wrapper__footer-right,
.disable-zen-mode--visible {
pointer-events: all;
}
.layer-ui__wrapper__footer-left {
margin-bottom: 0.2em;
}
.layer-ui__wrapper__footer-right {
margin-top: auto;
margin-bottom: auto;
margin-inline-end: 1em;
}
}
}

View File

@@ -10,33 +10,29 @@ import { ActionManager } from "../actions/manager";
import { CLASSES } from "../constants";
import { exportCanvas } from "../data";
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import { Library } from "../data/library";
import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { calculateScrollCenter, getSelectedElements } from "../scene";
import { ExportType } from "../scene/types";
import {
AppProps,
AppState,
ExcalidrawProps,
LibraryItem,
LibraryItems,
} from "../types";
import { AppState, ExcalidrawProps, LibraryItem, LibraryItems } from "../types";
import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog";
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
import { ExportCB, ExportDialog } from "./ExportDialog";
import { FixedSideContainer } from "./FixedSideContainer";
import { GitHubCorner } from "./GitHubCorner";
import { HintViewer } from "./HintViewer";
import { exportFile, load, trash } from "./icons";
import { exportFile, load, shield, trash } from "./icons";
import { Island } from "./Island";
import "./LayerUI.scss";
import { LibraryUnit } from "./LibraryUnit";
import { LoadingMessage } from "./LoadingMessage";
import { LockButton } from "./LockButton";
import { LockIcon } from "./LockIcon";
import { MobileMenu } from "./MobileMenu";
import { PasteChartDialog } from "./PasteChartDialog";
import { Section } from "./Section";
@@ -45,10 +41,6 @@ import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip";
import { UserList } from "./UserList";
import Library from "../data/library";
import { JSONExportDialog } from "./JSONExportDialog";
import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob";
interface LayerUIProps {
actionManager: ActionManager;
@@ -65,14 +57,14 @@ interface LayerUIProps {
toggleZenMode: () => void;
langCode: Language["code"];
isCollaborating: boolean;
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
onExportToBackend?: (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement | null,
) => void;
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"];
focusContainer: () => void;
library: Library;
id: string;
}
const useOnClickOutside = (
@@ -104,44 +96,35 @@ const useOnClickOutside = (
};
const LibraryMenuItems = ({
libraryItems,
library,
onRemoveFromLibrary,
onAddToLibrary,
onInsertShape,
pendingElements,
theme,
setAppState,
setLibraryItems,
libraryReturnUrl,
focusContainer,
library,
id,
}: {
libraryItems: LibraryItems;
library: LibraryItems;
pendingElements: LibraryItem;
onRemoveFromLibrary: (index: number) => void;
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: (elements: LibraryItem) => void;
theme: AppState["theme"];
setAppState: React.Component<any, AppState>["setState"];
setLibraryItems: (library: LibraryItems) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
library: Library;
id: string;
}) => {
const isMobile = useIsMobile();
const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
const CELLS_PER_ROW = isMobile ? 4 : 6;
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
const rows = [];
let addedPendingElements = false;
const referrer =
libraryReturnUrl || window.location.origin + window.location.pathname;
const referrer = libraryReturnUrl || window.location.origin;
rows.push(
<div className="layer-ui__library-header" key="library-header">
<div className="layer-ui__library-header">
<ToolButton
key="import"
type="button"
@@ -149,11 +132,11 @@ const LibraryMenuItems = ({
aria-label={t("buttons.load")}
icon={load}
onClick={() => {
importLibraryFromJSON(library)
importLibraryFromJSON()
.then(() => {
// Close and then open to get the libraries updated
// Maybe we should close and open the menu so that the items get updated.
// But for now we just close the menu.
setAppState({ isLibraryOpen: false });
setAppState({ isLibraryOpen: true });
})
.catch(muteFSAbortError)
.catch((error) => {
@@ -161,8 +144,6 @@ const LibraryMenuItems = ({
});
}}
/>
{!!libraryItems.length && (
<>
<ToolButton
key="export"
type="button"
@@ -170,7 +151,7 @@ const LibraryMenuItems = ({
aria-label={t("buttons.export")}
icon={exportFile}
onClick={() => {
saveLibraryAsJSON(library)
saveLibraryAsJSON()
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
@@ -185,18 +166,14 @@ const LibraryMenuItems = ({
icon={trash}
onClick={() => {
if (window.confirm(t("alerts.resetLibrary"))) {
library.resetLibrary();
Library.resetLibrary();
setLibraryItems([]);
focusContainer();
}
}}
/>
</>
)}
<a
href={`https://libraries.excalidraw.com?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
href={`https://libraries.excalidraw.com?referrer=${referrer}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
@@ -211,13 +188,13 @@ const LibraryMenuItems = ({
const shouldAddPendingElements: boolean =
pendingElements.length > 0 &&
!addedPendingElements &&
y + x >= libraryItems.length;
y + x >= library.length;
addedPendingElements = addedPendingElements || shouldAddPendingElements;
children.push(
<Stack.Col key={x}>
<LibraryUnit
elements={libraryItems[y + x]}
elements={library[y + x]}
pendingElements={
shouldAddPendingElements ? pendingElements : undefined
}
@@ -225,7 +202,7 @@ const LibraryMenuItems = ({
onClick={
shouldAddPendingElements
? onAddToLibrary.bind(null, pendingElements)
: onInsertShape.bind(null, libraryItems[y + x])
: onInsertShape.bind(null, library[y + x])
}
/>
</Stack.Col>,
@@ -250,23 +227,15 @@ const LibraryMenu = ({
onInsertShape,
pendingElements,
onAddToLibrary,
theme,
setAppState,
libraryReturnUrl,
focusContainer,
library,
id,
}: {
pendingElements: LibraryItem;
onClickOutside: (event: MouseEvent) => void;
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: () => void;
theme: AppState["theme"];
setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
library: Library;
id: string;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useOnClickOutside(ref, (event) => {
@@ -292,7 +261,7 @@ const LibraryMenu = ({
resolve("loading");
}, 100);
}),
library.loadLibrary().then((items) => {
Library.loadLibrary().then((items) => {
setLibraryItems(items);
setIsLoading("ready");
}),
@@ -304,33 +273,24 @@ const LibraryMenu = ({
return () => {
clearTimeout(loadingTimerRef.current!);
};
}, [library]);
}, []);
const removeFromLibrary = useCallback(
async (indexToRemove) => {
const items = await library.loadLibrary();
const removeFromLibrary = useCallback(async (indexToRemove) => {
const items = await Library.loadLibrary();
const nextItems = items.filter((_, index) => index !== indexToRemove);
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
Library.saveLibrary(nextItems);
setLibraryItems(nextItems);
},
[library, setAppState],
);
}, []);
const addToLibrary = useCallback(
async (elements: LibraryItem) => {
const items = await library.loadLibrary();
const items = await Library.loadLibrary();
const nextItems = [...items, elements];
onAddToLibrary();
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
Library.saveLibrary(nextItems);
setLibraryItems(nextItems);
},
[onAddToLibrary, library, setAppState],
[onAddToLibrary],
);
return loadingState === "preloading" ? null : (
@@ -341,7 +301,7 @@ const LibraryMenu = ({
</div>
) : (
<LibraryMenuItems
libraryItems={libraryItems}
library={libraryItems}
onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape}
@@ -349,10 +309,6 @@ const LibraryMenu = ({
setAppState={setAppState}
setLibraryItems={setLibraryItems}
libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
library={library}
theme={theme}
id={id}
/>
)}
</Island>
@@ -373,77 +329,69 @@ const LayerUI = ({
showThemeBtn,
toggleZenMode,
isCollaborating,
renderTopRightUI,
onExportToBackend,
renderCustomFooter,
viewModeEnabled,
libraryReturnUrl,
UIOptions,
focusContainer,
library,
id,
}: LayerUIProps) => {
const isMobile = useIsMobile();
const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) {
return null;
}
return (
<JSONExportDialog
elements={elements}
appState={appState}
actionManager={actionManager}
exportOpts={UIOptions.canvasActions.export}
canvas={canvas}
/>
const renderEncryptedIcon = () => (
<a
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
"zen-mode-visibility--hidden": zenModeEnabled,
})}
href="https://blog.excalidraw.com/end-to-end-encryption/"
target="_blank"
rel="noopener noreferrer"
>
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
{shield}
</Tooltip>
</a>
);
};
const renderImageExportDialog = () => {
if (!UIOptions.canvasActions.saveAsImage) {
return null;
}
const renderExportDialog = () => {
const createExporter = (type: ExportType): ExportCB => async (
exportedElements,
scale,
) => {
const fileHandle = await exportCanvas(type, exportedElements, appState, {
if (canvas) {
await exportCanvas(type, exportedElements, appState, canvas, {
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
scale,
shouldAddWatermark: appState.shouldAddWatermark,
})
.catch(muteFSAbortError)
.catch((error) => {
console.error(error);
setAppState({ errorMessage: error.message });
});
if (
appState.exportEmbedScene &&
fileHandle &&
isImageFileHandle(fileHandle)
) {
setAppState({ fileHandle });
}
};
return (
<ImageExportDialog
<ExportDialog
elements={elements}
appState={appState}
actionManager={actionManager}
onExportToPng={createExporter("png")}
onExportToSvg={createExporter("svg")}
onExportToClipboard={createExporter("clipboard")}
onExportToBackend={
onExportToBackend
? (elements) => {
onExportToBackend &&
onExportToBackend(elements, appState, canvas);
}
: undefined
}
/>
);
};
const Separator = () => {
return <div style={{ width: ".625em" }} />;
};
const renderViewModeCanvasActions = () => {
return (
<Section
@@ -457,8 +405,9 @@ const LayerUI = ({
<Island padding={2} style={{ zIndex: 1 }}>
<Stack.Col gap={4}>
<Stack.Row gap={1} justifyContent="space-between">
{renderJSONExportDialog()}
{renderImageExportDialog()}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{renderExportDialog()}
</Stack.Row>
</Stack.Col>
</Island>
@@ -477,12 +426,11 @@ const LayerUI = ({
<Island padding={2} style={{ zIndex: 1 }}>
<Stack.Col gap={4}>
<Stack.Row gap={1} justifyContent="space-between">
{actionManager.renderAction("clearCanvas")}
<Separator />
{actionManager.renderAction("loadScene")}
{renderJSONExportDialog()}
{renderImageExportDialog()}
<Separator />
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{renderExportDialog()}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
@@ -497,9 +445,6 @@ const LayerUI = ({
setAppState={setAppState}
showThemeBtn={showThemeBtn}
/>
{appState.fileHandle && (
<>{actionManager.renderAction("saveToActiveFile")}</>
)}
</Stack.Col>
</Island>
</Section>
@@ -518,8 +463,7 @@ const LayerUI = ({
style={{
// we want to make sure this doesn't overflow so substracting 200
// which is approximately height of zoom footer and top left menu items with some buffer
// if active file name is displayed, subtracting 248 to account for its height
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
maxHeight: `${appState.height - 200}px`,
}}
>
<SelectedShapeActions
@@ -554,10 +498,6 @@ const LayerUI = ({
onAddToLibrary={deselectItems}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
library={library}
theme={appState.theme}
id={id}
/>
) : null;
@@ -584,12 +524,6 @@ const LayerUI = ({
{(heading) => (
<Stack.Col gap={4} align="start">
<Stack.Row gap={1}>
<LockButton
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<Island
padding={1}
className={clsx({ "zen-mode": zenModeEnabled })}
@@ -601,12 +535,15 @@ const LayerUI = ({
canvas={canvas}
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LibraryButton
appState={appState}
setAppState={setAppState}
<LockIcon
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
</Stack.Row>
{libraryMenu}
@@ -614,15 +551,11 @@ const LayerUI = ({
)}
</Section>
)}
<div
className={clsx(
"layer-ui__wrapper__top-right zen-mode-transition",
{
<UserList
className={clsx("zen-mode-transition", {
"transition-right": zenModeEnabled,
},
)}
})}
>
<UserList>
{appState.collaborators.size > 0 &&
Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
@@ -632,14 +565,10 @@ const LayerUI = ({
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
{actionManager.renderAction("goToCollaborator", clientId)}
</Tooltip>
))}
</UserList>
{renderTopRightUI?.(isMobile, appState)}
</div>
</div>
</FixedSideContainer>
);
@@ -647,17 +576,10 @@ const LayerUI = ({
const renderBottomAppMenu = () => {
return (
<footer
role="contentinfo"
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
>
<div
className={clsx(
"layer-ui__wrapper__footer-left zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-left": zenModeEnabled,
},
)}
className={clsx("App-menu App-menu_bottom zen-mode-transition", {
"App-menu_bottom--transition-left": zenModeEnabled,
})}
>
<Stack.Col gap={2}>
<Section heading="canvasActions">
@@ -667,37 +589,35 @@ const LayerUI = ({
zoom={appState.zoom}
/>
</Island>
{!viewModeEnabled && (
<div
className={clsx("undo-redo-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
})}
>
{actionManager.renderAction("undo", { size: "small" })}
{actionManager.renderAction("redo", { size: "small" })}
</div>
)}
{renderEncryptedIcon()}
</Section>
</Stack.Col>
</div>
<div
);
};
const renderGitHubCorner = () => {
return (
<aside
className={clsx(
"layer-ui__wrapper__footer-center zen-mode-transition",
"layer-ui__wrapper__github-corner zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
"transition-right": zenModeEnabled,
},
)}
>
{renderCustomFooter?.(false, appState)}
</div>
<GitHubCorner theme={appState.theme} />
</aside>
);
};
const renderFooter = () => (
<footer role="contentinfo" className="layer-ui__wrapper__footer">
<div
className={clsx(
"layer-ui__wrapper__footer-right zen-mode-transition",
{
className={clsx("zen-mode-transition", {
"transition-right disable-pointerEvents": zenModeEnabled,
},
)}
})}
>
{renderCustomFooter?.(false)}
{actionManager.renderAction("toggleShortcuts")}
</div>
<button
@@ -710,7 +630,6 @@ const LayerUI = ({
</button>
</footer>
);
};
const dialogs = (
<>
@@ -722,11 +641,7 @@ const LayerUI = ({
/>
)}
{appState.showHelpDialog && (
<HelpDialog
onClose={() => {
setAppState({ showHelpDialog: false });
}}
/>
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
)}
{appState.pasteDialog.shown && (
<PasteChartDialog
@@ -751,8 +666,7 @@ const LayerUI = ({
elements={elements}
actionManager={actionManager}
libraryMenu={libraryMenu}
renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog}
exportButton={renderExportDialog()}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={onLockToggle}
@@ -775,6 +689,8 @@ const LayerUI = ({
{dialogs}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
{renderGitHubCorner()}
{renderFooter()}
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"

View File

@@ -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>
);
};

View File

@@ -1,10 +1,10 @@
import clsx from "clsx";
import oc from "open-color";
import { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { close } from "../components/icons";
import { MIME_TYPES } from "../constants";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { exportToSvg } from "../scene/export";
import { LibraryItem } from "../types";
import "./LibraryUnit.scss";
@@ -36,27 +36,22 @@ export const LibraryUnit = ({
if (!elementsToRender) {
return;
}
let svg: SVGSVGElement;
const current = ref.current!;
(async () => {
svg = await exportToSvg(elementsToRender, {
const svg = exportToSvg(elementsToRender, {
exportBackground: false,
viewBackgroundColor: oc.white,
shouldAddWatermark: false,
});
for (const child of ref.current!.children) {
if (child.tagName !== "svg") {
continue;
}
current!.removeChild(child);
ref.current!.removeChild(child);
}
current!.appendChild(svg);
})();
ref.current!.appendChild(svg);
const current = ref.current!;
return () => {
if (svg) {
current.removeChild(svg);
}
};
}, [elements, pendingElements]);

View File

@@ -1,3 +1,4 @@
import React from "react";
import { t } from "../i18n";
export const LoadingMessage = () => {

View File

@@ -2,17 +2,20 @@ import "./ToolIcon.scss";
import React from "react";
import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
type LockIconSize = "s" | "m";
type LockIconProps = {
title?: string;
name?: string;
id?: string;
checked: boolean;
onChange?(): void;
size?: LockIconSize;
zenModeEnabled?: boolean;
};
const DEFAULT_SIZE: ToolButtonSize = "medium";
const DEFAULT_SIZE: LockIconSize = "m";
const ICONS = {
CHECKED: (
@@ -38,12 +41,12 @@ const ICONS = {
),
};
export const LockButton = (props: LockIconProps) => {
export const LockIcon = (props: LockIconProps) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
`ToolIcon_size_${DEFAULT_SIZE}`,
`ToolIcon_size_${props.size || DEFAULT_SIZE}`,
{
"zen-mode-visibility--hidden": props.zenModeEnabled,
},
@@ -54,6 +57,7 @@ export const LockButton = (props: LockIconProps) => {
className="ToolIcon_type_checkbox"
type="checkbox"
name={props.name}
id={props.id}
onChange={props.onChange}
checked={props.checked}
aria-label={props.title}

View File

@@ -13,16 +13,14 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section";
import CollabButton from "./CollabButton";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockButton } from "./LockButton";
import { LockIcon } from "./LockIcon";
import { UserList } from "./UserList";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import { LibraryButton } from "./LibraryButton";
type MobileMenuProps = {
appState: AppState;
actionManager: ActionManager;
renderJSONExportDialog: () => React.ReactNode;
renderImageExportDialog: () => React.ReactNode;
exportButton: React.ReactNode;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
libraryMenu: JSX.Element | null;
@@ -30,7 +28,7 @@ type MobileMenuProps = {
onLockToggle: () => void;
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
showThemeBtn: boolean;
};
@@ -40,8 +38,7 @@ export const MobileMenu = ({
elements,
libraryMenu,
actionManager,
renderJSONExportDialog,
renderImageExportDialog,
exportButton,
setAppState,
onCollabButtonClick,
onLockToggle,
@@ -65,15 +62,15 @@ export const MobileMenu = ({
canvas={canvas}
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LockButton
<LockIcon
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<LibraryButton appState={appState} setAppState={setAppState} />
</Stack.Row>
{libraryMenu}
</Stack.Col>
@@ -110,17 +107,19 @@ export const MobileMenu = ({
if (viewModeEnabled) {
return (
<>
{renderJSONExportDialog()}
{renderImageExportDialog()}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
</>
);
}
return (
<>
{actionManager.renderAction("clearCanvas")}
{actionManager.renderAction("loadScene")}
{renderJSONExportDialog()}
{renderImageExportDialog()}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
@@ -156,7 +155,7 @@ export const MobileMenu = ({
<div className="panelColumn">
<Stack.Col gap={4}>
{renderCanvasActions()}
{renderCustomFooter?.(true, appState)}
{renderCustomFooter?.(true)}
{appState.collaborators.size > 0 && (
<fieldset>
<legend>{t("labels.collaborators")}</legend>
@@ -168,9 +167,10 @@ export const MobileMenu = ({
)
.map(([clientId, client]) => (
<React.Fragment key={clientId}>
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
{actionManager.renderAction(
"goToCollaborator",
clientId,
)}
</React.Fragment>
))}
</UserList>

View File

@@ -26,7 +26,8 @@
right: 0;
bottom: 0;
z-index: 1;
background-color: transparentize($oc-black, 0.3);
background-color: transparentize($oc-black, 0.7);
backdrop-filter: blur(2px);
}
.Modal__content {
@@ -44,17 +45,14 @@
// for modals, reset blurry bg
background: var(--island-bg-color);
backdrop-filter: none;
border: 1px solid var(--dialog-border-color);
box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
border-radius: 6px;
box-sizing: border-box;
&:focus {
outline: none;
}
@include isMobile {
@media #{$is-mobile-query} {
max-width: 100%;
border: 0;
border-radius: 0;
@@ -84,7 +82,7 @@
}
}
@include isMobile {
@media #{$is-mobile-query} {
.Modal {
padding: 0;
}

View File

@@ -1,12 +1,9 @@
import "./Modal.scss";
import React, { useState, useLayoutEffect, useRef } from "react";
import React, { useState, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import clsx from "clsx";
import { KEYS } from "../keys";
import { useExcalidrawContainer, useIsMobile } from "./App";
import { AppState } from "../types";
import { THEME } from "../constants";
export const Modal = (props: {
className?: string;
@@ -14,10 +11,8 @@ export const Modal = (props: {
maxWidth?: number;
onCloseRequest(): void;
labelledBy: string;
theme?: AppState["theme"];
}) => {
const { theme = THEME.LIGHT } = props;
const modalRoot = useBodyRoot(theme);
const modalRoot = useBodyRoot();
if (!modalRoot) {
return null;
@@ -26,7 +21,6 @@ export const Modal = (props: {
const handleKeydown = (event: React.KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) {
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
props.onCloseRequest();
}
};
@@ -43,7 +37,6 @@ export const Modal = (props: {
<div
className="Modal__content"
style={{ "--max-width": `${props.maxWidth}px` }}
tabIndex={0}
>
{props.children}
</div>
@@ -52,29 +45,16 @@ export const Modal = (props: {
);
};
const useBodyRoot = (theme: AppState["theme"]) => {
const useBodyRoot = () => {
const [div, setDiv] = useState<HTMLDivElement | null>(null);
const isMobile = useIsMobile();
const isMobileRef = useRef(isMobile);
isMobileRef.current = isMobile;
const { container: excalidrawContainer } = useExcalidrawContainer();
useLayoutEffect(() => {
if (div) {
div.classList.toggle("excalidraw--mobile", isMobile);
}
}, [div, isMobile]);
useLayoutEffect(() => {
const isDarkTheme =
!!excalidrawContainer?.classList.contains("theme--dark") ||
theme === "dark";
const isDarkTheme = !!document
.querySelector(".excalidraw")
?.classList.contains("theme--dark");
const div = document.createElement("div");
div.classList.add("excalidraw", "excalidraw-modal-container");
div.classList.toggle("excalidraw--mobile", isMobileRef.current);
if (isDarkTheme) {
div.classList.add("theme--dark");
@@ -87,7 +67,7 @@ const useBodyRoot = (theme: AppState["theme"]) => {
return () => {
document.body.removeChild(div);
};
}, [excalidrawContainer, theme]);
}, []);
return div;
};

View File

@@ -2,7 +2,7 @@
.excalidraw {
.PasteChartDialog {
@include isMobile {
@media #{$is-mobile-query} {
.Island {
display: flex;
flex-direction: column;
@@ -13,7 +13,7 @@
align-items: center;
justify-content: space-around;
flex-wrap: wrap;
@include isMobile {
@media #{$is-mobile-query} {
flex-direction: column;
justify-content: center;
}

View File

@@ -34,21 +34,20 @@ const ChartPreviewBtn = (props: {
0,
);
setChartElements(elements);
let svg: SVGSVGElement;
const previewNode = previewRef.current!;
(async () => {
svg = await exportToSvg(elements, {
const svg = exportToSvg(elements, {
exportBackground: false,
viewBackgroundColor: oc.white,
shouldAddWatermark: false,
});
const previewNode = previewRef.current!;
previewNode.appendChild(svg);
if (props.selected) {
(previewNode.parentNode as HTMLDivElement).focus();
}
})();
return () => {
previewNode.removeChild(svg);

View File

@@ -1,6 +1,6 @@
.excalidraw {
.popover {
position: absolute;
position: fixed;
z-index: 10;
}
}

View File

@@ -1,25 +0,0 @@
.ProjectName {
margin: auto;
display: flex;
align-items: center;
.TextInput {
height: calc(1rem - 3px);
width: 200px;
overflow: hidden;
text-align: center;
margin-left: 8px;
text-overflow: ellipsis;
&--readonly {
background: none;
border: none;
&:hover {
background: none;
}
width: auto;
max-width: 200px;
padding-left: 2px;
}
}
}

View File

@@ -1,10 +1,7 @@
import "./TextInput.scss";
import React, { useState } from "react";
import { focusNearestParent } from "../utils";
import "./ProjectName.scss";
import { useExcalidrawContainer } from "./App";
import React, { Component } from "react";
import { selectNode, removeSelection } from "../utils";
type Props = {
value: string;
@@ -13,19 +10,20 @@ type Props = {
isNameEditable: boolean;
};
export const ProjectName = (props: Props) => {
const { id } = useExcalidrawContainer();
const [fileName, setFileName] = useState<string>(props.value);
const handleBlur = (event: any) => {
focusNearestParent(event.target);
const value = event.target.value;
if (value !== props.value) {
props.onChange(value);
}
export class ProjectName extends Component<Props> {
private handleFocus = (event: React.FocusEvent<HTMLElement>) => {
selectNode(event.currentTarget);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
private handleBlur = (event: React.FocusEvent<HTMLElement>) => {
const value = event.currentTarget.innerText.trim();
if (value !== this.props.value) {
this.props.onChange(value);
}
removeSelection();
};
private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter") {
event.preventDefault();
if (event.nativeEvent.isComposing || event.keyCode === 229) {
@@ -34,26 +32,39 @@ export const ProjectName = (props: Props) => {
event.currentTarget.blur();
}
};
return (
<div className="ProjectName">
<label className="ProjectName-label" htmlFor="filename">
{`${props.label}${props.isNameEditable ? "" : ":"}`}
</label>
{props.isNameEditable ? (
<input
className="TextInput"
onBlur={handleBlur}
onKeyDown={handleKeyDown}
id={`${id}-filename`}
value={fileName}
onChange={(event) => setFileName(event.target.value)}
/>
) : (
<span className="TextInput TextInput--readonly" id={`${id}-filename`}>
{props.value}
</span>
)}
</div>
);
private makeEditable = (editable: HTMLSpanElement | null) => {
if (!editable) {
return;
}
try {
editable.contentEditable = "plaintext-only";
} catch {
editable.contentEditable = "true";
}
};
public render() {
return this.props.isNameEditable ? (
<span
suppressContentEditableWarning
ref={this.makeEditable}
data-type="wysiwyg"
className="TextInput"
role="textbox"
aria-label={this.props.label}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
onFocus={this.handleFocus}
>
{this.props.value}
</span>
) : (
<span
className="TextInput TextInput--readonly"
aria-label={this.props.label}
>
{this.props.value}
</span>
);
}
}

View File

@@ -1,6 +1,5 @@
import React from "react";
import { t } from "../i18n";
import { useExcalidrawContainer } from "./App";
interface SectionProps extends React.HTMLProps<HTMLElement> {
heading: string;
@@ -8,14 +7,13 @@ interface SectionProps extends React.HTMLProps<HTMLElement> {
}
export const Section = ({ heading, children, ...props }: SectionProps) => {
const { id } = useExcalidrawContainer();
const header = (
<h2 className="visually-hidden" id={`${id}-${heading}-title`}>
<h2 className="visually-hidden" id={`${heading}-title`}>
{t(`headings.${heading}`)}
</h2>
);
return (
<section {...props} aria-labelledby={`${id}-${heading}-title`}>
<section {...props} aria-labelledby={`${heading}-title`}>
{typeof children === "function" ? (
children(header)
) : (

View File

@@ -6,7 +6,7 @@
top: 64px;
right: 12px;
font-size: 12px;
z-index: 10;
z-index: 999;
h3 {
margin: 0 24px 8px 0;

View File

@@ -1,22 +1,49 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { copyTextToSystemClipboard } from "../clipboard";
import { DEFAULT_VERSION } from "../constants";
import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types";
import {
getElementsStorageSize,
getTotalStorageSize,
} from "../excalidraw-app/data/localStorage";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { getTargetElements } from "../scene";
import { AppState, ExcalidrawProps } from "../types";
import { AppState } from "../types";
import { debounce, getVersion, nFormatter } from "../utils";
import { close } from "./icons";
import { Island } from "./Island";
import "./Stats.scss";
type StorageSizes = { scene: number; total: number };
const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
cb({
scene: getElementsStorageSize(),
total: getTotalStorageSize(),
});
}, 500);
export const Stats = (props: {
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"];
}) => {
const isMobile = useIsMobile();
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
scene: 0,
total: 0,
});
useEffect(() => {
getStorageSizes((sizes) => {
setStorageSizes(sizes);
});
});
useEffect(() => () => getStorageSizes.cancel(), []);
const boundingBox = getCommonBounds(props.elements);
const selectedElements = getTargetElements(props.elements, props.appState);
@@ -26,6 +53,17 @@ export const Stats = (props: {
return null;
}
const version = getVersion();
let hash;
let timestamp;
if (version !== DEFAULT_VERSION) {
timestamp = version.slice(0, 16).replace("T", " ");
hash = version.slice(21);
} else {
timestamp = t("stats.versionNotAvailable");
}
return (
<div className="Stats">
<Island padding={2}>
@@ -50,7 +88,17 @@ export const Stats = (props: {
<td>{t("stats.height")}</td>
<td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
</tr>
<tr>
<th colSpan={2}>{t("stats.storage")}</th>
</tr>
<tr>
<td>{t("stats.scene")}</td>
<td>{nFormatter(storageSizes.scene, 1)}</td>
</tr>
<tr>
<td>{t("stats.total")}</td>
<td>{nFormatter(storageSizes.total, 1)}</td>
</tr>
{selectedElements.length === 1 && (
<tr>
<th colSpan={2}>{t("stats.element")}</th>
@@ -72,17 +120,31 @@ export const Stats = (props: {
<>
<tr>
<td>{"x"}</td>
<td>{Math.round(selectedBoundingBox[0])}</td>
<td>
{Math.round(
selectedElements.length === 1
? selectedElements[0].x
: selectedBoundingBox[0],
)}
</td>
</tr>
<tr>
<td>{"y"}</td>
<td>{Math.round(selectedBoundingBox[1])}</td>
<td>
{Math.round(
selectedElements.length === 1
? selectedElements[0].y
: selectedBoundingBox[1],
)}
</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>
{Math.round(
selectedBoundingBox[2] - selectedBoundingBox[0],
selectedElements.length === 1
? selectedElements[0].width
: selectedBoundingBox[2] - selectedBoundingBox[0],
)}
</td>
</tr>
@@ -90,7 +152,9 @@ export const Stats = (props: {
<td>{t("stats.height")}</td>
<td>
{Math.round(
selectedBoundingBox[3] - selectedBoundingBox[1],
selectedElements.length === 1
? selectedElements[0].height
: selectedBoundingBox[3] - selectedBoundingBox[1],
)}
</td>
</tr>
@@ -106,7 +170,28 @@ export const Stats = (props: {
</td>
</tr>
)}
{props.renderCustomStats?.(props.elements, props.appState)}
<tr>
<th colSpan={2}>{t("stats.version")}</th>
</tr>
<tr>
<td
colSpan={2}
style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setAppState({
toastMessage: t("toast.copyToClipboard"),
});
} catch {}
}}
title={t("stats.versionCopy")}
>
{timestamp}
<br />
{hash}
</td>
</tr>
</tbody>
</table>
</Island>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import { TOAST_TIMEOUT } from "../constants";
import "./Toast.scss";

View File

@@ -2,9 +2,8 @@ import "./ToolIcon.scss";
import React from "react";
import clsx from "clsx";
import { useExcalidrawContainer } from "./App";
export type ToolButtonSize = "small" | "medium";
type ToolIconSize = "s" | "m";
type ToolButtonBaseProps = {
icon?: React.ReactNode;
@@ -15,7 +14,7 @@ type ToolButtonBaseProps = {
title?: string;
name?: string;
id?: string;
size?: ToolButtonSize;
size?: ToolIconSize;
keyBindingLabel?: string;
showAriaLabel?: boolean;
hidden?: boolean;
@@ -30,24 +29,21 @@ type ToolButtonProps =
children?: React.ReactNode;
onClick?(): void;
})
| (ToolButtonBaseProps & {
type: "icon";
children?: React.ReactNode;
onClick?(): void;
})
| (ToolButtonBaseProps & {
type: "radio";
checked: boolean;
onChange?(): void;
});
const DEFAULT_SIZE: ToolIconSize = "m";
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
const { id: excalId } = useExcalidrawContainer();
const innerRef = React.useRef(null);
React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${props.size}`;
const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
if (props.type === "button" || props.type === "icon") {
if (props.type === "button") {
return (
<button
className={clsx(
@@ -60,7 +56,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
{
ToolIcon: !props.hidden,
"ToolIcon--selected": props.selected,
"ToolIcon--plain": props.type === "icon",
},
)}
data-testid={props["data-testid"]}
@@ -71,7 +66,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
onClick={props.onClick}
ref={innerRef}
>
{(props.icon || props.label) && (
<div className="ToolIcon__icon" aria-hidden="true">
{props.icon || props.label}
{props.keyBindingLabel && (
@@ -80,7 +74,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
</span>
)}
</div>
)}
{props.showAriaLabel && (
<div className="ToolIcon__label">{props["aria-label"]}</div>
)}
@@ -98,7 +91,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
aria-label={props["aria-label"]}
aria-keyshortcuts={props["aria-keyshortcuts"]}
data-testid={props["data-testid"]}
id={`${excalId}-${props.id}`}
id={props.id}
onChange={props.onChange}
checked={props.checked}
ref={innerRef}
@@ -116,5 +109,4 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
ToolButton.defaultProps = {
visible: true,
className: "",
size: "medium",
};

View File

@@ -8,26 +8,9 @@
position: relative;
font-family: Cascadia;
cursor: pointer;
background-color: var(--button-gray-1);
-webkit-tap-highlight-color: transparent;
border-radius: var(--space-factor);
user-select: none;
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
}
.ToolIcon--plain {
background-color: transparent;
.ToolIcon__icon {
width: 2rem;
height: 2rem;
}
}
.ToolIcon__icon {
@@ -60,9 +43,9 @@
text-overflow: ellipsis;
}
.ToolIcon_size_small .ToolIcon__icon {
width: 2rem;
height: 2rem;
.ToolIcon_size_s .ToolIcon__icon {
width: 1.4rem;
height: 1.4rem;
font-size: 0.8em;
}
@@ -74,6 +57,14 @@
margin: 0;
font-size: inherit;
&:hover {
background-color: var(--button-gray-1);
}
&:active {
background-color: var(--button-gray-2);
}
&:focus {
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
@@ -86,14 +77,6 @@
}
}
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
&--show {
visibility: visible;
}
@@ -111,9 +94,6 @@
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
background-color: var(--button-gray-2);
&:active {
background-color: var(--button-gray-3);
}
}
&:focus + .ToolIcon__icon {
@@ -141,21 +121,12 @@
}
.ToolIcon__icon {
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
width: 2rem;
height: 2em;
}
}
.ToolIcon.ToolIcon__lock {
margin-inline-end: var(--space-factor);
&.ToolIcon_type_floating {
margin-left: 0.1rem;
}
@@ -186,9 +157,10 @@
// move the lock button out of the way on small viewports
// it begins to collide with the GitHub icon before we switch to mobile mode
@media (max-width: 760px) {
.ToolIcon.ToolIcon_type_floating {
.ToolIcon.ToolIcon__lock {
display: inline-block;
position: absolute;
top: 60px;
right: -8px;
margin-left: 0;
@@ -213,13 +185,16 @@
position: static;
}
}
.ToolIcon.ToolIcon__library {
top: 100px;
}
.ToolIcon.ToolIcon__lock {
margin-inline-end: 0;
top: 60px;
.TooltipIcon {
width: 0.9em;
height: 0.9em;
margin-left: 5px;
margin-top: 1px;
@media #{$is-mobile-query} {
display: none;
}
}

View File

@@ -1,45 +1,58 @@
@import "../css/variables.module";
.excalidraw {
.Tooltip {
position: relative;
}
// container in body where the actual tooltip is appended to
.excalidraw-tooltip {
position: absolute;
z-index: 1000;
padding: 8px;
.Tooltip__label {
--arrow-size: 4px;
visibility: hidden;
background: $oc-black;
color: $oc-white;
text-align: center;
border-radius: 6px;
box-sizing: border-box;
pointer-events: none;
padding: 8px;
position: absolute;
z-index: 10;
font-size: 13px;
line-height: 1.5;
font-weight: 500;
// extra pixel offset for unknown reasons
left: calc(50% + var(--arrow-size) / 2 - 1px);
transform: translateX(-50%);
word-wrap: break-word;
background: $oc-black;
&::after {
content: "";
border: var(--arrow-size) solid transparent;
position: absolute;
left: calc(50% - var(--arrow-size));
}
line-height: 1.5;
text-align: center;
font-size: 13px;
font-weight: 500;
color: $oc-white;
&--above {
bottom: calc(100% + var(--arrow-size) + 3px);
display: none;
&.excalidraw-tooltip--visible {
display: block;
&::after {
border-top-color: $oc-black;
top: 100%;
}
}
// wraps the element we want to apply the tooltip to
.excalidraw-tooltip-wrapper {
display: flex;
height: 100%;
&--below {
top: calc(100% + var(--arrow-size) + 3px);
&::after {
border-bottom-color: $oc-black;
bottom: 100%;
}
}
}
.excalidraw-tooltip-icon {
width: 0.9em;
height: 0.9em;
margin-left: 5px;
margin-top: 1px;
display: flex;
.Tooltip:hover .Tooltip__label {
visibility: visible;
}
@include isMobile {
display: none;
.Tooltip__label:hover {
visibility: visible;
}
}

View File

@@ -1,93 +1,31 @@
import "./Tooltip.scss";
import React, { useEffect } from "react";
const getTooltipDiv = () => {
const existingDiv = document.querySelector<HTMLDivElement>(
".excalidraw-tooltip",
);
if (existingDiv) {
return existingDiv;
}
const div = document.createElement("div");
document.body.appendChild(div);
div.classList.add("excalidraw-tooltip");
return div;
};
const updateTooltip = (
item: HTMLDivElement,
tooltip: HTMLDivElement,
label: string,
long: boolean,
) => {
tooltip.classList.add("excalidraw-tooltip--visible");
tooltip.style.minWidth = long ? "50ch" : "10ch";
tooltip.style.maxWidth = long ? "50ch" : "15ch";
tooltip.textContent = label;
const {
x: itemX,
bottom: itemBottom,
top: itemTop,
width: itemWidth,
} = item.getBoundingClientRect();
const {
width: labelWidth,
height: labelHeight,
} = tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 5;
const left = itemX + itemWidth / 2 - labelWidth / 2;
const offsetLeft =
left + labelWidth >= viewportWidth ? left + labelWidth - viewportWidth : 0;
const top = itemBottom + margin;
const offsetTop =
top + labelHeight >= viewportHeight
? itemBottom - itemTop + labelHeight + margin * 2
: 0;
Object.assign(tooltip.style, {
top: `${top - offsetTop}px`,
left: `${left - offsetLeft}px`,
});
};
import React from "react";
type TooltipProps = {
children: React.ReactNode;
label: string;
position?: "above" | "below";
long?: boolean;
};
export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
useEffect(() => {
return () =>
getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
}, []);
return (
<div
className="excalidraw-tooltip-wrapper"
onPointerEnter={(event) =>
updateTooltip(
event.currentTarget as HTMLDivElement,
getTooltipDiv(),
export const Tooltip = ({
children,
label,
long,
)
}
onPointerLeave={() =>
getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
position = "below",
long = false,
}: TooltipProps) => (
<div className="Tooltip">
<span
className={
position === "above"
? "Tooltip__label Tooltip__label--above"
: "Tooltip__label Tooltip__label--below"
}
style={{ width: long ? "50ch" : "10ch" }}
>
{label}
</span>
{children}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More