Compare commits

..

43 Commits

Author SHA1 Message Date
dependabot[bot]
ff5405bacc chore(deps): bump socket.io-client from 2.3.1 to 4.5.1
Bumps [socket.io-client](https://github.com/socketio/socket.io-client) from 2.3.1 to 4.5.1.
- [Release notes](https://github.com/socketio/socket.io-client/releases)
- [Changelog](https://github.com/socketio/socket.io-client/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io-client/compare/2.3.1...4.5.1)

---
updated-dependencies:
- dependency-name: socket.io-client
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-31 12:55:12 +00:00
David Luzar
e6de1fe4a4 feat: rewrite public UI component rendering using tunnels (#6117)
* feat: rewrite public UI component rendering using tunnels

* factor out into components

* comments

* fix variable naming

* fix not hiding welcomeScreen

* factor out AppFooter and memoize components

* remove `UIOptions.welcomeScreen` and render only from host app

* factor out tunnels into own file

* update changelog. Keep `UIOptions.welcomeScreen` as deprecated

* update changelog

* lint

---------

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-01-31 13:53:20 +01:00
Aakansha Doshi
3a141ca77a fix: add 1px width to the container to calculate more accurately (#6174)
* fix: add 1px width to the container to calculate accurately

* fix tests
2023-01-30 18:52:56 +05:30
JUNYI OU
5ae39c9292 fix: quick typo fix (#6167) 2023-01-29 14:22:25 +01:00
Aakansha Doshi
e41ea9562b fix: set the width correctly using measureText in editor (#6162) 2023-01-28 12:09:53 +01:00
Ignacio Cuadra
b52c8943e4 fix: 🐛 broken emojis when wrap text (#6153)
* fix: 🐛 broken emojis when wrap text

* refactor: Delete unnecessary "else" (reduce indentation)

* fix: remove code block that causes the emojis to disappear

* Apply suggestions from code review

Co-authored-by: David Luzar <luzar.david@gmail.com>

* fix: 🚑 possibly undefined value

* Add spec

Co-authored-by: David Luzar <luzar.david@gmail.com>
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-01-26 11:49:21 +05:30
Aakansha Doshi
cf38c0f933 fix: declare css variable for font in excalidraw so its available in host (#6160)
declar css variable for font in excalidraw so its available in host
2023-01-25 15:44:20 +05:30
David Luzar
1db078a3dc feat: close MainMenu and Library dropdown on item select (#6152) 2023-01-23 16:54:35 +01:00
David Luzar
d4afd66268 feat: add hand/panning tool (#6141)
* feat: add hand/panning tool

* move hand tool right of tool lock separator

* tweak i18n

* rename `panning` -> `hand`

* toggle between last tool and hand on `H` shortcut

* hide properties sidebar when `hand` active

* revert to rendering HandButton manually due to mobile toolbar
2023-01-23 16:12:28 +01:00
David Luzar
849e6a0c86 fix: button background and svg sizes (#6155)
* fix: button background color fallback

* fix svg width/height
2023-01-23 16:10:04 +01:00
Nishant-l
f03f5c948d style: change in ExportButton style (#6147) (#6148)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2023-01-22 12:37:18 +00:00
David Luzar
d2b698093c feat: show copy-as-png export button on firefox and show steps how to enable it (#6125)
* feat: hide copy-as-png shortcut from help dialog if not supported

* fix: support firefox if clipboard.write supported

* show shrotcut in firefox and instead show error message how to enable the flag support

* widen to TypeError because minification

* show copy-as-png on firefox even if it will throw
2023-01-22 12:33:15 +01:00
Excalidraw Bot
0f1720be61 chore: Update translations from Crowdin (#6077) 2023-01-22 12:19:21 +01:00
David Luzar
d0b33d35db build: temporarily disable pre-commit (#6132) 2023-01-19 13:50:42 +01:00
Aakansha Doshi
d6a5ef1936 docs: release @excalidraw/excalidraw@0.14.1 🎉 (#6112) 2023-01-16 16:08:03 +05:30
Aakansha Doshi
c7a11f5cd2 docs: release @excalidraw/excalidraw@0.14.0 🎉 (#6109) 2023-01-13 16:08:29 +05:30
Aakansha Doshi
893c487add fix: remove overflow hidden from button (#6110)
remove overflow hidden from button
2023-01-13 15:44:33 +05:30
Aakansha Doshi
99fdffdab7 fix: mobile tools positioning (#6107)
* fix: mobile tools positioning

* add var for padding

* use css var

* new line

* stupid mistake

* lint
2023-01-13 00:57:25 +05:30
Aakansha Doshi
faad8a65f1 feat: new Live Collaboration Component API (#6104)
* feat: new Live Collaboration Component API

* namespace export icons into `icons` dictionary and lowercase

* update readme and changelog

* review fixes

* fix

* fix

* update docs

* remove

* allow button rest props

* update docs

* docs

* add `WelcomeScreen.Center.MenuItemLiveCollaborationTrigger`

* fix lint

* update changelog

Co-authored-by: dwelle <luzar.david@gmail.com>
2023-01-12 23:28:57 +05:30
Aakansha Doshi
9d04479f98 fix: renamed folder MainMenu->main-menu and support rest props (#6103)
* renamed folder MainMenu -> main-menu

* rename ariaLabel -> aria-label and dataTestId -> data-testid

* allow rest props

* fix

* lint

* add ts check

* ts for div

* fix

* fix

* fix
2023-01-12 20:40:09 +05:30
David Luzar
599a8f3c6f feat: support WelcomeScreen customization API (#6048) 2023-01-12 15:49:28 +01:00
David Luzar
0982da38fe feat: render unknown supplied children to UI (#6096) 2023-01-12 15:20:16 +01:00
Barnabás Molnár
699897f71b feat: generic button export (#6092)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-01-12 13:06:00 +01:00
Aakansha Doshi
328ff6c32d fix: use position absolute for mobile misc tools (#6099) 2023-01-11 19:47:40 +05:30
David Luzar
618442299f fix: React.memo resolvers not accounting for all props (#6042) 2023-01-09 10:24:17 +01:00
Antonio Della Fortuna
06b45e0cfc fix: image horizontal flip fix + improved tests (#5799)
Co-authored-by: Antonio Della Fortuna <a.dellafortuna00@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
fixes https://github.com/excalidraw/excalidraw/issues/5784
2023-01-08 16:19:13 +00:00
David Luzar
809d5ba17f fix: png-exporting does not preserve angles correctly for flipped images (#6085)
* fix: png-exporting does not preserve angles correctly for flipped images

* refactor related code

* simplify further and comment
2023-01-08 16:22:04 +01:00
David Luzar
40d53d9231 fix: stale appState of MainMenu defaultItems rendered from Actions (#6074) 2023-01-06 14:32:55 +01:00
dependabot[bot]
9803a85381 build(deps): bump loader-utils from 2.0.3 to 2.0.4 in /src/packages/excalidraw (#5892)
build(deps): bump loader-utils in /src/packages/excalidraw

Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-06 12:31:06 +05:30
dependabot[bot]
72784f9d29 build(deps): bump loader-utils from 2.0.3 to 2.0.4 (#5905)
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-06 12:30:43 +05:30
dependabot[bot]
e3249f930c build(deps): bump json5 from 1.0.1 to 1.0.2 (#6076)
Bumps [json5](https://github.com/json5/json5) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v1.0.1...v1.0.2)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-06 06:59:44 +00:00
dependabot[bot]
cbe0d34f1a build(deps): bump decode-uri-component from 0.2.0 to 0.2.2 (#5963)
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-06 12:26:23 +05:30
dependabot[bot]
bed8093e47 build(deps): bump json5 from 2.2.1 to 2.2.3 in /dev-docs (#6060)
Bumps [json5](https://github.com/json5/json5) from 2.2.1 to 2.2.3.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v2.2.1...v2.2.3)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-06 12:25:48 +05:30
dependabot[bot]
1255ca2e84 build(deps): bump json5 from 2.2.1 to 2.2.3 in /src/packages/utils (#6061)
Bumps [json5](https://github.com/json5/json5) from 2.2.1 to 2.2.3.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v2.2.1...v2.2.3)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-06 12:25:35 +05:30
dependabot[bot]
14d02dcaea build(deps): bump json5 from 2.2.1 to 2.2.3 in /src/packages/excalidraw (#6062)
Bumps [json5](https://github.com/json5/json5) from 2.2.1 to 2.2.3.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v2.2.1...v2.2.3)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-06 12:25:20 +05:30
Excalidraw Bot
9747223705 chore: Update translations from Crowdin (#6052)
* New translations en.json (German)

* Auto commit: Calculate translation coverage

* New translations en.json (Hindi)

* New translations en.json (Marathi)

* New translations en.json (Hindi)

* Auto commit: Calculate translation coverage

* New translations en.json (Galician)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (Kabyle)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional)

* New translations en.json (Chinese Traditional)

* New translations en.json (Norwegian Bokmal)

* Auto commit: Calculate translation coverage

* New translations en.json (Latvian)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovenian)

* Auto commit: Calculate translation coverage

* New translations en.json (Spanish)

* New translations en.json (Russian)

* Auto commit: Calculate translation coverage

* New translations en.json (German)

* Auto commit: Calculate translation coverage

* New translations en.json (Vietnamese)

* Auto commit: Calculate translation coverage

* New translations en.json (Hindi)

* Auto commit: Calculate translation coverage

* New translations en.json (Dutch)

* Auto commit: Calculate translation coverage

* New translations en.json (Marathi)

* Auto commit: Calculate translation coverage

* New translations en.json (Latvian)

* New translations en.json (French)

* Auto commit: Calculate translation coverage

* New translations en.json (French)

* Auto commit: Calculate translation coverage

* New translations en.json (Portuguese, Brazilian)

* Auto commit: Calculate translation coverage

* New translations en.json (Japanese)

* Auto commit: Calculate translation coverage
2023-01-06 12:23:14 +05:30
EternalWill43
0f11f7da15 fix: HelpDialog (#6072) 2023-01-05 17:43:19 +01:00
Aakansha Doshi
8420aecb34 feat: new Menu Component API (#6034)
* feat: new Menu Component API

* allow valid children types

* introduce menu group to group items

* Add lang footer

* use display name

* displayName

* define types inside

* fix default menu

* add json export to menu

* fix

* simplify expression

* put open menu into own compo to optimize perf

So that we don't rerun `useOutsideClickHook` (and rebind event listeners
all the time)

* naming tweaks

* rename MenuComponents->MenuDefaultItems and export default items from Menu.Items

* import Menu.scss in Menu.tsx

* move menu scss to excal app

* Don't filter children inside menu group

* move E+ out of socials

* support style prop for MenuItem and MenuGroup

* Support header in menu group and add Excalidraw links header for default items in social section

* rename header to title

* fix padding for lang

* render menu in mobile

* review fixes

* tweaks

* Export collaborators and show in mobile menu

* revert .env

* lint :p

* again lint

* show correct actions in view mode for mobile

* Whitelist Collaborators Comp

* mobile styling

* padding

* don't show nerds when menu open in mobile

* lint :(

* hide shortcuts

* refactor userlist to support mobile and keep a wrapper comp for excal app

* use only UserList

* render only on mobile for default items

* remove unused hooks

* Show collab button in menu when onCollabButtonClick present and hide export when UIOptions.canvasActions.export is false

* fix tests

* lint

* inject userlist inside menu on mobile

* revert userlist

* move menu socials to default menu

* fix collab

* use meny in library

* Make Menu generic and create hamburgemenu for public excal menu and use menu in library as well

* use appState.openMenu for mobile

* fix tests

* styling fixes and support style and class name in menu content

* fix test

* rename MenuDefaultItems->DefaultItems

* move footer css to its own comp

* rename HamburgerMenu -> MainMenu

* rename menu -> dropdownMenu and update classes, onClick->onToggle

* close main menu when dialog closes

* by bye filtering

* update docs

* fix lint

* update example, docs for useDevice and footer in mobile, rename menu ->DropDownMenu everywhere

* spec

* remove isMenuOpenAtom and set openMenu as canvas for main menu, render decreases in specs :)

* [temp] remove cyclic depenedency to fix build

* hack- update appstate to sync lang change

* Add more specs

* wip: rewrite MainMenu footer

* fix margin

* fix snaps

* not needed as lang list no more imported

* simplify custom footer rendering

* Add DropdownMenuItemLink and DropdownMenuItemCustom and update API, docs

* fix `MainMenu.ItemCustom`

* naming

* use onSelect and base class for custom items

* fix lint

* fix snap

* use custom item for lang

* update docs

* fix

* properly use `MainMenu.ItemCustom` for `LanguageList`

* add margin top to custom items

* flex

Co-authored-by: dwelle <luzar.david@gmail.com>
2023-01-05 22:04:23 +05:30
David Luzar
08afb857c3 fix: show error message on collab save failure (#6063)
* fix: show error message on collab save failure

* comment
2023-01-02 23:53:51 +01:00
David Luzar
9230c8f4d2 fix: remove ga from docker build (#6059)
* fix: remove ga from docker build

* lint

* fix debug
2023-01-02 21:21:57 +01:00
Excalidraw Bot
dba8f812f1 chore: Update translations from Crowdin (#5807)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2023-01-01 17:01:46 +00:00
DanielJGeiger
fdd8552637 feat: Scroll using PageUp and PageDown (#6038)
* feat: Scroll using PageUp and PageDown

* support x-axis via `shift` & enable in viewMode

* tweak test

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-12-31 15:54:37 -06:00
Aakansha Doshi
c8370b394c fix: use displayName since name gets stripped off when uglifying/minifiyng in production (#6036)
fix: use displayName since name gets stripped off when uglifying/minifiy in production
2022-12-27 15:17:13 +05:30
183 changed files with 6583 additions and 4604 deletions

View File

@@ -1,2 +1,2 @@
#!/bin/sh
yarn lint-staged
# yarn lint-staged

View File

@@ -4692,9 +4692,9 @@ json-schema-traverse@^1.0.0:
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
json5@^2.1.2, json5@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c"
integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==
version "2.2.3"
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
jsonfile@^6.0.1:
version "6.1.0"

View File

@@ -54,7 +54,8 @@
"react-scripts": "5.0.1",
"roughjs": "4.5.2",
"sass": "1.51.0",
"socket.io-client": "2.3.1",
"socket.io-client": "4.5.4",
"tunnel-rat": "0.1.0",
"typescript": "4.9.4",
"workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4",
@@ -103,7 +104,7 @@
"private": true,
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
"build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true REACT_APP_DISABLE_TRACKING=true react-scripts build",
"build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
"build:version": "node ./scripts/build-version.js",
"build": "yarn build:app && yarn build:version",

View File

@@ -146,7 +146,8 @@
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' &&
process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
@@ -166,9 +167,6 @@
body,
html {
margin: 0;
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system,
Segoe UI, Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
-webkit-text-size-adjust: 100%;
width: 100%;

View File

@@ -26,7 +26,7 @@ export const actionUnbindText = register({
name: "unbindText",
contextItemLabel: "labels.unbindText",
trackEvent: { category: "element" },
contextItemPredicate: (elements, appState) => {
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return selectedElements.some((element) => hasBoundTextElement(element));
},
@@ -76,7 +76,7 @@ export const actionBindText = register({
name: "bindText",
contextItemLabel: "labels.bindText",
trackEvent: { category: "element" },
contextItemPredicate: (elements, appState) => {
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 2) {

View File

@@ -1,13 +1,7 @@
import { ColorPicker } from "../components/ColorPicker";
import {
eraser,
MoonIcon,
SunIcon,
ZoomInIcon,
ZoomOutIcon,
} from "../components/icons";
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
@@ -16,19 +10,25 @@ import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils";
import { getShortcutKey, setCursor, updateActiveTool } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement";
import { getDefaultAppState, isEraserActive } from "../appState";
import ClearCanvas from "../components/ClearCanvas";
import clsx from "clsx";
import MenuItem from "../components/MenuItem";
import { getShortcutFromShortcutName } from "./shortcuts";
import {
getDefaultAppState,
isEraserActive,
isHandToolActive,
} from "../appState";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
trackEvent: false,
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.changeViewBackgroundColor &&
!appState.viewModeEnabled
);
},
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
@@ -36,6 +36,7 @@ export const actionChangeViewBackgroundColor = register({
};
},
PanelComponent: ({ elements, appState, updateData }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
return (
<div style={{ position: "relative" }}>
<ColorPicker
@@ -59,6 +60,12 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
trackEvent: { category: "canvas" },
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.clearCanvas &&
!appState.viewModeEnabled
);
},
perform: (elements, appState, _, app) => {
app.imageCache.clear();
return {
@@ -84,8 +91,6 @@ export const actionClearCanvas = register({
commitToHistory: true,
};
},
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
});
export const actionZoomIn = register({
@@ -298,33 +303,21 @@ export const actionToggleTheme = register({
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
<MenuItem
label={
appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")
}
onClick={() => {
updateData(appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
}}
icon={appState.theme === "dark" ? SunIcon : MoonIcon}
dataTestId="toggle-dark-mode"
shortcut={getShortcutFromShortcutName("toggleTheme")}
/>
),
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
predicate: (elements, appState, props, app) => {
return !!app.props.UIOptions.canvasActions.toggleTheme;
},
});
export const actionErase = register({
name: "eraser",
export const actionToggleEraserTool = register({
name: "toggleEraserTool",
trackEvent: { category: "toolbar" },
perform: (elements, appState) => {
let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveToolBeforeEraser || {
...(appState.activeTool.lastActiveTool || {
type: "selection",
}),
lastActiveToolBeforeEraser: null,
@@ -347,17 +340,38 @@ export const actionErase = register({
};
},
keyTest: (event) => event.key === KEYS.E,
PanelComponent: ({ elements, appState, updateData, data }) => (
<ToolButton
type="button"
icon={eraser}
className={clsx("eraser", { active: isEraserActive(appState) })}
title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
aria-label={t("toolBar.eraser")}
onClick={() => {
updateData(null);
}}
size={data?.size || "medium"}
></ToolButton>
),
});
export const actionToggleHandTool = register({
name: "toggleHandTool",
trackEvent: { category: "toolbar" },
perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"];
if (isHandToolActive(appState)) {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: "selection",
}),
lastActiveToolBeforeEraser: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: "hand",
lastActiveToolBeforeEraser: appState.activeTool,
});
setCursor(app.canvas, CURSOR_TYPE.GRAB);
}
return {
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeTool,
},
commitToHistory: true,
};
},
keyTest: (event) => event.key === KEYS.H,
});

View File

@@ -24,7 +24,7 @@ export const actionCopy = register({
commitToHistory: false,
};
},
contextItemPredicate: (elements, appState, appProps, app) => {
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.copy",
@@ -41,7 +41,7 @@ export const actionPaste = register({
commitToHistory: false,
};
},
contextItemPredicate: (elements, appState, appProps, app) => {
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.paste",
@@ -56,7 +56,7 @@ export const actionCut = register({
actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState);
},
contextItemPredicate: (elements, appState, appProps, app) => {
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.cut",
@@ -101,7 +101,7 @@ export const actionCopyAsSvg = register({
};
}
},
contextItemPredicate: (elements) => {
predicate: (elements) => {
return probablySupportsClipboardWriteText && elements.length > 0;
},
contextItemLabel: "labels.copyAsSvg",
@@ -158,7 +158,7 @@ export const actionCopyAsPng = register({
};
}
},
contextItemPredicate: (elements) => {
predicate: (elements) => {
return probablySupportsClipboardBlob && elements.length > 0;
},
contextItemLabel: "labels.copyAsPng",
@@ -188,7 +188,7 @@ export const copyText = register({
commitToHistory: false,
};
},
contextItemPredicate: (elements, appState) => {
predicate: (elements, appState) => {
return (
probablySupportsClipboardWriteText &&
getSelectedElements(elements, appState, true).some(isTextElement)

View File

@@ -1,7 +1,6 @@
import { LoadIcon, questionCircle, saveAs } from "../components/icons";
import { questionCircle, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton";
import "../components/ToolIcon.scss";
import { Tooltip } from "../components/Tooltip";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data";
@@ -15,12 +14,11 @@ 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";
import MenuItem from "../components/MenuItem";
import { getShortcutFromShortcutName } from "./shortcuts";
import "../components/ToolIcon.scss";
export const actionChangeProjectName = register({
name: "changeProjectName",
@@ -133,6 +131,13 @@ export const actionChangeExportEmbedScene = register({
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
trackEvent: { category: "export" },
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.saveToActiveFile &&
!!appState.fileHandle &&
!appState.viewModeEnabled
);
},
perform: async (elements, appState, value, app) => {
const fileHandleExists = !!appState.fileHandle;
@@ -169,12 +174,6 @@ 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}
/>
),
});
export const actionSaveFileToDisk = register({
@@ -220,6 +219,11 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({
name: "loadScene",
trackEvent: { category: "export" },
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.loadScene && !appState.viewModeEnabled
);
},
perform: async (elements, appState, _, app) => {
try {
const {
@@ -247,15 +251,6 @@ export const actionLoadScene = register({
}
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
PanelComponent: ({ updateData }) => (
<MenuItem
label={t("buttons.load")}
icon={LoadIcon}
onClick={updateData}
dataTestId="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
/>
),
});
export const actionExportWithDarkMode = register({

View File

@@ -145,7 +145,7 @@ export const actionFinalize = register({
let activeTool: AppState["activeTool"];
if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveToolBeforeEraser || {
...(appState.activeTool.lastActiveTool || {
type: "selection",
}),
lastActiveToolBeforeEraser: null,

View File

@@ -50,7 +50,7 @@ export const actionFlipHorizontal = register({
},
keyTest: (event) => event.shiftKey && event.code === "KeyH",
contextItemLabel: "labels.flipHorizontal",
contextItemPredicate: (elements, appState) =>
predicate: (elements, appState) =>
enableActionFlipHorizontal(elements, appState),
});
@@ -67,7 +67,7 @@ export const actionFlipVertical = register({
keyTest: (event) =>
event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
contextItemLabel: "labels.flipVertical",
contextItemPredicate: (elements, appState) =>
predicate: (elements, appState) =>
enableActionFlipVertical(elements, appState),
});

View File

@@ -129,8 +129,7 @@ export const actionGroup = register({
};
},
contextItemLabel: "labels.group",
contextItemPredicate: (elements, appState) =>
enableActionGroup(elements, appState),
predicate: (elements, appState) => enableActionGroup(elements, appState),
keyTest: (event) =>
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
PanelComponent: ({ elements, appState, updateData }) => (
@@ -193,8 +192,7 @@ export const actionUngroup = register({
event[KEYS.CTRL_OR_CMD] &&
event.key === KEYS.G.toUpperCase(),
contextItemLabel: "labels.ungroup",
contextItemPredicate: (elements, appState) =>
getSelectedGroupIds(appState).length > 0,
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton

View File

@@ -5,10 +5,11 @@ import { t } from "../i18n";
import History, { HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { isWindows, KEYS } from "../keys";
import { KEYS } from "../keys";
import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
const writeData = (
prevElements: readonly ExcalidrawElement[],

View File

@@ -10,7 +10,7 @@ export const actionToggleLinearEditor = register({
trackEvent: {
category: "element",
},
contextItemPredicate: (elements, appState) => {
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
return true;

View File

@@ -1,12 +1,10 @@
import { HamburgerMenuIcon, HelpIcon, palette } from "../components/icons";
import { HamburgerMenuIcon, palette } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { KEYS } from "../keys";
import { HelpButton } from "../components/HelpButton";
import MenuItem from "../components/MenuItem";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
@@ -88,17 +86,5 @@ export const actionShortcuts = register({
commitToHistory: false,
};
},
PanelComponent: ({ updateData, isInHamburgerMenu }) =>
isInHamburgerMenu ? (
<MenuItem
label={t("helpDialog.title")}
dataTestId="help-menu-item"
icon={HelpIcon}
onClick={updateData}
shortcut="?"
/>
) : (
<HelpButton title={t("helpDialog.title")} onClick={updateData} />
),
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
});

View File

@@ -20,7 +20,7 @@ export const actionToggleGridMode = register({
};
},
checked: (appState: AppState) => appState.gridSize !== null,
contextItemPredicate: (element, appState, props) => {
predicate: (element, appState, props) => {
return typeof props.gridModeEnabled === "undefined";
},
contextItemLabel: "labels.showGrid",

View File

@@ -18,7 +18,7 @@ export const actionToggleViewMode = register({
};
},
checked: (appState) => appState.viewModeEnabled,
contextItemPredicate: (elements, appState, appProps) => {
predicate: (elements, appState, appProps) => {
return typeof appProps.viewModeEnabled === "undefined";
},
contextItemLabel: "labels.viewMode",

View File

@@ -18,7 +18,7 @@ export const actionToggleZenMode = register({
};
},
checked: (appState) => appState.zenModeEnabled,
contextItemPredicate: (elements, appState, appProps) => {
predicate: (elements, appState, appProps) => {
return typeof appProps.zenModeEnabled === "undefined";
},
contextItemLabel: "buttons.zenMode",

View File

@@ -5,7 +5,7 @@ import {
moveAllLeft,
moveAllRight,
} from "../zindex";
import { KEYS, isDarwin, CODES } from "../keys";
import { KEYS, CODES } from "../keys";
import { t } from "../i18n";
import { getShortcutKey } from "../utils";
import { register } from "./register";
@@ -15,6 +15,7 @@ import {
SendBackwardIcon,
SendToBackIcon,
} from "../components/icons";
import { isDarwin } from "../constants";
export const actionSendBackward = register({
name: "sendBackward",

View File

@@ -131,11 +131,7 @@ export class ActionManager {
/**
* @param data additional data sent to the PanelComponent
*/
renderAction = (
name: ActionName,
data?: PanelComponentProps["data"],
isInHamburgerMenu = false,
) => {
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
const canvasActions = this.app.props.UIOptions.canvasActions;
if (
@@ -170,11 +166,20 @@ export class ActionManager {
updateData={updateData}
appProps={this.app.props}
data={data}
isInHamburgerMenu={isInHamburgerMenu}
/>
);
}
return null;
};
isActionEnabled = (action: Action) => {
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
return (
!action.predicate ||
action.predicate(elements, appState, this.app.props, this.app)
);
};
}

View File

@@ -1,5 +1,5 @@
import { isDarwin } from "../constants";
import { t } from "../i18n";
import { isDarwin } from "../keys";
import { getShortcutKey } from "../utils";
import { ActionName } from "./types";

View File

@@ -109,10 +109,11 @@ export type ActionName =
| "decreaseFontSize"
| "unbindText"
| "hyperlink"
| "eraser"
| "bindText"
| "toggleLock"
| "toggleLinearEditor";
| "toggleLinearEditor"
| "toggleEraserTool"
| "toggleHandTool";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@@ -124,9 +125,7 @@ export type PanelComponentProps = {
export interface Action {
name: ActionName;
PanelComponent?: React.FC<
PanelComponentProps & { isInHamburgerMenu: boolean }
>;
PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn;
keyPriority?: number;
keyTest?: (
@@ -140,7 +139,7 @@ export interface Action {
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
) => string);
contextItemPredicate?: (
predicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
appProps: ExcalidrawProps,

View File

@@ -45,7 +45,7 @@ export const getDefaultAppState = (): Omit<
type: "selection",
customType: null,
locked: false,
lastActiveToolBeforeEraser: null,
lastActiveTool: null,
},
penMode: false,
penDetected: false,
@@ -228,3 +228,11 @@ export const isEraserActive = ({
}: {
activeTool: AppState["activeTool"];
}) => activeTool.type === "eraser";
export const isHandToolActive = ({
activeTool,
}: {
activeTool: AppState["activeTool"];
}) => {
return activeTool.type === "hand";
};

View File

@@ -180,16 +180,16 @@ export const parseClipboard = async (
};
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
let promise;
try {
// in Safari so far we need to construct the ClipboardItem synchronously
// (i.e. in the same tick) otherwise browser will complain for lack of
// user intent. Using a Promise ClipboardItem constructor solves this.
// https://bugs.webkit.org/show_bug.cgi?id=222262
//
// not await so that we can detect whether the thrown error likely relates
// to a lack of support for the Promise ClipboardItem constructor
promise = navigator.clipboard.write([
// Note that Firefox (and potentially others) seems to support Promise
// ClipboardItem constructor, but throws on an unrelated MIME type error.
// So we need to await this and fallback to awaiting the blob if applicable.
await navigator.clipboard.write([
new window.ClipboardItem({
[MIME_TYPES.png]: blob,
}),
@@ -207,7 +207,6 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
throw error;
}
}
await promise;
};
export const copyTextToSystemClipboard = async (text: string | null) => {

View File

@@ -219,9 +219,10 @@ export const ShapesSwitcher = ({
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
const label = t(`toolBar.${value}`);
const letter = key && (typeof key === "string" ? key : key[0]);
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numericKey}`
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
return (
<ToolButton
@@ -232,7 +233,7 @@ export const ShapesSwitcher = ({
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey}
keyBindingLabel={numericKey || letter}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}

View File

@@ -0,0 +1,35 @@
import { atom, useAtom } from "jotai";
import { actionClearCanvas } from "../actions";
import { t } from "../i18n";
import { useExcalidrawActionManager } from "./App";
import ConfirmDialog from "./ConfirmDialog";
export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
export const ActiveConfirmDialog = () => {
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
activeConfirmDialogAtom,
);
const actionManager = useExcalidrawActionManager();
if (!activeConfirmDialog) {
return null;
}
if (activeConfirmDialog === "clearCanvas") {
return (
<ConfirmDialog
onConfirm={() => {
actionManager.executeAction(actionClearCanvas);
setActiveConfirmDialog(null);
}}
onCancel={() => setActiveConfirmDialog(null)}
title={t("clearCanvasDialog.title")}
>
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
</ConfirmDialog>
);
}
return null;
};

View File

@@ -1,23 +0,0 @@
// TODO barnabasmolnar/editor-redesign
// this icon is not great
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { save } from "../components/icons";
import { t } from "../i18n";
import "./ActiveFile.scss";
import MenuItem from "./MenuItem";
type ActiveFileProps = {
fileName?: string;
onSave: () => void;
};
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
<MenuItem
label={`${t("buttons.save")}`}
shortcut={getShortcutFromShortcutName("saveScene")}
dataTestId="save-button"
onClick={onSave}
icon={save}
/>
);

View File

@@ -41,7 +41,11 @@ import { ActionManager } from "../actions/manager";
import { actions } from "../actions/register";
import { ActionResult } from "../actions/types";
import { trackEvent } from "../analytics";
import { getDefaultAppState, isEraserActive } from "../appState";
import {
getDefaultAppState,
isEraserActive,
isHandToolActive,
} from "../appState";
import { parseClipboard } from "../clipboard";
import {
APP_NAME,
@@ -57,6 +61,7 @@ import {
EVENT,
GRID_SIZE,
IMAGE_RENDER_TIMEOUT,
isAndroid,
LINE_CONFIRM_THRESHOLD,
MAX_ALLOWED_FILE_BYTES,
MIME_TYPES,
@@ -166,7 +171,6 @@ import {
shouldRotateWithDiscreteAngle,
isArrowKey,
KEYS,
isAndroid,
} from "../keys";
import { distance2d, getGridPoint, isPathALoop } from "../math";
import { renderScene } from "../renderer/renderScene";
@@ -272,12 +276,9 @@ import {
isLocalLink,
} from "../element/Hyperlink";
import { shouldShowBoundingBox } from "../element/transformHandles";
import { atom } from "jotai";
import { Fonts } from "../scene/Fonts";
import { actionPaste } from "../actions/actionClipboard";
export const isMenuOpenAtom = atom(false);
export const isDropdownOpenAtom = atom(false);
import { actionToggleHandTool } from "../actions/actionCanvas";
const deviceContextInitialValue = {
isSmScreen: false,
@@ -287,15 +288,12 @@ const deviceContextInitialValue = {
};
const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
DeviceContext.displayName = "DeviceContext";
export const useDevice = () => useContext<Device>(DeviceContext);
const ExcalidrawContainerContext = React.createContext<{
export const ExcalidrawContainerContext = React.createContext<{
container: HTMLDivElement | null;
id: string | null;
}>({ container: null, id: null });
ExcalidrawContainerContext.displayName = "ExcalidrawContainerContext";
export const useExcalidrawContainer = () =>
useContext(ExcalidrawContainerContext);
const ExcalidrawElementsContext = React.createContext<
readonly NonDeletedExcalidrawElement[]
@@ -313,15 +311,27 @@ ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext";
const ExcalidrawSetAppStateContext = React.createContext<
React.Component<any, AppState>["setState"]
>(() => {});
>(() => {
console.warn("unitialized ExcalidrawSetAppStateContext context!");
});
ExcalidrawSetAppStateContext.displayName = "ExcalidrawSetAppStateContext";
const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
null!,
);
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
export const useDevice = () => useContext<Device>(DeviceContext);
export const useExcalidrawContainer = () =>
useContext(ExcalidrawContainerContext);
export const useExcalidrawElements = () =>
useContext(ExcalidrawElementsContext);
export const useExcalidrawAppState = () =>
useContext(ExcalidrawAppStateContext);
export const useExcalidrawSetAppState = () =>
useContext(ExcalidrawSetAppStateContext);
export const useExcalidrawActionManager = () =>
useContext(ExcalidrawActionManagerContext);
let didTapTwice: boolean = false;
let tappedTwiceTimer = 0;
@@ -534,8 +544,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElements(),
this.state,
);
const { onCollabButtonClick, renderTopRightUI, renderCustomStats } =
this.props;
const { renderTopRightUI, renderCustomStats } = this.props;
return (
<div
@@ -559,75 +568,79 @@ class App extends React.Component<AppProps, AppState> {
<ExcalidrawElementsContext.Provider
value={this.scene.getNonDeletedElements()}
>
<LayerUI
canvas={this.canvas}
appState={this.state}
files={this.files}
setAppState={this.setAppState}
actionManager={this.actionManager}
elements={this.scene.getNonDeletedElements()}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={this.toggleLock}
onPenModeToggle={this.togglePenMode}
onInsertElements={(elements) =>
this.addElementsFromPasteOrLibrary({
elements,
position: "center",
files: null,
})
}
langCode={getLanguage().code}
isCollaborating={this.props.isCollaborating}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderCustomSidebar={this.props.renderSidebar}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" &&
this.state.zenModeEnabled
}
libraryReturnUrl={this.props.libraryReturnUrl}
UIOptions={this.props.UIOptions}
focusContainer={this.focusContainer}
library={this.library}
id={this.id}
onImageAction={this.onImageAction}
renderWelcomeScreen={
this.state.showWelcomeScreen &&
this.state.activeTool.type === "selection" &&
!this.scene.getElementsIncludingDeleted().length
}
<ExcalidrawActionManagerContext.Provider
value={this.actionManager}
>
{this.props.children}
</LayerUI>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
{selectedElement.length === 1 &&
!this.state.contextMenu &&
this.state.showHyperlinkPopup && (
<Hyperlink
key={selectedElement[0].id}
element={selectedElement[0]}
setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen}
<LayerUI
canvas={this.canvas}
appState={this.state}
files={this.files}
setAppState={this.setAppState}
actionManager={this.actionManager}
elements={this.scene.getNonDeletedElements()}
onLockToggle={this.toggleLock}
onPenModeToggle={this.togglePenMode}
onHandToolToggle={this.onHandToolToggle}
onInsertElements={(elements) =>
this.addElementsFromPasteOrLibrary({
elements,
position: "center",
files: null,
})
}
langCode={getLanguage().code}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderCustomSidebar={this.props.renderSidebar}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" &&
this.state.zenModeEnabled
}
libraryReturnUrl={this.props.libraryReturnUrl}
UIOptions={this.props.UIOptions}
focusContainer={this.focusContainer}
library={this.library}
id={this.id}
onImageAction={this.onImageAction}
renderWelcomeScreen={
!this.state.isLoading &&
this.state.showWelcomeScreen &&
this.state.activeTool.type === "selection" &&
!this.scene.getElementsIncludingDeleted().length
}
>
{this.props.children}
</LayerUI>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
{selectedElement.length === 1 &&
!this.state.contextMenu &&
this.state.showHyperlinkPopup && (
<Hyperlink
key={selectedElement[0].id}
element={selectedElement[0]}
setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen}
/>
)}
{this.state.toast !== null && (
<Toast
message={this.state.toast.message}
onClose={() => this.setToast(null)}
duration={this.state.toast.duration}
closable={this.state.toast.closable}
/>
)}
{this.state.toast !== null && (
<Toast
message={this.state.toast.message}
onClose={() => this.setToast(null)}
duration={this.state.toast.duration}
closable={this.state.toast.closable}
/>
)}
{this.state.contextMenu && (
<ContextMenu
items={this.state.contextMenu.items}
top={this.state.contextMenu.top}
left={this.state.contextMenu.left}
actionManager={this.actionManager}
/>
)}
<main>{this.renderCanvas()}</main>
{this.state.contextMenu && (
<ContextMenu
items={this.state.contextMenu.items}
top={this.state.contextMenu.top}
left={this.state.contextMenu.left}
actionManager={this.actionManager}
/>
)}
<main>{this.renderCanvas()}</main>
</ExcalidrawActionManagerContext.Provider>
</ExcalidrawElementsContext.Provider>{" "}
</ExcalidrawAppStateContext.Provider>
</ExcalidrawSetAppStateContext.Provider>
@@ -1803,6 +1816,10 @@ class App extends React.Component<AppProps, AppState> {
});
};
onHandToolToggle = () => {
this.actionManager.executeAction(actionToggleHandTool);
};
scrollToContent = (
target:
| ExcalidrawElement
@@ -2008,6 +2025,20 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (event.key === KEYS.PAGE_UP || event.key === KEYS.PAGE_DOWN) {
let offset =
(event.shiftKey ? this.state.width : this.state.height) /
this.state.zoom.value;
if (event.key === KEYS.PAGE_DOWN) {
offset = -offset;
}
if (event.shiftKey) {
this.setState((state) => ({ scrollX: state.scrollX + offset }));
} else {
this.setState((state) => ({ scrollY: state.scrollY + offset }));
}
}
if (this.actionManager.handleKeyDown(event)) {
return;
}
@@ -2030,12 +2061,6 @@ class App extends React.Component<AppProps, AppState> {
? ELEMENT_SHIFT_TRANSLATE_AMOUNT
: ELEMENT_TRANSLATE_AMOUNT);
const selectedElements = getSelectedElements(
this.scene.getNonDeletedElements(),
this.state,
true,
);
let offsetX = 0;
let offsetY = 0;
@@ -2049,6 +2074,12 @@ class App extends React.Component<AppProps, AppState> {
offsetY = step;
}
const selectedElements = getSelectedElements(
this.scene.getNonDeletedElements(),
this.state,
true,
);
selectedElements.forEach((element) => {
mutateElement(element, {
x: element.x + offsetX,
@@ -2206,11 +2237,13 @@ class App extends React.Component<AppProps, AppState> {
private setActiveTool = (
tool:
| { type: typeof SHAPES[number]["value"] | "eraser" }
| { type: typeof SHAPES[number]["value"] | "eraser" | "hand" }
| { type: "custom"; customType: string },
) => {
const nextActiveTool = updateActiveTool(this.state, tool);
if (!isHoldingSpace) {
if (nextActiveTool.type === "hand") {
setCursor(this.canvas, CURSOR_TYPE.GRAB);
} else if (!isHoldingSpace) {
setCursorForShape(this.canvas, this.state);
}
if (isToolIcon(document.activeElement)) {
@@ -2881,7 +2914,12 @@ class App extends React.Component<AppProps, AppState> {
null;
}
if (isHoldingSpace || isPanning || isDraggingScrollBar) {
if (
isHoldingSpace ||
isPanning ||
isDraggingScrollBar ||
isHandToolActive(this.state)
) {
return;
}
@@ -3473,7 +3511,10 @@ class App extends React.Component<AppProps, AppState> {
);
} else if (this.state.activeTool.type === "custom") {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
} else if (this.state.activeTool.type !== "eraser") {
} else if (
this.state.activeTool.type !== "eraser" &&
this.state.activeTool.type !== "hand"
) {
this.createGenericElementOnPointerDown(
this.state.activeTool.type,
pointerDownState,
@@ -3584,6 +3625,7 @@ class App extends React.Component<AppProps, AppState> {
gesture.pointers.size <= 1 &&
(event.button === POINTER_BUTTON.WHEEL ||
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
isHandToolActive(this.state) ||
this.state.viewModeEnabled)
) ||
isTextElement(this.state.editingElement)

View File

@@ -0,0 +1,7 @@
@import "../css/theme";
.excalidraw {
.excalidraw-button {
@include outlineButtonStyles;
}
}

35
src/components/Button.tsx Normal file
View File

@@ -0,0 +1,35 @@
import "./Button.scss";
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
type?: "button" | "submit" | "reset";
onSelect: () => any;
children: React.ReactNode;
className?: string;
}
/**
* A generic button component that follows Excalidraw's design system.
* Style can be customised using `className` or `style` prop.
* Accepts all props that a regular `button` element accepts.
*/
export const Button = ({
type = "button",
onSelect,
children,
className = "",
...rest
}: ButtonProps) => {
return (
<button
onClick={(event) => {
onSelect();
rest.onClick?.(event);
}}
type={type}
className={`excalidraw-button ${className}`}
{...rest}
>
{children}
</button>
);
};

View File

@@ -1,39 +0,0 @@
import { useState } from "react";
import { t } from "../i18n";
import { TrashIcon } from "./icons";
import ConfirmDialog from "./ConfirmDialog";
import MenuItem from "./MenuItem";
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
const [showDialog, setShowDialog] = useState(false);
const toggleDialog = () => {
setShowDialog(!showDialog);
};
return (
<>
<MenuItem
label={t("buttons.clearReset")}
icon={TrashIcon}
onClick={toggleDialog}
dataTestId="clear-canvas-button"
/>
{showDialog && (
<ConfirmDialog
onConfirm={() => {
onConfirm();
toggleDialog();
}}
onCancel={toggleDialog}
title={t("clearCanvasDialog.title")}
>
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
</ConfirmDialog>
)}
</>
);
};
export default ClearCanvas;

View File

@@ -1,49 +0,0 @@
import { t } from "../i18n";
import { UsersIcon } from "./icons";
import "./CollabButton.scss";
import MenuItem from "./MenuItem";
import clsx from "clsx";
const CollabButton = ({
isCollaborating,
collaboratorCount,
onClick,
isInHamburgerMenu = true,
}: {
isCollaborating: boolean;
collaboratorCount: number;
onClick: () => void;
isInHamburgerMenu?: boolean;
}) => {
return (
<>
{isInHamburgerMenu ? (
<MenuItem
label={t("labels.liveCollaboration")}
dataTestId="collab-button"
icon={UsersIcon}
onClick={onClick}
isCollaborating={isCollaborating}
/>
) : (
<button
className={clsx("collab-button", { active: isCollaborating })}
type="button"
onClick={onClick}
style={{ position: "relative" }}
title={t("labels.liveCollaboration")}
>
{UsersIcon}
{collaboratorCount > 0 && (
<div className="CollabButton-collaborators">
{collaboratorCount}
</div>
)}
</button>
)}
</>
);
};
export default CollabButton;

View File

@@ -3,9 +3,9 @@ import { Dialog, DialogProps } from "./Dialog";
import "./ConfirmDialog.scss";
import DialogActionButton from "./DialogActionButton";
import { isMenuOpenAtom } from "./App";
import { isDropdownOpenAtom } from "./App";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
import { useExcalidrawSetAppState } from "./App";
interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void;
@@ -23,9 +23,8 @@ const ConfirmDialog = (props: Props) => {
className = "",
...rest
} = props;
const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
return (
<Dialog
@@ -39,16 +38,16 @@ const ConfirmDialog = (props: Props) => {
<DialogActionButton
label={cancelText}
onClick={() => {
setIsMenuOpen(false);
setIsDropdownOpen(false);
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
onCancel();
}}
/>
<DialogActionButton
label={confirmText}
onClick={() => {
setIsMenuOpen(false);
setIsDropdownOpen(false);
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
onConfirm();
}}
actionType="danger"

View File

@@ -39,8 +39,8 @@ export const ContextMenu = React.memo(
if (
item &&
(item === CONTEXT_MENU_SEPARATOR ||
!item.contextItemPredicate ||
item.contextItemPredicate(
!item.predicate ||
item.predicate(
elements,
appState,
actionManager.app.props,

View File

@@ -2,7 +2,11 @@ import clsx from "clsx";
import React, { useEffect, useState } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import { useExcalidrawContainer, useDevice } from "../components/App";
import {
useExcalidrawContainer,
useDevice,
useExcalidrawSetAppState,
} from "../components/App";
import { KEYS } from "../keys";
import "./Dialog.scss";
import { back, CloseIcon } from "./icons";
@@ -10,8 +14,8 @@ import { Island } from "./Island";
import { Modal } from "./Modal";
import { AppState } from "../types";
import { queryFocusableElements } from "../utils";
import { isMenuOpenAtom, isDropdownOpenAtom } from "./App";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
export interface DialogProps {
children: React.ReactNode;
@@ -67,12 +71,12 @@ export const Dialog = (props: DialogProps) => {
return () => islandNode.removeEventListener("keydown", handleKeyDown);
}, [islandNode, props.autofocus]);
const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const onClose = () => {
setIsMenuOpen(false);
setIsDropdownOpen(false);
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
(lastActiveElement as HTMLElement).focus();
props.onCloseRequest();
};

View File

@@ -96,6 +96,10 @@
width: 5rem;
height: 5rem;
margin: 0 0.2em;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 1rem;
background-color: var(--button-color);

View File

@@ -1,3 +1,5 @@
@import "../css/variables.module";
.excalidraw {
.FixedSideContainer {
position: absolute;
@@ -9,10 +11,10 @@
}
.FixedSideContainer_side_top {
left: 1rem;
top: 1rem;
right: 1rem;
bottom: 1rem;
left: var(--editor-container-padding);
top: var(--editor-container-padding);
right: var(--editor-container-padding);
bottom: var(--editor-container-padding);
z-index: 2;
}

View File

@@ -0,0 +1,32 @@
import "./ToolIcon.scss";
import clsx from "clsx";
import { ToolButton } from "./ToolButton";
import { handIcon } from "./icons";
import { KEYS } from "../keys";
type LockIconProps = {
title?: string;
name?: string;
checked: boolean;
onChange?(): void;
isMobile?: boolean;
};
export const HandButton = (props: LockIconProps) => {
return (
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
icon={handIcon}
name="editor-current-shape"
checked={props.checked}
title={`${props.title} — H`}
keyBindingLabel={!props.isMobile ? KEYS.H.toLocaleUpperCase() : undefined}
aria-label={`${props.title} — H`}
aria-keyshortcuts={KEYS.H}
data-testid={`toolbar-hand`}
onChange={() => props.onChange?.()}
/>
);
};

View File

@@ -1,10 +1,12 @@
import React from "react";
import { t } from "../i18n";
import { isDarwin, isWindows, KEYS } from "../keys";
import { KEYS } from "../keys";
import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils";
import "./HelpDialog.scss";
import { ExternalLinkIcon } from "./icons";
import { probablySupportsClipboardBlob } from "../clipboard";
import { isDarwin, isFirefox, isWindows } from "../constants";
const Header = () => (
<div className="HelpDialog__header">
@@ -67,6 +69,10 @@ function* intersperse(as: JSX.Element[][], delim: string | null) {
}
}
const upperCaseSingleChars = (str: string) => {
return str.replace(/\b[a-z]\b/, (c) => c.toUpperCase());
};
const Shortcut = ({
label,
shortcuts,
@@ -81,7 +87,9 @@ const Shortcut = ({
? [...shortcut.slice(0, -2).split("+"), "+"]
: shortcut.split("+");
return keys.map((key) => <ShortcutKey key={key}>{key}</ShortcutKey>);
return keys.map((key) => (
<ShortcutKey key={key}>{upperCaseSingleChars(key)}</ShortcutKey>
));
});
return (
@@ -118,6 +126,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
className="HelpDialog__island--tools"
caption={t("helpDialog.tools")}
>
<Shortcut label={t("toolBar.hand")} shortcuts={[KEYS.H]} />
<Shortcut
label={t("toolBar.selection")}
shortcuts={[KEYS.V, KEYS["1"]]}
@@ -140,11 +149,11 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/>
<Shortcut
label={t("toolBar.line")}
shortcuts={[KEYS.P, KEYS["6"]]}
shortcuts={[KEYS.L, KEYS["6"]]}
/>
<Shortcut
label={t("toolBar.freedraw")}
shortcuts={["Shift + P", KEYS["7"]]}
shortcuts={[KEYS.P, KEYS["7"]]}
/>
<Shortcut
label={t("toolBar.text")}
@@ -230,6 +239,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("helpDialog.zoomToSelection")}
shortcuts={["Shift+2"]}
/>
<Shortcut
label={t("helpDialog.movePageUpDown")}
shortcuts={["PgUp/PgDn"]}
/>
<Shortcut
label={t("helpDialog.movePageLeftRight")}
shortcuts={["Shift+PgUp/PgDn"]}
/>
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
<Shortcut
label={t("buttons.zenMode")}
@@ -296,10 +313,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.pasteAsPlaintext")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]}
/>
<Shortcut
label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("Shift+Alt+C")]}
/>
{/* firefox supports clipboard API under a flag, so we'll
show users what they can do in the error message */}
{(probablySupportsClipboardBlob || isFirefox) && (
<Shortcut
label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("Shift+Alt+C")]}
/>
)}
<Shortcut
label={t("labels.copyStyles")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}

View File

@@ -12,7 +12,7 @@ import Stack from "./Stack";
import "./ExportDialog.scss";
import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING } from "../constants";
import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem";
import { ActionManager } from "../actions/manager";
@@ -190,7 +190,9 @@ const ImageExportModal = ({
>
SVG
</ExportButton>
{probablySupportsClipboardBlob && (
{/* firefox supports clipboard API under a flag,
so let's throw and tell people what they can do */}
{(probablySupportsClipboardBlob || isFirefox) && (
<ExportButton
title={t("buttons.copyPngToClipboard")}
onClick={() => onExportToClipboard(exportedElements)}

View File

@@ -1,10 +1,10 @@
import React, { useState } from "react";
import React from "react";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { AppState, ExportOpts, BinaryFiles } from "../types";
import { Dialog } from "./Dialog";
import { ExportIcon, exportToFileIcon, LinkIcon } from "./icons";
import { exportToFileIcon, LinkIcon } from "./icons";
import { ToolButton } from "./ToolButton";
import { actionSaveFileToDisk } from "../actions/actionExport";
import { Card } from "./Card";
@@ -14,7 +14,6 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import { trackEvent } from "../analytics";
import { ActionManager } from "../actions/manager";
import { getFrame } from "../utils";
import MenuItem from "./MenuItem";
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
@@ -94,6 +93,7 @@ export const JSONExportDialog = ({
actionManager,
exportOpts,
canvas,
setAppState,
}: {
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
@@ -101,24 +101,15 @@ export const JSONExportDialog = ({
actionManager: ActionManager;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"];
}) => {
const [modalIsShown, setModalIsShown] = useState(false);
const handleClose = React.useCallback(() => {
setModalIsShown(false);
}, []);
setAppState({ openDialog: null });
}, [setAppState]);
return (
<>
<MenuItem
icon={ExportIcon}
label={t("buttons.export")}
onClick={() => {
setModalIsShown(true);
}}
dataTestId="json-export-button"
/>
{modalIsShown && (
{appState.openDialog === "jsonExport" && (
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<JSONExportModal
elements={elements}

View File

@@ -80,16 +80,6 @@
}
}
.layer-ui__wrapper__footer-center {
pointer-events: none;
& > * {
pointer-events: all;
}
display: flex;
width: 100%;
justify-content: flex-start;
}
.layer-ui__wrapper__footer-left,
.layer-ui__wrapper__footer-right,
.disable-zen-mode--visible {

View File

@@ -8,16 +8,9 @@ import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { ExportType } from "../scene/types";
import {
AppProps,
AppState,
ExcalidrawProps,
BinaryFiles,
UIChildrenComponents,
} from "../types";
import { muteFSAbortError, ReactChildrenToObject } from "../utils";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
import { isShallowEqual, muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog";
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
import { FixedSideContainer } from "./FixedSideContainer";
@@ -41,26 +34,23 @@ import "./LayerUI.scss";
import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics";
import { isMenuOpenAtom, useDevice } from "../components/App";
import { useDevice } from "../components/App";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./footer/Footer";
import {
ExportImageIcon,
HamburgerMenuIcon,
WelcomeScreenMenuArrow,
WelcomeScreenTopToolbarArrow,
} from "./icons";
import { MenuLinks, Separator } from "./MenuUtils";
import { useOutsideClickHook } from "../hooks/useOutsideClick";
import WelcomeScreen from "./WelcomeScreen";
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai";
import { useAtom } from "jotai";
import { LanguageList } from "../excalidraw-app/components/LanguageList";
import WelcomeScreenDecor from "./WelcomeScreenDecor";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import MenuItem from "./MenuItem";
import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState";
import {
mainMenuTunnel,
welcomeScreenMenuHintTunnel,
welcomeScreenToolbarHintTunnel,
welcomeScreenCenterTunnel,
} from "./tunnels";
interface LayerUIProps {
actionManager: ActionManager;
@@ -69,13 +59,12 @@ interface LayerUIProps {
canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onCollabButtonClick?: () => void;
onLockToggle: () => void;
onHandToolToggle: () => void;
onPenModeToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
showExitZenModeBtn: boolean;
langCode: Language["code"];
isCollaborating: boolean;
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
@@ -89,6 +78,32 @@ interface LayerUIProps {
children?: React.ReactNode;
}
const DefaultMainMenu: React.FC<{
UIOptions: AppProps["UIOptions"];
}> = ({ UIOptions }) => {
return (
<MainMenu __fallback>
<MainMenu.DefaultItems.LoadScene />
<MainMenu.DefaultItems.SaveToActiveFile />
{/* FIXME we should to test for this inside the item itself */}
{UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
{/* FIXME we should to test for this inside the item itself */}
{UIOptions.canvasActions.saveAsImage && (
<MainMenu.DefaultItems.SaveAsImage />
)}
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.Group title="Excalidraw links">
<MainMenu.DefaultItems.Socials />
</MainMenu.Group>
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme />
<MainMenu.DefaultItems.ChangeCanvasBackground />
</MainMenu>
);
};
const LayerUI = ({
actionManager,
appState,
@@ -96,14 +111,12 @@ const LayerUI = ({
setAppState,
elements,
canvas,
onCollabButtonClick,
onLockToggle,
onHandToolToggle,
onPenModeToggle,
onInsertElements,
showExitZenModeBtn,
isCollaborating,
renderTopRightUI,
renderCustomStats,
renderCustomSidebar,
libraryReturnUrl,
@@ -117,9 +130,6 @@ const LayerUI = ({
}: LayerUIProps) => {
const device = useDevice();
const childrenComponents =
ReactChildrenToObject<UIChildrenComponents>(children);
const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) {
return null;
@@ -133,6 +143,7 @@ const LayerUI = ({
actionManager={actionManager}
exportOpts={UIOptions.canvasActions.export}
canvas={canvas}
setAppState={setAppState}
/>
);
};
@@ -186,100 +197,12 @@ const LayerUI = ({
);
};
const [isMenuOpen, setIsMenuOpen] = useAtom(isMenuOpenAtom);
const menuRef = useOutsideClickHook(() => setIsMenuOpen(false));
const renderCanvasActions = () => (
<div style={{ position: "relative" }}>
<WelcomeScreenDecor
shouldRender={renderWelcomeScreen && !appState.isLoading}
>
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--menu-pointer">
{WelcomeScreenMenuArrow}
<div>{t("welcomeScreen.menuHints")}</div>
</div>
</WelcomeScreenDecor>
<button
data-prevent-outside-click
className={clsx("menu-button", "zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
})}
onClick={() => setIsMenuOpen(!isMenuOpen)}
type="button"
data-testid="menu-button"
>
{HamburgerMenuIcon}
</button>
{isMenuOpen && (
<div
ref={menuRef}
style={{ position: "absolute", top: "100%", marginTop: ".25rem" }}
>
<Section heading="canvasActions">
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
<Island
className="menu-container"
padding={2}
style={{ zIndex: 1 }}
>
{!appState.viewModeEnabled &&
actionManager.renderAction("loadScene")}
{/* // TODO barnabasmolnar/editor-redesign */}
{/* is this fine here? */}
{appState.fileHandle &&
actionManager.renderAction("saveToActiveFile")}
{renderJSONExportDialog()}
{UIOptions.canvasActions.saveAsImage && (
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
shortcut={getShortcutFromShortcutName("imageExport")}
/>
)}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{actionManager.renderAction("toggleShortcuts", undefined, true)}
{!appState.viewModeEnabled &&
actionManager.renderAction("clearCanvas")}
<Separator />
<MenuLinks />
<Separator />
<div
style={{
display: "flex",
flexDirection: "column",
rowGap: ".5rem",
}}
>
<div>{actionManager.renderAction("toggleTheme")}</div>
<div style={{ padding: "0 0.625rem" }}>
<LanguageList style={{ width: "100%" }} />
</div>
{!appState.viewModeEnabled && (
<div>
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
{t("labels.canvasBackground")}
</div>
<div style={{ padding: "0 0.625rem" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
</div>
</div>
)}
</div>
</Island>
</Section>
</div>
)}
{/* wrapping to Fragment stops React from occasionally complaining
about identical Keys */}
<mainMenuTunnel.Out />
{renderWelcomeScreen && <welcomeScreenMenuHintTunnel.Out />}
</div>
);
@@ -316,9 +239,6 @@ const LayerUI = ({
return (
<FixedSideContainer side="top">
{renderWelcomeScreen && !appState.isLoading && (
<WelcomeScreen appState={appState} actionManager={actionManager} />
)}
<div className="App-menu App-menu_top">
<Stack.Col
gap={6}
@@ -333,17 +253,9 @@ const LayerUI = ({
<Section heading="shapes" className="shapes-section">
{(heading: React.ReactNode) => (
<div style={{ position: "relative" }}>
<WelcomeScreenDecor
shouldRender={renderWelcomeScreen && !appState.isLoading}
>
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--top-toolbar-pointer">
<div className="WelcomeScreen-decor--top-toolbar-pointer__label">
{t("welcomeScreen.toolbarHints")}
</div>
{WelcomeScreenTopToolbarArrow}
</div>
</WelcomeScreenDecor>
{renderWelcomeScreen && (
<welcomeScreenToolbarHintTunnel.Out />
)}
<Stack.Col gap={4} align="start">
<Stack.Row
gap={1}
@@ -373,13 +285,20 @@ const LayerUI = ({
penDetected={appState.penDetected}
/>
<LockButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.activeTool.locked}
onChange={() => onLockToggle()}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<div className="App-toolbar__divider"></div>
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
<ShapesSwitcher
appState={appState}
canvas={canvas}
@@ -391,9 +310,6 @@ const LayerUI = ({
});
}}
/>
{/* {actionManager.renderAction("eraser", {
// size: "small",
})} */}
</Stack.Row>
</Island>
</Stack.Row>
@@ -410,18 +326,7 @@ const LayerUI = ({
},
)}
>
<UserList
collaborators={appState.collaborators}
actionManager={actionManager}
/>
{onCollabButtonClick && (
<CollabButton
isInHamburgerMenu={false}
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
<UserList collaborators={appState.collaborators} />
{renderTopRightUI?.(device.isMobile, appState)}
{!appState.viewModeEnabled && (
<LibraryButton appState={appState} setAppState={setAppState} />
@@ -451,6 +356,16 @@ const LayerUI = ({
return (
<>
{/* ------------------------- tunneled UI ---------------------------- */}
{/* make sure we render host app components first so that we can detect
them first on initial render to optimize layout shift */}
{children}
{/* render component fallbacks. Can be rendered anywhere as they'll be
tunneled away. We only render tunneled components that actually
have defaults when host do not render anything. */}
<DefaultMainMenu UIOptions={UIOptions} />
{/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />}
{appState.errorMessage && (
<ErrorDialog
@@ -465,7 +380,9 @@ const LayerUI = ({
}}
/>
)}
<ActiveConfirmDialog />
{renderImageExportDialog()}
{renderJSONExportDialog()}
{appState.pasteDialog.shown && (
<PasteChartDialog
setAppState={setAppState}
@@ -480,18 +397,16 @@ const LayerUI = ({
)}
{device.isMobile && (
<MobileMenu
renderWelcomeScreen={renderWelcomeScreen}
appState={appState}
elements={elements}
actionManager={actionManager}
renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={() => onLockToggle()}
onLockToggle={onLockToggle}
onHandToolToggle={onHandToolToggle}
onPenModeToggle={onPenModeToggle}
canvas={canvas}
isCollaborating={isCollaborating}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
@@ -519,16 +434,14 @@ const LayerUI = ({
: {}
}
>
{renderWelcomeScreen && <welcomeScreenCenterTunnel.Out />}
{renderFixedSideContainer()}
<Footer
renderWelcomeScreen={renderWelcomeScreen}
appState={appState}
actionManager={actionManager}
showExitZenModeBtn={showExitZenModeBtn}
>
{childrenComponents.FooterCenter}
</Footer>
renderWelcomeScreen={renderWelcomeScreen}
/>
{appState.showStats && (
<Stats
appState={appState}
@@ -560,28 +473,39 @@ const LayerUI = ({
);
};
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
const {
suggestedBindings,
startBoundElement: boundElement,
...ret
} = appState;
return ret;
};
const prevAppState = getNecessaryObj(prev.appState);
const nextAppState = getNecessaryObj(next.appState);
const stripIrrelevantAppStateProps = (
appState: AppState,
): Partial<AppState> => {
const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
appState;
return ret;
};
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
// short-circuit early
if (prevProps.children !== nextProps.children) {
return false;
}
const {
canvas: _prevCanvas,
// not stable, but shouldn't matter in our case
onInsertElements: _prevOnInsertElements,
appState: prevAppState,
...prev
} = prevProps;
const {
canvas: _nextCanvas,
onInsertElements: _nextOnInsertElements,
appState: nextAppState,
...next
} = nextProps;
return (
prev.renderTopRightUI === next.renderTopRightUI &&
prev.renderCustomStats === next.renderCustomStats &&
prev.renderCustomSidebar === next.renderCustomSidebar &&
prev.langCode === next.langCode &&
prev.elements === next.elements &&
prev.files === next.files &&
keys.every((key) => prevAppState[key] === nextAppState[key])
isShallowEqual(
stripIrrelevantAppStateProps(prevAppState),
stripIrrelevantAppStateProps(nextAppState),
) && isShallowEqual(prev, next)
);
};

View File

@@ -129,4 +129,27 @@
padding-right: 0;
}
}
.layer-ui__sidebar__header .dropdown-menu {
&.dropdown-menu--mobile {
top: 100%;
}
.dropdown-menu-container {
--gap: 0;
z-index: 1;
position: absolute;
top: 100%;
left: 0;
:root[dir="rtl"] & {
right: 0;
left: auto;
}
width: 196px;
box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg);
padding: 0.25rem 0.5rem;
}
}
}

View File

@@ -13,14 +13,15 @@ import {
import { ToolButton } from "./ToolButton";
import { fileOpen } from "../data/filesystem";
import { muteFSAbortError } from "../utils";
import { useAtom } from "jotai";
import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import ConfirmDialog from "./ConfirmDialog";
import PublishLibrary from "./PublishLibrary";
import { Dialog } from "./Dialog";
import { useOutsideClickHook } from "../hooks/useOutsideClick";
import MenuItem from "./MenuItem";
import { isDropdownOpenAtom } from "./App";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
export const isLibraryMenuOpenAtom = atom(false);
const getSelectedItems = (
libraryItems: LibraryItems,
@@ -45,7 +46,9 @@ export const LibraryMenuHeader: React.FC<{
appState,
}) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
isLibraryMenuOpenAtom,
);
const renderRemoveLibAlert = useCallback(() => {
const content = selectedItems.length
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
@@ -173,85 +176,87 @@ export const LibraryMenuHeader: React.FC<{
});
};
const [isDropdownOpen, setIsDropdownOpen] = useAtom(isDropdownOpenAtom);
const dropdownRef = useOutsideClickHook(() => setIsDropdownOpen(false));
const renderLibraryMenu = () => {
return (
<DropdownMenu open={isLibraryMenuOpen}>
<DropdownMenu.Trigger
className="Sidebar__dropdown-btn"
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
>
{DotsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsLibraryMenuOpen(false)}
onSelect={() => setIsLibraryMenuOpen(false)}
className="library-menu"
>
{!itemsSelected && (
<DropdownMenu.Item
onSelect={onLibraryImport}
icon={LoadIcon}
data-testid="lib-dropdown--load"
>
{t("buttons.load")}
</DropdownMenu.Item>
)}
{!!items.length && (
<DropdownMenu.Item
onSelect={onLibraryExport}
icon={ExportIcon}
data-testid="lib-dropdown--export"
>
{t("buttons.export")}
</DropdownMenu.Item>
)}
{!!items.length && (
<DropdownMenu.Item
onSelect={() => setShowRemoveLibAlert(true)}
icon={TrashIcon}
>
{resetLabel}
</DropdownMenu.Item>
)}
{itemsSelected && (
<DropdownMenu.Item
icon={publishIcon}
onSelect={() => setShowPublishLibraryDialog(true)}
data-testid="lib-dropdown--remove"
>
{t("buttons.publishLibrary")}
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu>
);
};
return (
<div style={{ position: "relative" }}>
<button
type="button"
className="Sidebar__dropdown-btn"
data-prevent-outside-click
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
{DotsIcon}
</button>
{renderLibraryMenu()}
{selectedItems.length > 0 && (
<div className="library-actions-counter">{selectedItems.length}</div>
)}
{isDropdownOpen && (
<div
className="Sidebar__dropdown-content menu-container"
ref={dropdownRef}
>
{!itemsSelected && (
<MenuItem
label={t("buttons.load")}
icon={LoadIcon}
dataTestId="lib-dropdown--load"
onClick={onLibraryImport}
/>
{showRemoveLibAlert && renderRemoveLibAlert()}
{showPublishLibraryDialog && (
<PublishLibrary
onClose={() => setShowPublishLibraryDialog(false)}
libraryItems={getSelectedItems(
libraryItemsData.libraryItems,
selectedItems,
)}
{showRemoveLibAlert && renderRemoveLibAlert()}
{showPublishLibraryDialog && (
<PublishLibrary
onClose={() => setShowPublishLibraryDialog(false)}
libraryItems={getSelectedItems(
libraryItemsData.libraryItems,
selectedItems,
)}
appState={appState}
onSuccess={(data) =>
onPublishLibSuccess(data, libraryItemsData.libraryItems)
}
onError={(error) => window.alert(error)}
updateItemsInStorage={() =>
library.setLibrary(libraryItemsData.libraryItems)
}
onRemove={(id: string) =>
onSelectItems(selectedItems.filter((_id) => _id !== id))
}
/>
)}
{publishLibSuccess && renderPublishSuccess()}
{!!items.length && (
<>
<MenuItem
label={t("buttons.export")}
icon={ExportIcon}
onClick={onLibraryExport}
dataTestId="lib-dropdown--export"
/>
<MenuItem
label={resetLabel}
icon={TrashIcon}
onClick={() => setShowRemoveLibAlert(true)}
dataTestId="lib-dropdown--remove"
/>
</>
)}
{itemsSelected && (
<MenuItem
label={t("buttons.publishLibrary")}
icon={publishIcon}
dataTestId="lib-dropdown--publish"
onClick={() => setShowPublishLibraryDialog(true)}
/>
)}
</div>
appState={appState}
onSuccess={(data) =>
onPublishLibSuccess(data, libraryItemsData.libraryItems)
}
onError={(error) => window.alert(error)}
updateItemsInStorage={() =>
library.setLibrary(libraryItemsData.libraryItems)
}
onRemove={(id: string) =>
onSelectItems(selectedItems.filter((_id) => _id !== id))
}
/>
)}
{publishLibSuccess && renderPublishSuccess()}
</div>
);
};

View File

@@ -9,7 +9,6 @@ type LockIconProps = {
name?: string;
checked: boolean;
onChange?(): void;
zenModeEnabled?: boolean;
isMobile?: boolean;
};

View File

@@ -1,85 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.menu-container {
background-color: #fff !important;
max-height: calc(100vh - 150px);
overflow-y: auto;
}
.menu-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
.menu-item {
display: flex;
background-color: transparent;
border: 0;
align-items: center;
padding: 0 0.625rem;
height: 2rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-gray-100);
cursor: pointer;
border-radius: var(--border-radius-md);
width: 100%;
box-sizing: border-box;
font-weight: normal;
font-family: inherit;
@media screen and (min-width: 1921px) {
height: 2.25rem;
}
&__text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
&__shortcut {
margin-inline-start: auto;
opacity: 0.5;
}
&:hover {
background-color: var(--button-hover);
text-decoration: none;
}
svg {
width: 1rem;
height: 1rem;
display: block;
}
&.active-collab {
background-color: #ecfdf5;
color: #064e3c;
}
}
&.theme--dark {
.menu-item {
color: var(--color-gray-40);
&.active-collab {
background-color: #064e3c;
color: #ecfdf5;
}
}
.menu-container {
background-color: var(--color-gray-90) !important;
}
}
}

View File

@@ -1,37 +0,0 @@
import clsx from "clsx";
import "./Menu.scss";
interface MenuProps {
icon: JSX.Element;
onClick: () => void;
label: string;
dataTestId: string;
shortcut?: string;
isCollaborating?: boolean;
}
const MenuItem = ({
icon,
onClick,
label,
dataTestId,
shortcut,
isCollaborating,
}: MenuProps) => {
return (
<button
className={clsx("menu-item", { "active-collab": isCollaborating })}
aria-label={label}
onClick={onClick}
data-testid={dataTestId}
title={label}
type="button"
>
<div className="menu-item__icon">{icon}</div>
<div className="menu-item__text">{label}</div>
{shortcut && <div className="menu-item__shortcut">{shortcut}</div>}
</button>
);
};
export default MenuItem;

View File

@@ -1,53 +0,0 @@
import { GithubIcon, DiscordIcon, PlusPromoIcon, TwitterIcon } from "./icons";
export const MenuLinks = () => (
<>
<a
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger"
target="_blank"
rel="noreferrer"
className="menu-item"
style={{ color: "var(--color-promo)" }}
>
<div className="menu-item__icon">{PlusPromoIcon}</div>
<div className="menu-item__text">Excalidraw+</div>
</a>
<a
className="menu-item"
href="https://github.com/excalidraw/excalidraw"
target="_blank"
rel="noopener noreferrer"
>
<div className="menu-item__icon">{GithubIcon}</div>
<div className="menu-item__text">GitHub</div>
</a>
<a
className="menu-item"
target="_blank"
href="https://discord.gg/UexuTaE"
rel="noopener noreferrer"
>
<div className="menu-item__icon">{DiscordIcon}</div>
<div className="menu-item__text">Discord</div>
</a>
<a
className="menu-item"
target="_blank"
href="https://twitter.com/excalidraw"
rel="noopener noreferrer"
>
<div className="menu-item__icon">{TwitterIcon}</div>
<div className="menu-item__text">Twitter</div>
</a>
</>
);
export const Separator = () => (
<div
style={{
height: "1px",
backgroundColor: "var(--default-border-color)",
margin: ".5rem 0",
}}
/>
);

View File

@@ -11,18 +11,15 @@ import { HintViewer } from "./HintViewer";
import { calculateScrollCenter } from "../scene";
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 { UserList } from "./UserList";
import { LibraryButton } from "./LibraryButton";
import { PenModeButton } from "./PenModeButton";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
import { MenuLinks, Separator } from "./MenuUtils";
import WelcomeScreen from "./WelcomeScreen";
import MenuItem from "./MenuItem";
import { ExportImageIcon } from "./icons";
import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState";
import { mainMenuTunnel, welcomeScreenCenterTunnel } from "./tunnels";
type MobileMenuProps = {
appState: AppState;
@@ -31,11 +28,10 @@ type MobileMenuProps = {
renderImageExportDialog: () => React.ReactNode;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onCollabButtonClick?: () => void;
onLockToggle: () => void;
onHandToolToggle: () => void;
onPenModeToggle: () => void;
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: (
@@ -45,34 +41,27 @@ type MobileMenuProps = {
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen?: boolean;
};
export const MobileMenu = ({
appState,
elements,
actionManager,
renderJSONExportDialog,
renderImageExportDialog,
setAppState,
onCollabButtonClick,
onLockToggle,
onHandToolToggle,
onPenModeToggle,
canvas,
isCollaborating,
onImageAction,
renderTopRightUI,
renderCustomStats,
renderSidebars,
device,
renderWelcomeScreen,
}: MobileMenuProps) => {
const renderToolbar = () => {
return (
<FixedSideContainer side="top" className="App-top-bar">
{renderWelcomeScreen && !appState.isLoading && (
<WelcomeScreen appState={appState} actionManager={actionManager} />
)}
<welcomeScreenCenterTunnel.Out />
<Section heading="shapes">
{(heading: React.ReactNode) => (
<Stack.Col gap={4} align="center">
@@ -80,20 +69,6 @@ export const MobileMenu = ({
<Island padding={1} className="App-toolbar App-toolbar--mobile">
{heading}
<Stack.Row gap={1}>
{/* <PenModeButton
checked={appState.penMode}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
isMobile
/>
<div className="App-toolbar__divider"></div> */}
<ShapesSwitcher
appState={appState}
canvas={canvas}
@@ -109,20 +84,6 @@ export const MobileMenu = ({
</Island>
{renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container">
<PenModeButton
checked={appState.penMode}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
// penDetected={true}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
isMobile
/>
{!appState.viewModeEnabled && (
<LibraryButton
appState={appState}
@@ -130,6 +91,25 @@ export const MobileMenu = ({
isMobile
/>
)}
<PenModeButton
checked={appState.penMode}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
isMobile
/>
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
</div>
</Stack.Row>
</Stack.Col>
@@ -149,14 +129,14 @@ export const MobileMenu = ({
if (appState.viewModeEnabled) {
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
<mainMenuTunnel.Out />
</div>
);
}
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
<mainMenuTunnel.Out />
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
@@ -168,58 +148,6 @@ export const MobileMenu = ({
);
};
const renderCanvasActions = () => {
if (appState.viewModeEnabled) {
return (
<>
{renderJSONExportDialog()}
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
/>
{renderImageExportDialog()}
</>
);
}
return (
<>
{!appState.viewModeEnabled && actionManager.renderAction("loadScene")}
{renderJSONExportDialog()}
{renderImageExportDialog()}
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
/>
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{actionManager.renderAction("toggleShortcuts", undefined, true)}
{!appState.viewModeEnabled && actionManager.renderAction("clearCanvas")}
<Separator />
<MenuLinks />
<Separator />
{!appState.viewModeEnabled && (
<div style={{ marginBottom: ".5rem" }}>
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
{t("labels.canvasBackground")}
</div>
<div style={{ padding: "0 0.625rem" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
</div>
</div>
)}
{actionManager.renderAction("toggleTheme")}
</>
);
};
return (
<>
{renderSidebars()}
@@ -244,27 +172,9 @@ export const MobileMenu = ({
}}
>
<Island padding={0}>
{appState.openMenu === "canvas" ? (
<Section className="App-mobile-menu" heading="canvasActions">
<div className="panelColumn">
<Stack.Col gap={2}>
{renderCanvasActions()}
{appState.collaborators.size > 0 && (
<fieldset>
<legend>{t("labels.collaborators")}</legend>
<UserList
mobile
collaborators={appState.collaborators}
actionManager={actionManager}
/>
</fieldset>
)}
</Stack.Col>
</div>
</Section>
) : appState.openMenu === "shape" &&
!appState.viewModeEnabled &&
showSelectedShapeActions(appState, elements) ? (
{appState.openMenu === "shape" &&
!appState.viewModeEnabled &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions
appState={appState}

View File

@@ -3,24 +3,6 @@
.excalidraw {
.Sidebar {
&__dropdown-content {
z-index: 1;
position: absolute;
top: 100%;
left: 0;
:root[dir="rtl"] & {
right: 0;
left: auto;
}
margin-top: 0.25rem;
width: 180px;
box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg);
padding: 0.25rem 0.5rem;
}
&__close-btn,
&__pin-btn,
&__dropdown-btn {

View File

@@ -19,7 +19,7 @@ type ToolButtonBaseProps = {
name?: string;
id?: string;
size?: ToolButtonSize;
keyBindingLabel?: string;
keyBindingLabel?: string | null;
showAriaLabel?: boolean;
hidden?: boolean;
visible?: boolean;

View File

@@ -4,16 +4,16 @@ import React from "react";
import clsx from "clsx";
import { AppState, Collaborator } from "../types";
import { Tooltip } from "./Tooltip";
import { ActionManager } from "../actions/manager";
import { useExcalidrawActionManager } from "./App";
export const UserList: React.FC<{
className?: string;
mobile?: boolean;
collaborators: AppState["collaborators"];
actionManager: ActionManager;
}> = ({ className, mobile, collaborators, actionManager }) => {
const uniqueCollaborators = new Map<string, Collaborator>();
}> = ({ className, mobile, collaborators }) => {
const actionManager = useExcalidrawActionManager();
const uniqueCollaborators = new Map<string, Collaborator>();
collaborators.forEach((collaborator, socketId) => {
uniqueCollaborators.set(
// filter on user id, else fall back on unique socketId
@@ -44,26 +44,6 @@ export const UserList: React.FC<{
);
});
// TODO barnabasmolnar/editor-redesign
// probably remove before shipping :)
// 20 fake collaborators; for easy, convenient debug purposes ˇˇ
// const avatars = Array.from({ length: 20 }).map((_, index) => {
// const avatarJSX = actionManager.renderAction("goToCollaborator", [
// index.toString(),
// {
// username: `User ${index}`,
// },
// ]);
// return mobile ? (
// <Tooltip label={`User ${index}`} key={index}>
// {avatarJSX}
// </Tooltip>
// ) : (
// <React.Fragment key={index}>{avatarJSX}</React.Fragment>
// );
// });
return (
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
{avatars}

View File

@@ -1,137 +0,0 @@
import { useAtom } from "jotai";
import { actionLoadScene, actionShortcuts } from "../actions";
import { ActionManager } from "../actions/manager";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { isExcalidrawPlusSignedUser } from "../constants";
import { collabDialogShownAtom } from "../excalidraw-app/collab/Collab";
import { t } from "../i18n";
import { AppState } from "../types";
import {
ExcalLogo,
HelpIcon,
LoadIcon,
PlusPromoIcon,
UsersIcon,
} from "./icons";
import "./WelcomeScreen.scss";
const WelcomeScreenItem = ({
label,
shortcut,
onClick,
icon,
link,
}: {
label: string;
shortcut: string | null;
onClick?: () => void;
icon: JSX.Element;
link?: string;
}) => {
if (link) {
return (
<a
className="WelcomeScreen-item"
href={link}
target="_blank"
rel="noreferrer"
>
<div className="WelcomeScreen-item__label">
{icon}
{label}
</div>
</a>
);
}
return (
<button className="WelcomeScreen-item" type="button" onClick={onClick}>
<div className="WelcomeScreen-item__label">
{icon}
{label}
</div>
{shortcut && (
<div className="WelcomeScreen-item__shortcut">{shortcut}</div>
)}
</button>
);
};
const WelcomeScreen = ({
appState,
actionManager,
}: {
appState: AppState;
actionManager: ActionManager;
}) => {
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
let subheadingJSX;
if (isExcalidrawPlusSignedUser) {
subheadingJSX = t("welcomeScreen.switchToPlusApp")
.split(/(Excalidraw\+)/)
.map((bit, idx) => {
if (bit === "Excalidraw+") {
return (
<a
style={{ pointerEvents: "all" }}
href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
key={idx}
>
Excalidraw+
</a>
);
}
return bit;
});
} else {
subheadingJSX = t("welcomeScreen.data");
}
return (
<div className="WelcomeScreen-container">
<div className="WelcomeScreen-logo virgil WelcomeScreen-decor">
{ExcalLogo} Excalidraw
</div>
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--subheading">
{subheadingJSX}
</div>
<div className="WelcomeScreen-items">
{!appState.viewModeEnabled && (
<WelcomeScreenItem
// TODO barnabasmolnar/editor-redesign
// do we want the internationalized labels here that are currently
// in use elsewhere or new ones?
label={t("buttons.load")}
onClick={() => actionManager.executeAction(actionLoadScene)}
shortcut={getShortcutFromShortcutName("loadScene")}
icon={LoadIcon}
/>
)}
<WelcomeScreenItem
label={t("labels.liveCollaboration")}
shortcut={null}
onClick={() => setCollabDialogShown(true)}
icon={UsersIcon}
/>
<WelcomeScreenItem
onClick={() => actionManager.executeAction(actionShortcuts)}
label={t("helpDialog.title")}
shortcut="?"
icon={HelpIcon}
/>
{!isExcalidrawPlusSignedUser && (
<WelcomeScreenItem
link="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
label="Try Excalidraw Plus!"
shortcut={null}
icon={PlusPromoIcon}
/>
)}
</div>
</div>
);
};
export default WelcomeScreen;

View File

@@ -1,11 +0,0 @@
import { ReactNode } from "react";
const WelcomeScreenDecor = ({
children,
shouldRender,
}: {
children: ReactNode;
shouldRender: boolean;
}) => (shouldRender ? <>{children}</> : null);
export default WelcomeScreenDecor;

View File

@@ -0,0 +1,127 @@
@import "../../css/variables.module";
.excalidraw {
.dropdown-menu {
position: absolute;
top: 100%;
margin-top: 0.25rem;
&--mobile {
bottom: 55px;
top: auto;
left: 0;
width: 100%;
display: flex;
flex-direction: column;
row-gap: 0.75rem;
.dropdown-menu-container {
padding: 8px 8px;
box-sizing: border-box;
background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
position: relative;
transition: box-shadow 0.5s ease-in-out;
&.zen-mode {
box-shadow: none;
}
}
}
.dropdown-menu-container {
background-color: #fff !important;
max-height: calc(100vh - 150px);
overflow-y: auto;
--gap: 2;
}
.dropdown-menu-item-base {
display: flex;
padding: 0 0.625rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-gray-100);
width: 100%;
box-sizing: border-box;
font-weight: normal;
font-family: inherit;
}
.dropdown-menu-item {
background-color: transparent;
border: 0;
align-items: center;
height: 2rem;
cursor: pointer;
border-radius: var(--border-radius-md);
@media screen and (min-width: 1921px) {
height: 2.25rem;
}
&__text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
&__shortcut {
margin-inline-start: auto;
opacity: 0.5;
}
&:hover {
background-color: var(--button-hover-bg);
text-decoration: none;
}
svg {
width: 1rem;
height: 1rem;
display: block;
}
}
.dropdown-menu-item-custom {
margin-top: 0.5rem;
}
.dropdown-menu-group-title {
font-size: 14px;
text-align: left;
margin: 10px 0;
font-weight: 500;
}
}
&.theme--dark {
.dropdown-menu-item {
color: var(--color-gray-40);
}
.dropdown-menu-container {
background-color: var(--color-gray-90) !important;
}
}
.dropdown-menu-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
&--mobile {
border: none;
margin: 0;
padding: 0;
width: var(--default-button-size);
height: var(--default-button-size);
}
}
}

View File

@@ -0,0 +1,43 @@
import React from "react";
import DropdownMenuTrigger from "./DropdownMenuTrigger";
import DropdownMenuItem from "./DropdownMenuItem";
import MenuSeparator from "./DropdownMenuSeparator";
import DropdownMenuGroup from "./DropdownMenuGroup";
import DropdownMenuContent from "./DropdownMenuContent";
import DropdownMenuItemLink from "./DropdownMenuItemLink";
import DropdownMenuItemCustom from "./DropdownMenuItemCustom";
import {
getMenuContentComponent,
getMenuTriggerComponent,
} from "./dropdownMenuUtils";
import "./DropdownMenu.scss";
const DropdownMenu = ({
children,
open,
}: {
children?: React.ReactNode;
open: boolean;
}) => {
const MenuTriggerComp = getMenuTriggerComponent(children);
const MenuContentComp = getMenuContentComponent(children);
return (
<>
{MenuTriggerComp}
{open && MenuContentComp}
</>
);
};
DropdownMenu.Trigger = DropdownMenuTrigger;
DropdownMenu.Content = DropdownMenuContent;
DropdownMenu.Item = DropdownMenuItem;
DropdownMenu.ItemLink = DropdownMenuItemLink;
DropdownMenu.ItemCustom = DropdownMenuItemCustom;
DropdownMenu.Group = DropdownMenuGroup;
DropdownMenu.Separator = MenuSeparator;
export default DropdownMenu;
DropdownMenu.displayName = "DropdownMenu";

View File

@@ -0,0 +1,62 @@
import { useOutsideClickHook } from "../../hooks/useOutsideClick";
import { Island } from "../Island";
import { useDevice } from "../App";
import clsx from "clsx";
import Stack from "../Stack";
import React from "react";
import { DropdownMenuContentPropsContext } from "./common";
const MenuContent = ({
children,
onClickOutside,
className = "",
onSelect,
style,
}: {
children?: React.ReactNode;
onClickOutside?: () => void;
className?: string;
/**
* Called when any menu item is selected (clicked on).
*/
onSelect?: (event: Event) => void;
style?: React.CSSProperties;
}) => {
const device = useDevice();
const menuRef = useOutsideClickHook(() => {
onClickOutside?.();
});
const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": device.isMobile,
}).trim();
return (
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
<div
ref={menuRef}
className={classNames}
style={style}
data-testid="dropdown-menu"
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
{device.isMobile ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : (
<Island
className="dropdown-menu-container"
padding={2}
style={{ zIndex: 1 }}
>
{children}
</Island>
)}
</div>
</DropdownMenuContentPropsContext.Provider>
);
};
MenuContent.displayName = "DropdownMenuContent";
export default MenuContent;

View File

@@ -0,0 +1,23 @@
import React from "react";
const MenuGroup = ({
children,
className = "",
style,
title,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
title?: string;
}) => {
return (
<div className={`dropdown-menu-group ${className}`} style={style}>
{title && <p className="dropdown-menu-group-title">{title}</p>}
{children}
</div>
);
};
export default MenuGroup;
MenuGroup.displayName = "DropdownMenuGroup";

View File

@@ -0,0 +1,40 @@
import React from "react";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
import MenuItemContent from "./DropdownMenuItemContent";
const DropdownMenuItem = ({
icon,
onSelect,
children,
shortcut,
className,
...rest
}: {
icon?: JSX.Element;
onSelect: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return (
<button
{...rest}
onClick={handleClick}
type="button"
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</button>
);
};
export default DropdownMenuItem;
DropdownMenuItem.displayName = "DropdownMenuItem";

View File

@@ -0,0 +1,23 @@
import { useDevice } from "../App";
const MenuItemContent = ({
icon,
shortcut,
children,
}: {
icon?: JSX.Element;
shortcut?: string;
children: React.ReactNode;
}) => {
const device = useDevice();
return (
<>
<div className="dropdown-menu-item__icon">{icon}</div>
<div className="dropdown-menu-item__text">{children}</div>
{shortcut && !device.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
)}
</>
);
};
export default MenuItemContent;

View File

@@ -0,0 +1,21 @@
import React from "react";
const DropdownMenuItemCustom = ({
children,
className = "",
...rest
}: {
children: React.ReactNode;
className?: string;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
{...rest}
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
>
{children}
</div>
);
};
export default DropdownMenuItemCustom;

View File

@@ -0,0 +1,44 @@
import MenuItemContent from "./DropdownMenuItemContent";
import React from "react";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
const DropdownMenuItemLink = ({
icon,
shortcut,
href,
children,
onSelect,
className = "",
...rest
}: {
href: string;
icon?: JSX.Element;
children: React.ReactNode;
shortcut?: string;
className?: string;
onSelect?: (event: Event) => void;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return (
<a
{...rest}
href={href}
target="_blank"
rel="noreferrer"
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
onClick={handleClick}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</a>
);
};
export default DropdownMenuItemLink;
DropdownMenuItemLink.displayName = "DropdownMenuItemLink";

View File

@@ -0,0 +1,14 @@
import React from "react";
const MenuSeparator = () => (
<div
style={{
height: "1px",
backgroundColor: "var(--default-border-color)",
margin: ".5rem 0",
}}
/>
);
export default MenuSeparator;
MenuSeparator.displayName = "DropdownMenuSeparator";

View File

@@ -0,0 +1,37 @@
import clsx from "clsx";
import { useDevice, useExcalidrawAppState } from "../App";
const MenuTrigger = ({
className = "",
children,
onToggle,
}: {
className?: string;
children: React.ReactNode;
onToggle: () => void;
}) => {
const appState = useExcalidrawAppState();
const device = useDevice();
const classNames = clsx(
`dropdown-menu-button ${className}`,
"zen-mode-transition",
{
"transition-left": appState.zenModeEnabled,
"dropdown-menu-button--mobile": device.isMobile,
},
).trim();
return (
<button
data-prevent-outside-click
className={classNames}
onClick={onToggle}
type="button"
data-testid="dropdown-menu-button"
>
{children}
</button>
);
};
export default MenuTrigger;
MenuTrigger.displayName = "DropdownMenuTrigger";

View File

@@ -0,0 +1,31 @@
import React, { useContext } from "react";
import { EVENT } from "../../constants";
import { composeEventHandlers } from "../../utils";
export const DropdownMenuContentPropsContext = React.createContext<{
onSelect?: (event: Event) => void;
}>({});
export const getDropdownMenuItemClassName = (className = "") => {
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
};
export const useHandleDropdownMenuItemClick = (
origOnClick:
| React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>
| undefined,
onSelect: ((event: Event) => void) | undefined,
) => {
const DropdownMenuContentProps = useContext(DropdownMenuContentPropsContext);
return composeEventHandlers(origOnClick, (event) => {
const itemSelectEvent = new CustomEvent(EVENT.MENU_ITEM_SELECT, {
bubbles: true,
cancelable: true,
});
onSelect?.(itemSelectEvent);
if (!itemSelectEvent.defaultPrevented) {
DropdownMenuContentProps.onSelect?.(itemSelectEvent);
}
});
};

View File

@@ -0,0 +1,35 @@
import React from "react";
export const getMenuTriggerComponent = (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
typeof child.type !== "string" &&
//@ts-ignore
child?.type.displayName &&
//@ts-ignore
child.type.displayName === "DropdownMenuTrigger",
);
if (!comp) {
return null;
}
//@ts-ignore
return comp;
};
export const getMenuContentComponent = (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
typeof child.type !== "string" &&
//@ts-ignore
child?.type.displayName &&
//@ts-ignore
child.type.displayName === "DropdownMenuContent",
);
if (!comp) {
return null;
}
//@ts-ignore
return comp;
};

View File

@@ -1,6 +1,6 @@
import clsx from "clsx";
import { actionShortcuts } from "../../actions";
import { ActionManager } from "../../actions/manager";
import { t } from "../../i18n";
import { AppState } from "../../types";
import {
ExitZenModeAction,
@@ -9,24 +9,21 @@ import {
ZoomActions,
} from "../Actions";
import { useDevice } from "../App";
import { WelcomeScreenHelpArrow } from "../icons";
import { HelpButton } from "../HelpButton";
import { Section } from "../Section";
import Stack from "../Stack";
import WelcomeScreenDecor from "../WelcomeScreenDecor";
import FooterCenter from "./FooterCenter";
import { footerCenterTunnel, welcomeScreenHelpHintTunnel } from "../tunnels";
const Footer = ({
appState,
actionManager,
showExitZenModeBtn,
renderWelcomeScreen,
children,
}: {
appState: AppState;
actionManager: ActionManager;
showExitZenModeBtn: boolean;
renderWelcomeScreen: boolean;
children?: React.ReactNode;
}) => {
const device = useDevice();
const showFinalize =
@@ -71,23 +68,17 @@ const Footer = ({
</Section>
</Stack.Col>
</div>
<FooterCenter>{children}</FooterCenter>
<footerCenterTunnel.Out />
<div
className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
"transition-right disable-pointerEvents": appState.zenModeEnabled,
})}
>
<div style={{ position: "relative" }}>
<WelcomeScreenDecor
shouldRender={renderWelcomeScreen && !appState.isLoading}
>
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--help-pointer">
<div>{t("welcomeScreen.helpHints")}</div>
{WelcomeScreenHelpArrow}
</div>
</WelcomeScreenDecor>
{actionManager.renderAction("toggleShortcuts")}
{renderWelcomeScreen && <welcomeScreenHelpHintTunnel.Out />}
<HelpButton
onClick={() => actionManager.executeAction(actionShortcuts)}
/>
</div>
</div>
<ExitZenModeAction

View File

@@ -0,0 +1,10 @@
.footer-center {
pointer-events: none;
& > * {
pointer-events: all;
}
display: flex;
width: 100%;
justify-content: flex-start;
}

View File

@@ -1,17 +1,21 @@
import clsx from "clsx";
import { useExcalidrawAppState } from "../App";
import { footerCenterTunnel } from "../tunnels";
import "./FooterCenter.scss";
const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
const appState = useExcalidrawAppState();
return (
<div
className={clsx("layer-ui__wrapper__footer-center zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
})}
>
{children}
</div>
<footerCenterTunnel.In>
<div
className={clsx("footer-center zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
})}
>
{children}
</div>
</footerCenterTunnel.In>
);
};

View File

@@ -0,0 +1,50 @@
import { atom, useAtom } from "jotai";
import React, { useLayoutEffect } from "react";
export const withInternalFallback = <P,>(
componentName: string,
Component: React.FC<P>,
) => {
const counterAtom = atom(0);
// flag set on initial render to tell the fallback component to skip the
// render until mount counter are initialized. This is because the counter
// is initialized in an effect, and thus we could end rendering both
// components at the same time until counter is initialized.
let preferHost = false;
const WrapperComponent: React.FC<
P & {
__fallback?: boolean;
}
> = (props) => {
const [counter, setCounter] = useAtom(counterAtom);
useLayoutEffect(() => {
setCounter((counter) => counter + 1);
return () => {
setCounter((counter) => counter - 1);
};
}, [setCounter]);
if (!props.__fallback) {
preferHost = true;
}
// ensure we don't render fallback and host components at the same time
if (
// either before the counters are initialized
(!counter && props.__fallback && preferHost) ||
// or after the counters are initialized, and both are rendered
// (this is the default when host renders as well)
(counter > 1 && props.__fallback)
) {
return null;
}
return <Component {...props} />;
};
WrapperComponent.displayName = componentName;
return WrapperComponent;
};

View File

@@ -883,7 +883,7 @@ export const CenterHorizontallyIcon = createIcon(
modifiedTablerIconProps,
);
export const UsersIcon = createIcon(
export const usersIcon = createIcon(
<g strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="9" cy="7" r="4"></circle>
@@ -1532,3 +1532,14 @@ export const publishIcon = createIcon(
export const eraser = createIcon(
<path d="M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z" />,
);
export const handIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M8 13v-7.5a1.5 1.5 0 0 1 3 0v6.5"></path>
<path d="M11 5.5v-2a1.5 1.5 0 1 1 3 0v8.5"></path>
<path d="M14 5.5a1.5 1.5 0 0 1 3 0v6.5"></path>
<path d="M17 7.5a1.5 1.5 0 0 1 3 0v8.5a6 6 0 0 1 -6 6h-2h.208a6 6 0 0 1 -5.012 -2.7a69.74 69.74 0 0 1 -.196 -.3c-.312 -.479 -1.407 -2.388 -3.286 -5.728a1.5 1.5 0 0 1 .536 -2.022a1.867 1.867 0 0 1 2.28 .28l1.47 1.47"></path>
</g>,
tablerIconProps,
);

View File

@@ -1,30 +1,23 @@
@import "../css/variables.module";
@import "../../css/variables.module";
.excalidraw {
.collab-button {
@include outlineButtonStyles;
width: var(--lg-button-size);
height: var(--lg-button-size);
--button-bg: var(--color-primary);
--button-color: white;
--button-border: var(--color-primary);
--button-width: var(--lg-button-size);
--button-height: var(--lg-button-size);
--button-hover-bg: var(--color-primary-darker);
--button-hover-border: var(--color-primary-darker);
--button-active-bg: var(--color-primary-darker);
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
background-color: var(--color-primary);
border-color: var(--color-primary);
color: white;
flex-shrink: 0;
&:hover {
background-color: var(--color-primary-darker);
border-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darker);
}
&.active {
// double .active to force specificity
&.active.active {
background-color: #0fb884;
border-color: #0fb884;

View File

@@ -0,0 +1,40 @@
import { t } from "../../i18n";
import { usersIcon } from "../icons";
import { Button } from "../Button";
import clsx from "clsx";
import { useExcalidrawAppState } from "../App";
import "./LiveCollaborationTrigger.scss";
const LiveCollaborationTrigger = ({
isCollaborating,
onSelect,
...rest
}: {
isCollaborating: boolean;
onSelect: () => void;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const appState = useExcalidrawAppState();
return (
<Button
{...rest}
className={clsx("collab-button", { active: isCollaborating })}
type="button"
onSelect={onSelect}
style={{ position: "relative" }}
title={t("labels.liveCollaboration")}
>
{usersIcon}
{appState.collaborators.size > 0 && (
<div className="CollabButton-collaborators">
{appState.collaborators.size}
</div>
)}
</Button>
);
};
export default LiveCollaborationTrigger;
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";

View File

@@ -0,0 +1,268 @@
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t } from "../../i18n";
import {
useExcalidrawAppState,
useExcalidrawSetAppState,
useExcalidrawActionManager,
} from "../App";
import {
ExportIcon,
ExportImageIcon,
HelpIcon,
LoadIcon,
MoonIcon,
save,
SunIcon,
TrashIcon,
usersIcon,
} from "../icons";
import { GithubIcon, DiscordIcon, TwitterIcon } from "../icons";
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import {
actionClearCanvas,
actionLoadScene,
actionSaveToActiveFile,
actionShortcuts,
actionToggleTheme,
} from "../../actions";
import "./DefaultItems.scss";
import clsx from "clsx";
import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
export const LoadScene = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionLoadScene)) {
return null;
}
return (
<DropdownMenuItem
icon={LoadIcon}
onSelect={() => actionManager.executeAction(actionLoadScene)}
data-testid="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
aria-label={t("buttons.load")}
>
{t("buttons.load")}
</DropdownMenuItem>
);
};
LoadScene.displayName = "LoadScene";
export const SaveToActiveFile = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionSaveToActiveFile)) {
return null;
}
return (
<DropdownMenuItem
shortcut={getShortcutFromShortcutName("saveScene")}
data-testid="save-button"
onSelect={() => actionManager.executeAction(actionSaveToActiveFile)}
icon={save}
aria-label={`${t("buttons.save")}`}
>{`${t("buttons.save")}`}</DropdownMenuItem>
);
};
SaveToActiveFile.displayName = "SaveToActiveFile";
export const SaveAsImage = () => {
const setAppState = useExcalidrawSetAppState();
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
return (
<DropdownMenuItem
icon={ExportImageIcon}
data-testid="image-export-button"
onSelect={() => setAppState({ openDialog: "imageExport" })}
shortcut={getShortcutFromShortcutName("imageExport")}
aria-label={t("buttons.exportImage")}
>
{t("buttons.exportImage")}
</DropdownMenuItem>
);
};
SaveAsImage.displayName = "SaveAsImage";
export const Help = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
return (
<DropdownMenuItem
data-testid="help-menu-item"
icon={HelpIcon}
onSelect={() => actionManager.executeAction(actionShortcuts)}
shortcut="?"
aria-label={t("helpDialog.title")}
>
{t("helpDialog.title")}
</DropdownMenuItem>
);
};
Help.displayName = "Help";
export const ClearCanvas = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionClearCanvas)) {
return null;
}
return (
<DropdownMenuItem
icon={TrashIcon}
onSelect={() => setActiveConfirmDialog("clearCanvas")}
data-testid="clear-canvas-button"
aria-label={t("buttons.clearReset")}
>
{t("buttons.clearReset")}
</DropdownMenuItem>
);
};
ClearCanvas.displayName = "ClearCanvas";
export const ToggleTheme = () => {
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionToggleTheme)) {
return null;
}
return (
<DropdownMenuItem
onSelect={(event) => {
// do not close the menu when changing theme
event.preventDefault();
return actionManager.executeAction(actionToggleTheme);
}}
icon={appState.theme === "dark" ? SunIcon : MoonIcon}
data-testid="toggle-dark-mode"
shortcut={getShortcutFromShortcutName("toggleTheme")}
aria-label={
appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")
}
>
{appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")}
</DropdownMenuItem>
);
};
ToggleTheme.displayName = "ToggleTheme";
export const ChangeCanvasBackground = () => {
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) {
return null;
}
return (
<div style={{ marginTop: "0.5rem" }}>
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
{t("labels.canvasBackground")}
</div>
<div style={{ padding: "0 0.625rem" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
</div>
</div>
);
};
ChangeCanvasBackground.displayName = "ChangeCanvasBackground";
export const Export = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
return (
<DropdownMenuItem
icon={ExportIcon}
onSelect={() => {
setAppState({ openDialog: "jsonExport" });
}}
data-testid="json-export-button"
aria-label={t("buttons.export")}
>
{t("buttons.export")}
</DropdownMenuItem>
);
};
Export.displayName = "Export";
export const Socials = () => (
<>
<DropdownMenuItemLink
icon={GithubIcon}
href="https://github.com/excalidraw/excalidraw"
aria-label="GitHub"
>
GitHub
</DropdownMenuItemLink>
<DropdownMenuItemLink
icon={DiscordIcon}
href="https://discord.gg/UexuTaE"
aria-label="Discord"
>
Discord
</DropdownMenuItemLink>
<DropdownMenuItemLink
icon={TwitterIcon}
href="https://twitter.com/excalidraw"
aria-label="Twitter"
>
Twitter
</DropdownMenuItemLink>
</>
);
Socials.displayName = "Socials";
export const LiveCollaborationTrigger = ({
onSelect,
isCollaborating,
}: {
onSelect: () => void;
isCollaborating: boolean;
}) => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
return (
<DropdownMenuItem
data-testid="collab-button"
icon={usersIcon}
className={clsx({
"active-collab": isCollaborating,
})}
onSelect={onSelect}
>
{t("labels.liveCollaboration")}
</DropdownMenuItem>
);
};
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";

View File

@@ -0,0 +1,83 @@
import React from "react";
import {
useDevice,
useExcalidrawAppState,
useExcalidrawSetAppState,
} from "../App";
import DropdownMenu from "../dropdownMenu/DropdownMenu";
import * as DefaultItems from "./DefaultItems";
import { UserList } from "../UserList";
import { t } from "../../i18n";
import { HamburgerMenuIcon } from "../icons";
import { withInternalFallback } from "../hoc/withInternalFallback";
import { composeEventHandlers } from "../../utils";
import { mainMenuTunnel } from "../tunnels";
const MainMenu = Object.assign(
withInternalFallback(
"MainMenu",
({
children,
onSelect,
}: {
children?: React.ReactNode;
/**
* Called when any menu item is selected (clicked on).
*/
onSelect?: (event: Event) => void;
}) => {
const device = useDevice();
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
const onClickOutside = device.isMobile
? undefined
: () => setAppState({ openMenu: null });
return (
<mainMenuTunnel.In>
<DropdownMenu open={appState.openMenu === "canvas"}>
<DropdownMenu.Trigger
onToggle={() => {
setAppState({
openMenu: appState.openMenu === "canvas" ? null : "canvas",
});
}}
>
{HamburgerMenuIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={onClickOutside}
onSelect={composeEventHandlers(onSelect, () => {
setAppState({ openMenu: null });
})}
>
{children}
{device.isMobile && appState.collaborators.size > 0 && (
<fieldset className="UserList-Wrapper">
<legend>{t("labels.collaborators")}</legend>
<UserList
mobile={true}
collaborators={appState.collaborators}
/>
</fieldset>
)}
</DropdownMenu.Content>
</DropdownMenu>
</mainMenuTunnel.In>
);
},
),
{
Trigger: DropdownMenu.Trigger,
Item: DropdownMenu.Item,
ItemLink: DropdownMenu.ItemLink,
ItemCustom: DropdownMenu.ItemCustom,
Group: DropdownMenu.Group,
Separator: DropdownMenu.Separator,
DefaultItems,
},
);
export default MainMenu;

View File

@@ -0,0 +1,8 @@
import tunnel from "tunnel-rat";
export const mainMenuTunnel = tunnel();
export const welcomeScreenMenuHintTunnel = tunnel();
export const welcomeScreenToolbarHintTunnel = tunnel();
export const welcomeScreenHelpHintTunnel = tunnel();
export const welcomeScreenCenterTunnel = tunnel();
export const footerCenterTunnel = tunnel();

View File

@@ -0,0 +1,198 @@
import { actionLoadScene, actionShortcuts } from "../../actions";
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t } from "../../i18n";
import {
useDevice,
useExcalidrawActionManager,
useExcalidrawAppState,
} from "../App";
import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons";
import { welcomeScreenCenterTunnel } from "../tunnels";
const WelcomeScreenMenuItemContent = ({
icon,
shortcut,
children,
}: {
icon?: JSX.Element;
shortcut?: string | null;
children: React.ReactNode;
}) => {
const device = useDevice();
return (
<>
<div className="welcome-screen-menu-item__icon">{icon}</div>
<div className="welcome-screen-menu-item__text">{children}</div>
{shortcut && !device.isMobile && (
<div className="welcome-screen-menu-item__shortcut">{shortcut}</div>
)}
</>
);
};
WelcomeScreenMenuItemContent.displayName = "WelcomeScreenMenuItemContent";
const WelcomeScreenMenuItem = ({
onSelect,
children,
icon,
shortcut,
className = "",
...props
}: {
onSelect: () => void;
children: React.ReactNode;
icon?: JSX.Element;
shortcut?: string | null;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
return (
<button
{...props}
type="button"
className={`welcome-screen-menu-item ${className}`}
onClick={onSelect}
>
<WelcomeScreenMenuItemContent icon={icon} shortcut={shortcut}>
{children}
</WelcomeScreenMenuItemContent>
</button>
);
};
WelcomeScreenMenuItem.displayName = "WelcomeScreenMenuItem";
const WelcomeScreenMenuItemLink = ({
children,
href,
icon,
shortcut,
className = "",
...props
}: {
children: React.ReactNode;
href: string;
icon?: JSX.Element;
shortcut?: string | null;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
return (
<a
{...props}
className={`welcome-screen-menu-item ${className}`}
href={href}
target="_blank"
rel="noreferrer"
>
<WelcomeScreenMenuItemContent icon={icon} shortcut={shortcut}>
{children}
</WelcomeScreenMenuItemContent>
</a>
);
};
WelcomeScreenMenuItemLink.displayName = "WelcomeScreenMenuItemLink";
const Center = ({ children }: { children?: React.ReactNode }) => {
return (
<welcomeScreenCenterTunnel.In>
<div className="welcome-screen-center">
{children || (
<>
<Logo />
<Heading>{t("welcomeScreen.defaults.center_heading")}</Heading>
<Menu>
<MenuItemLoadScene />
<MenuItemHelp />
</Menu>
</>
)}
</div>
</welcomeScreenCenterTunnel.In>
);
};
Center.displayName = "Center";
const Logo = ({ children }: { children?: React.ReactNode }) => {
return (
<div className="welcome-screen-center__logo virgil welcome-screen-decor">
{children || <>{ExcalLogo} Excalidraw</>}
</div>
);
};
Logo.displayName = "Logo";
const Heading = ({ children }: { children: React.ReactNode }) => {
return (
<div className="welcome-screen-center__heading welcome-screen-decor virgil">
{children}
</div>
);
};
Heading.displayName = "Heading";
const Menu = ({ children }: { children?: React.ReactNode }) => {
return <div className="welcome-screen-menu">{children}</div>;
};
Menu.displayName = "Menu";
const MenuItemHelp = () => {
const actionManager = useExcalidrawActionManager();
return (
<WelcomeScreenMenuItem
onSelect={() => actionManager.executeAction(actionShortcuts)}
shortcut="?"
icon={HelpIcon}
>
{t("helpDialog.title")}
</WelcomeScreenMenuItem>
);
};
MenuItemHelp.displayName = "MenuItemHelp";
const MenuItemLoadScene = () => {
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) {
return null;
}
return (
<WelcomeScreenMenuItem
onSelect={() => actionManager.executeAction(actionLoadScene)}
shortcut={getShortcutFromShortcutName("loadScene")}
icon={LoadIcon}
>
{t("buttons.load")}
</WelcomeScreenMenuItem>
);
};
MenuItemLoadScene.displayName = "MenuItemLoadScene";
const MenuItemLiveCollaborationTrigger = ({
onSelect,
}: {
onSelect: () => any;
}) => {
// FIXME when we tie t() to lang state
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const appState = useExcalidrawAppState();
return (
<WelcomeScreenMenuItem shortcut={null} onSelect={onSelect} icon={usersIcon}>
{t("labels.liveCollaboration")}
</WelcomeScreenMenuItem>
);
};
MenuItemLiveCollaborationTrigger.displayName =
"MenuItemLiveCollaborationTrigger";
// -----------------------------------------------------------------------------
Center.Logo = Logo;
Center.Heading = Heading;
Center.Menu = Menu;
Center.MenuItem = WelcomeScreenMenuItem;
Center.MenuItemLink = WelcomeScreenMenuItemLink;
Center.MenuItemHelp = MenuItemHelp;
Center.MenuItemLoadScene = MenuItemLoadScene;
Center.MenuItemLiveCollaborationTrigger = MenuItemLiveCollaborationTrigger;
export { Center };

View File

@@ -0,0 +1,53 @@
import { t } from "../../i18n";
import {
WelcomeScreenHelpArrow,
WelcomeScreenMenuArrow,
WelcomeScreenTopToolbarArrow,
} from "../icons";
import {
welcomeScreenMenuHintTunnel,
welcomeScreenToolbarHintTunnel,
welcomeScreenHelpHintTunnel,
} from "../tunnels";
const MenuHint = ({ children }: { children?: React.ReactNode }) => {
return (
<welcomeScreenMenuHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
{WelcomeScreenMenuArrow}
<div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.menuHint")}
</div>
</div>
</welcomeScreenMenuHintTunnel.In>
);
};
MenuHint.displayName = "MenuHint";
const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
return (
<welcomeScreenToolbarHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
<div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.toolbarHint")}
</div>
{WelcomeScreenTopToolbarArrow}
</div>
</welcomeScreenToolbarHintTunnel.In>
);
};
ToolbarHint.displayName = "ToolbarHint";
const HelpHint = ({ children }: { children?: React.ReactNode }) => {
return (
<welcomeScreenHelpHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
<div>{children || t("welcomeScreen.defaults.helpHint")}</div>
{WelcomeScreenHelpArrow}
</div>
</welcomeScreenHelpHintTunnel.In>
);
};
HelpHint.displayName = "HelpHint";
export { HelpHint, MenuHint, ToolbarHint };

View File

@@ -3,29 +3,39 @@
font-family: "Virgil";
}
.WelcomeScreen-logo {
display: flex;
align-items: center;
column-gap: 0.75rem;
font-size: 2.25rem;
// WelcomeSreen common
// ---------------------------------------------------------------------------
svg {
width: 1.625rem;
height: auto;
}
}
.WelcomeScreen-decor {
.welcome-screen-decor {
pointer-events: none;
color: var(--color-gray-40);
}
&--subheading {
font-size: 1.125rem;
text-align: center;
&.theme--dark {
.welcome-screen-decor {
color: var(--color-gray-60);
}
}
// WelcomeScreen.Hints
// ---------------------------------------------------------------------------
.welcome-screen-decor-hint {
@media (max-height: 599px) {
display: none !important;
}
&--help-pointer {
@media (max-width: 1024px), (max-width: 800px) {
.welcome-screen-decor {
&--help,
&--menu {
display: none;
}
}
}
&--help {
display: flex;
position: absolute;
right: 0;
@@ -49,7 +59,7 @@
}
}
&--top-toolbar-pointer {
&--toolbar {
position: absolute;
top: 100%;
left: 50%;
@@ -58,7 +68,7 @@
display: flex;
align-items: baseline;
&__label {
.welcome-screen-decor-hint__label {
width: 120px;
position: relative;
top: -0.5rem;
@@ -74,7 +84,7 @@
}
}
&--menu-pointer {
&--menu {
position: absolute;
width: 320px;
font-size: 1rem;
@@ -95,10 +105,19 @@
transform: scaleX(-1);
}
}
@media (max-width: 860px) {
.welcome-screen-decor-hint__label {
max-width: 160px;
}
}
}
}
.WelcomeScreen-container {
// WelcomeSreen.Center
// ---------------------------------------------------------------------------
.welcome-screen-center {
display: flex;
flex-direction: column;
gap: 2rem;
@@ -112,7 +131,24 @@
bottom: 1rem;
}
.WelcomeScreen-items {
.welcome-screen-center__logo {
display: flex;
align-items: center;
column-gap: 0.75rem;
font-size: 2.25rem;
svg {
width: 1.625rem;
height: auto;
}
}
.welcome-screen-center__heading {
font-size: 1.125rem;
text-align: center;
}
.welcome-screen-menu {
display: flex;
flex-direction: column;
gap: 2px;
@@ -120,7 +156,7 @@
align-items: center;
}
.WelcomeScreen-item {
.welcome-screen-menu-item {
box-sizing: border-box;
pointer-events: all;
@@ -128,8 +164,10 @@
color: var(--color-gray-50);
font-size: 0.875rem;
width: 100%;
min-width: 300px;
display: flex;
max-width: 400px;
display: grid;
align-items: center;
justify-content: space-between;
@@ -140,44 +178,49 @@
border-radius: var(--border-radius-md);
&__label {
grid-template-columns: calc(var(--default-icon-size) + 0.5rem) 1fr 3rem;
&__text {
display: flex;
align-items: center;
margin-right: auto;
text-align: left;
column-gap: 0.5rem;
}
svg {
width: var(--default-icon-size);
height: var(--default-icon-size);
}
&__icon {
width: var(--default-icon-size);
height: var(--default-icon-size);
}
&__shortcut {
margin-left: auto;
color: var(--color-gray-40);
font-size: 0.75rem;
}
}
&:not(:active) .WelcomeScreen-item:hover {
&:not(:active) .welcome-screen-menu-item:hover {
text-decoration: none;
background: var(--color-gray-10);
.WelcomeScreen-item__shortcut {
.welcome-screen-menu-item__shortcut {
color: var(--color-gray-50);
}
.WelcomeScreen-item__label {
.welcome-screen-menu-item__text {
color: var(--color-gray-100);
}
}
.WelcomeScreen-item:active {
.welcome-screen-menu-item:active {
background: var(--color-gray-20);
.WelcomeScreen-item__shortcut {
.welcome-screen-menu-item__shortcut {
color: var(--color-gray-50);
}
.WelcomeScreen-item__label {
.welcome-screen-menu-item__text {
color: var(--color-gray-100);
}
@@ -185,7 +228,7 @@
color: var(--color-promo) !important;
&:hover {
.WelcomeScreen-item__label {
.welcome-screen-menu-item__text {
color: var(--color-promo) !important;
}
}
@@ -193,11 +236,7 @@
}
&.theme--dark {
.WelcomeScreen-decor {
color: var(--color-gray-60);
}
.WelcomeScreen-item {
.welcome-screen-menu-item {
color: var(--color-gray-60);
&__shortcut {
@@ -205,69 +244,41 @@
}
}
&:not(:active) .WelcomeScreen-item:hover {
&:not(:active) .welcome-screen-menu-item:hover {
background: var(--color-gray-85);
.WelcomeScreen-item__shortcut {
.welcome-screen-menu-item__shortcut {
color: var(--color-gray-50);
}
.WelcomeScreen-item__label {
.welcome-screen-menu-item__text {
color: var(--color-gray-10);
}
}
.WelcomeScreen-item:active {
.welcome-screen-menu-item:active {
background-color: var(--color-gray-90);
.WelcomeScreen-item__label {
.welcome-screen-menu-item__text {
color: var(--color-gray-10);
}
}
}
// Can tweak these values but for an initial effort, it looks OK to me
@media (max-width: 1024px) {
.WelcomeScreen-decor {
&--help-pointer,
&--menu-pointer {
display: none;
}
}
}
// @media (max-height: 400px) {
// .WelcomeScreen-container {
// margin-top: 0;
// }
// }
@media (max-height: 599px) {
.WelcomeScreen-container {
.welcome-screen-center {
margin-top: 4rem;
}
}
@media (min-height: 600px) and (max-height: 900px) {
.WelcomeScreen-container {
.welcome-screen-center {
margin-top: 8rem;
}
}
@media (max-height: 630px) {
.WelcomeScreen-decor--top-toolbar-pointer {
display: none;
}
}
@media (max-height: 500px) {
.WelcomeScreen-container {
@media (max-height: 500px), (max-width: 320px) {
.welcome-screen-center {
display: none;
}
}
// @media (max-height: 740px) {
// .WelcomeScreen-decor {
// &--help-pointer,
// &--top-toolbar-pointer,
// &--menu-pointer {
// display: none;
// }
// }
// }
// ---------------------------------------------------------------------------
}

View File

@@ -0,0 +1,26 @@
import { Center } from "./WelcomeScreen.Center";
import { MenuHint, ToolbarHint, HelpHint } from "./WelcomeScreen.Hints";
import "./WelcomeScreen.scss";
const WelcomeScreen = (props: { children?: React.ReactNode }) => {
return (
<>
{props.children || (
<>
<Center />
<MenuHint />
<ToolbarHint />
<HelpHint />
</>
)}
</>
);
};
WelcomeScreen.displayName = "WelcomeScreen";
WelcomeScreen.Center = Center;
WelcomeScreen.Hints = { MenuHint, ToolbarHint, HelpHint };
export default WelcomeScreen;

View File

@@ -2,6 +2,14 @@ import cssVariables from "./css/variables.module.scss";
import { AppProps } from "./types";
import { FontFamilyValues } from "./element/types";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform);
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
export const isFirefox =
"netscape" in window &&
navigator.userAgent.indexOf("rv:") > 1 &&
navigator.userAgent.indexOf("Gecko") > 1;
export const APP_NAME = "Excalidraw";
export const DRAGGING_THRESHOLD = 10; // px
@@ -54,6 +62,7 @@ export enum EVENT {
SCROLL = "scroll",
// custom events
EXCALIDRAW_LINK = "excalidraw-link",
MENU_ITEM_SELECT = "menu.itemSelect",
}
export const ENV = {
@@ -236,14 +245,6 @@ export const ROUNDNESS = {
ADAPTIVE_RADIUS: 3,
} as const;
export const COOKIES = {
AUTH_STATE_COOKIE: "excplus-auth",
} as const;
/** key containt id of precedeing elemnt id we use in reconciliation during
* collaboration */
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
export const isExcalidrawPlusSignedUser = document.cookie.includes(
COOKIES.AUTH_STATE_COOKIE,
);

View File

@@ -8,6 +8,10 @@
}
.excalidraw {
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
position: relative;
overflow: hidden;
color: var(--text-primary-color);
@@ -408,7 +412,7 @@
pointer-events: all;
&:hover {
background-color: var(--button-hover);
background-color: var(--button-hover-bg);
}
&:active {
@@ -540,15 +544,16 @@
}
.mobile-misc-tools-container {
position: fixed;
top: 5rem;
right: 0;
position: absolute;
top: calc(5rem - var(--editor-container-padding));
right: calc(var(--editor-container-padding) * -1);
display: flex;
flex-direction: column;
border: 1px solid var(--sidebar-border-color);
border-top-left-radius: var(--border-radius-lg);
border-bottom-left-radius: var(--border-radius-lg);
border-right: 0;
overflow: hidden;
background-color: var(--island-bg-color);
@@ -569,6 +574,20 @@
display: none;
}
}
.UserList-Wrapper {
margin: 0;
padding: 0;
border: none;
text-align: left;
legend {
display: block;
font-size: 0.75rem;
font-weight: 400;
margin: 0 0 0.25rem;
padding: 0;
}
}
}
.ErrorSplash.excalidraw {

View File

@@ -35,13 +35,14 @@
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
0px 0px 0.931014px rgba(0, 0, 0, 0.1702);
--button-hover: var(--color-gray-10);
--button-hover-bg: var(--color-gray-10);
--default-border-color: var(--color-gray-30);
--default-button-size: 2rem;
--default-icon-size: 1rem;
--lg-button-size: 2.25rem;
--lg-icon-size: 1rem;
--editor-container-padding: 1rem;
@media screen and (min-device-width: 1921px) {
--lg-button-size: 2.5rem;
@@ -135,7 +136,7 @@
--popup-text-inverted-color: #2c2c2c;
--select-highlight-color: #{$oc-blue-4};
--text-primary-color: var(--color-gray-40);
--button-hover: var(--color-gray-80);
--button-hover-bg: var(--color-gray-80);
--default-border-color: var(--color-gray-80);
--shadow-island: 0px 13px 33px rgba(0, 0, 0, 0.07),
0px 4.13px 9.94853px rgba(0, 0, 0, 0.0456112),

View File

@@ -39,11 +39,11 @@
.ToolIcon__icon {
&:hover {
background: var(--button-hover);
background: var(--button-hover-bg);
}
&:active {
background: var(--button-hover);
background: var(--button-hover-bg);
border: 1px solid var(--color-primary-darkest);
}
}
@@ -54,24 +54,30 @@
justify-content: center;
align-items: center;
padding: 0.625rem;
width: var(--default-button-size);
height: var(--default-button-size);
width: var(--button-width, var(--default-button-size));
height: var(--button-height, var(--default-button-size));
box-sizing: border-box;
border-width: 1px;
border-style: solid;
border-color: var(--default-border-color);
border-color: var(--button-border, var(--default-border-color));
border-radius: var(--border-radius-lg);
cursor: pointer;
background-color: transparent;
color: var(--text-primary-color);
background-color: var(--button-bg, var(--island-bg-color));
color: var(--button-color, var(--text-primary-color));
svg {
width: var(--button-width, var(--lg-icon-size));
height: var(--button-height, var(--lg-icon-size));
}
&:hover {
background-color: var(--button-hover);
background-color: var(--button-hover-bg, var(--island-bg-color));
border-color: var(--button-hover-border, var(--default-border-color));
}
&:active {
background-color: var(--button-hover);
border-color: var(--color-primary-darkest);
background-color: var(--button-active-bg, var(--island-bg-color));
border-color: var(--button-active-border, var(--color-primary-darkest));
}
&.active {
@@ -83,7 +89,7 @@
}
svg {
color: var(--color-primary-darker);
color: var(--button-color, var(--color-primary-darker));
}
}
}

View File

@@ -2,7 +2,7 @@ import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
} from "../clipboard";
import { DEFAULT_EXPORT_PADDING, MIME_TYPES } from "../constants";
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { exportToCanvas, exportToSvg } from "../scene/export";
@@ -97,10 +97,21 @@ export const exportCanvas = async (
const blob = canvasToBlob(tempCanvas);
await copyBlobToClipboardAsPng(blob);
} catch (error: any) {
console.warn(error);
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw error;
}
throw new Error(t("alerts.couldNotCopyToClipboard"));
// TypeError *probably* suggests ClipboardItem not defined, which
// people on Firefox can enable through a flag, so let's tell them.
if (isFirefox && error.name === "TypeError") {
throw new Error(
`${t("alerts.couldNotCopyToClipboard")}\n\n${t(
"hints.firefox_clipboard_write",
)}`,
);
} else {
throw new Error(t("alerts.couldNotCopyToClipboard"));
}
} finally {
tempCanvas.remove();
}

View File

@@ -55,6 +55,7 @@ export const AllowedExcalidrawActiveTools: Record<
freedraw: true,
eraser: false,
custom: true,
hand: true,
};
export type RestoredDataState = {
@@ -465,7 +466,7 @@ export const restoreAppState = (
? nextAppState.activeTool
: { type: "selection" },
),
lastActiveToolBeforeEraser: null,
lastActiveTool: null,
locked: nextAppState.activeTool.locked ?? false,
},
// Migrates from previous version where appState.zoom was a number

View File

@@ -267,7 +267,7 @@ export const actionLink = register({
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
contextItemLabel: (elements, appState) =>
getContextMenuLabel(elements, appState),
contextItemPredicate: (elements, appState) => {
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return selectedElements.length === 1;
},

View File

@@ -557,10 +557,10 @@ export const resizeSingleElement = (
mutateElement(element, {
scale: [
// defaulting because scaleX/Y can be 0/-0
(Math.sign(scaleX) || stateAtResizeStart.scale[0]) *
stateAtResizeStart.scale[0],
(Math.sign(scaleY) || stateAtResizeStart.scale[1]) *
stateAtResizeStart.scale[1],
(Math.sign(newBoundsX2 - stateAtResizeStart.x) ||
stateAtResizeStart.scale[0]) * stateAtResizeStart.scale[0],
(Math.sign(newBoundsY2 - stateAtResizeStart.y) ||
stateAtResizeStart.scale[1]) * stateAtResizeStart.scale[1],
],
});
}

View File

@@ -11,6 +11,7 @@ export const showSelectedShapeActions = (
appState.activeTool.type !== "custom" &&
(appState.editingElement ||
(appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "eraser"))) ||
appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand"))) ||
getSelectedElements(elements, appState).length,
);

View File

@@ -12,6 +12,20 @@ describe("Test wrapText", () => {
expect(res).toBe("Hello whats up ");
});
it("should work with emojis", () => {
const text = "😀";
const maxWidth = 1;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("😀");
});
it("should show the text correctly when min width reached", () => {
const text = "Hello😀";
const maxWidth = 10;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("H\ne\nl\nl\no\n😀");
});
describe("When text doesn't contain new lines", () => {
const text = "Hello whats up";
[
@@ -157,7 +171,7 @@ describe("Test measureText", () => {
expect(res.container).toMatchInlineSnapshot(`
<div
style="position: absolute; white-space: pre-wrap; font: Emoji 20px 20px; min-height: 1em; width: 111px; overflow: hidden; word-break: break-word; line-height: 0px;"
style="position: absolute; white-space: pre-wrap; font: Emoji 20px 20px; min-height: 1em; max-width: 191px; overflow: hidden; word-break: break-word; line-height: 0px;"
>
<span
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"

View File

@@ -271,12 +271,11 @@ export const measureText = (
container.style.whiteSpace = "pre";
container.style.font = font;
container.style.minHeight = "1em";
const textWidth = getTextWidth(text, font);
if (maxWidth) {
const lineHeight = getApproxLineHeight(font);
container.style.width = `${String(Math.min(textWidth, maxWidth) + 1)}px`;
// since we are adding a span of width 1px later
container.style.maxWidth = `${maxWidth + 1}px`;
container.style.overflow = "hidden";
container.style.wordBreak = "break-word";
container.style.lineHeight = `${String(lineHeight)}px`;
@@ -293,11 +292,8 @@ export const measureText = (
container.appendChild(span);
// Baseline is important for positioning text on canvas
const baseline = span.offsetTop + span.offsetHeight;
// Since span adds 1px extra width to the container
let width = container.offsetWidth;
if (maxWidth && textWidth > maxWidth) {
width = width - 1;
}
// since we are adding a span of width 1px
const width = container.offsetWidth + 1;
const height = container.offsetHeight;
document.body.removeChild(container);
if (isTestEnv()) {
@@ -332,8 +328,11 @@ const getLineWidth = (text: string, font: FontString) => {
if (isTestEnv()) {
return metrics.width * 10;
}
// Since measureText behaves differently in different browsers
// OS so considering a adjustment factor of 0.2
const adjustmentFactor = 0.2;
return metrics.width;
return metrics.width + adjustmentFactor;
};
export const getTextWidth = (text: string, font: FontString) => {
@@ -359,96 +358,94 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
// This means its newline so push it
if (words.length === 1 && words[0] === "") {
lines.push(words[0]);
} else {
let currentLine = "";
let currentLineWidthTillNow = 0;
return; // continue
}
let currentLine = "";
let currentLineWidthTillNow = 0;
let index = 0;
while (index < words.length) {
const currentWordWidth = getLineWidth(words[index], font);
let index = 0;
while (index < words.length) {
const currentWordWidth = getLineWidth(words[index], font);
// Start breaking longer words exceeding max width
if (currentWordWidth >= maxWidth) {
// push current line since the current word exceeds the max width
// so will be appended in next line
// Start breaking longer words exceeding max width
if (currentWordWidth >= maxWidth) {
// push current line since the current word exceeds the max width
// so will be appended in next line
push(currentLine);
currentLine = "";
currentLineWidthTillNow = 0;
while (words[index].length > 0) {
const currentChar = String.fromCodePoint(
words[index].codePointAt(0)!,
);
const width = charWidth.calculate(currentChar, font);
currentLineWidthTillNow += width;
words[index] = words[index].slice(currentChar.length);
if (currentLineWidthTillNow >= maxWidth) {
// only remove last trailing space which we have added when joining words
if (currentLine.slice(-1) === " ") {
currentLine = currentLine.slice(0, -1);
}
push(currentLine);
currentLine = currentChar;
currentLineWidthTillNow = width;
} else {
currentLine += currentChar;
}
}
// push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
push(currentLine);
currentLine = "";
currentLineWidthTillNow = 0;
while (words[index].length > 0) {
const currentChar = words[index][0];
const width = charWidth.calculate(currentChar, font);
currentLineWidthTillNow += width;
words[index] = words[index].slice(1);
if (currentLineWidthTillNow >= maxWidth) {
// only remove last trailing space which we have added when joining words
if (currentLine.slice(-1) === " ") {
currentLine = currentLine.slice(0, -1);
}
push(currentLine);
currentLine = currentChar;
currentLineWidthTillNow = width;
if (currentLineWidthTillNow === maxWidth) {
currentLine = "";
currentLineWidthTillNow = 0;
}
} else {
currentLine += currentChar;
}
}
// push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
push(currentLine);
currentLine = "";
currentLineWidthTillNow = 0;
} else {
// space needs to be appended before next word
// as currentLine contains chars which couldn't be appended
// to previous line
currentLine += " ";
currentLineWidthTillNow += spaceWidth;
}
index++;
} else {
// Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index];
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
// space needs to be appended before next word
// as currentLine contains chars which couldn't be appended
// to previous line
currentLine += " ";
currentLineWidthTillNow += spaceWidth;
}
if (currentLineWidthTillNow >= maxWidth) {
push(currentLine);
currentLineWidthTillNow = 0;
currentLine = "";
index++;
} else {
// Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index];
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
break;
}
index++;
currentLine += `${word} `;
if (currentLineWidthTillNow >= maxWidth) {
push(currentLine);
currentLineWidthTillNow = 0;
currentLine = "";
// Push the word if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
const word = currentLine.slice(0, -1);
push(word);
currentLine = "";
currentLineWidthTillNow = 0;
break;
}
break;
}
if (currentLineWidthTillNow === maxWidth) {
index++;
currentLine += `${word} `;
// Push the word if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
const word = currentLine.slice(0, -1);
push(word);
currentLine = "";
currentLineWidthTillNow = 0;
break;
}
}
}
if (currentLine) {
// only remove last trailing space which we have added when joining words
if (currentLine.slice(-1) === " ") {
currentLine = currentLine.slice(0, -1);
if (currentLineWidthTillNow === maxWidth) {
currentLine = "";
currentLineWidthTillNow = 0;
}
push(currentLine);
}
}
if (currentLine) {
// only remove last trailing space which we have added when joining words
if (currentLine.slice(-1) === " ") {
currentLine = currentLine.slice(0, -1);
}
push(currentLine);
}
});
return lines.join("\n");
};

View File

@@ -862,7 +862,7 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
110,
109.5,
17,
]
`);
@@ -910,7 +910,7 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
425,
424,
-539,
]
`);
@@ -1026,7 +1026,7 @@ describe("textWysiwyg", () => {
mouse.up(rectangle.x + 100, rectangle.y + 50);
expect(rectangle.x).toBe(80);
expect(rectangle.y).toBe(85);
expect(text.x).toBe(90);
expect(text.x).toBe(89.5);
expect(text.y).toBe(90);
Keyboard.withModifierKeys({ ctrl: true }, () => {

View File

@@ -142,11 +142,11 @@ export const textWysiwyg = ({
const appState = app.state;
const updatedTextElement =
Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
if (!updatedTextElement) {
return;
}
const { textAlign, verticalAlign } = updatedTextElement;
const approxLineHeight = getApproxLineHeight(
getFontString(updatedTextElement),
);
@@ -161,6 +161,7 @@ export const textWysiwyg = ({
// Set to element height by default since that's
// what is going to be used for unbounded text
let textElementHeight = updatedTextElement.height;
if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) {
const boundTextCoords =
@@ -206,7 +207,6 @@ export const textWysiwyg = ({
maxHeight = getMaxContainerHeight(container);
// autogrow container height if text exceeds
if (!isArrowElement(container) && textElementHeight > maxHeight) {
const diff = Math.min(
textElementHeight - maxHeight,
@@ -276,7 +276,6 @@ export const textWysiwyg = ({
// Make sure text editor height doesn't go beyond viewport
const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value;
Object.assign(editable.style, {
font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯
@@ -395,11 +394,12 @@ export const textWysiwyg = ({
// first line as well as setting height to "auto"
// doubles the height as soon as user starts typing
if (isBoundToContainer(element) && lines > 1) {
const container = getContainerElement(element);
let height = "auto";
editable.style.height = "0px";
let heightSet = false;
if (lines === 2) {
const container = getContainerElement(element);
const actualLineCount = wrapText(
editable.value,
font,
@@ -416,6 +416,14 @@ export const textWysiwyg = ({
heightSet = true;
}
}
const wrappedText = wrapText(
normalizeText(editable.value),
font,
getMaxContainerWidth(container!),
);
const width = getTextWidth(wrappedText, font);
editable.style.width = `${width}px`;
if (!heightSet) {
editable.style.height = `${editable.scrollHeight}px`;
}

View File

@@ -38,3 +38,11 @@ export const STORAGE_KEYS = {
VERSION_DATA_STATE: "version-dataState",
VERSION_FILES: "version-files",
} as const;
export const COOKIES = {
AUTH_STATE_COOKIE: "excplus-auth",
} as const;
export const isExcalidrawPlusSignedUser = document.cookie.includes(
COOKIES.AUTH_STATE_COOKIE,
);

View File

@@ -242,6 +242,12 @@ class Collab extends PureComponent<Props, CollabState> {
);
}
} catch (error: any) {
this.setState({
// firestore doesn't return a specific error code when size exceeded
errorMessage: /is longer than.*?bytes/.test(error.message)
? t("errors.collabSaveFailed_sizeExceeded")
: t("errors.collabSaveFailed"),
});
console.error(error);
}
};

View File

@@ -0,0 +1,21 @@
import React from "react";
import { Footer } from "../../packages/excalidraw/index";
import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
export const AppFooter = React.memo(() => {
return (
<Footer>
<div
style={{
display: "flex",
gap: ".5rem",
alignItems: "center",
}}
>
<ExcalidrawPlusAppLink />
<EncryptedIcon />
</div>
</Footer>
);
});

View File

@@ -0,0 +1,40 @@
import React from "react";
import { PlusPromoIcon } from "../../components/icons";
import { MainMenu } from "../../packages/excalidraw/index";
import { LanguageList } from "./LanguageList";
export const AppMainMenu: React.FC<{
setCollabDialogShown: (toggle: boolean) => any;
isCollaborating: boolean;
}> = React.memo((props) => {
return (
<MainMenu>
<MainMenu.DefaultItems.LoadScene />
<MainMenu.DefaultItems.SaveToActiveFile />
<MainMenu.DefaultItems.Export />
<MainMenu.DefaultItems.SaveAsImage />
<MainMenu.DefaultItems.LiveCollaborationTrigger
isCollaborating={props.isCollaborating}
onSelect={() => props.setCollabDialogShown(true)}
/>
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.ItemLink
icon={PlusPromoIcon}
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger"
className="ExcalidrawPlus"
>
Excalidraw+
</MainMenu.ItemLink>
<MainMenu.DefaultItems.Socials />
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme />
<MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom>
<MainMenu.DefaultItems.ChangeCanvasBackground />
</MainMenu>
);
});

View File

@@ -0,0 +1,64 @@
import React from "react";
import { PlusPromoIcon } from "../../components/icons";
import { t } from "../../i18n";
import { WelcomeScreen } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
export const AppWelcomeScreen: React.FC<{
setCollabDialogShown: (toggle: boolean) => any;
}> = React.memo((props) => {
let headingContent;
if (isExcalidrawPlusSignedUser) {
headingContent = t("welcomeScreen.app.center_heading_plus")
.split(/(Excalidraw\+)/)
.map((bit, idx) => {
if (bit === "Excalidraw+") {
return (
<a
style={{ pointerEvents: "all" }}
href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
key={idx}
>
Excalidraw+
</a>
);
}
return bit;
});
} else {
headingContent = t("welcomeScreen.app.center_heading");
}
return (
<WelcomeScreen>
<WelcomeScreen.Hints.MenuHint>
{t("welcomeScreen.app.menuHint")}
</WelcomeScreen.Hints.MenuHint>
<WelcomeScreen.Hints.ToolbarHint />
<WelcomeScreen.Hints.HelpHint />
<WelcomeScreen.Center>
<WelcomeScreen.Center.Logo />
<WelcomeScreen.Center.Heading>
{headingContent}
</WelcomeScreen.Center.Heading>
<WelcomeScreen.Center.Menu>
<WelcomeScreen.Center.MenuItemLoadScene />
<WelcomeScreen.Center.MenuItemHelp />
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
onSelect={() => props.setCollabDialogShown(true)}
/>
{!isExcalidrawPlusSignedUser && (
<WelcomeScreen.Center.MenuItemLink
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
shortcut={null}
icon={PlusPromoIcon}
>
Try Excalidraw Plus!
</WelcomeScreen.Center.MenuItemLink>
)}
</WelcomeScreen.Center.Menu>
</WelcomeScreen.Center>
</WelcomeScreen>
);
});

View File

@@ -1,4 +1,4 @@
import { isExcalidrawPlusSignedUser } from "../../constants";
import { isExcalidrawPlusSignedUser } from "../app_constants";
export const ExcalidrawPlusAppLink = () => {
if (!isExcalidrawPlusSignedUser) {

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