Compare commits

...

27 Commits

Author SHA1 Message Date
b412e742e6 Update MobileMenu.tsx 2022-11-14 18:08:19 +01:00
c246ccf9d9 Update LayerUI.tsx 2022-11-14 18:05:56 +01:00
3c0b29d85f build(deps): bump loader-utils from 2.0.0 to 2.0.4 in /src/packages/utils (#5874)
build(deps): bump loader-utils in /src/packages/utils

Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.0 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.0...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>
2022-11-14 15:49:05 +05:30
bfbaeae67f fix: Correctly paste contents parsed by JSON.parse() as text. (#5868)
* Fix #5867

* Add test.

* Add tests to clipboard.test.ts

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-11-14 14:02:54 +05:30
74b9885955 build(deps): bump minimatch from 3.0.4 to 3.1.2 in /src/packages/excalidraw (#5861)
build(deps): bump minimatch in /src/packages/excalidraw

Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.0.4 to 3.1.2.
- [Release notes](https://github.com/isaacs/minimatch/releases)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.0.4...v3.1.2)

---
updated-dependencies:
- dependency-name: minimatch
  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>
2022-11-14 13:51:10 +05:30
2cbe869a13 build(deps): bump socket.io-parser from 3.3.2 to 3.3.3 (#5862)
Bumps [socket.io-parser](https://github.com/socketio/socket.io-parser) from 3.3.2 to 3.3.3.
- [Release notes](https://github.com/socketio/socket.io-parser/releases)
- [Changelog](https://github.com/socketio/socket.io-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io-parser/compare/3.3.2...3.3.3)

---
updated-dependencies:
- dependency-name: socket.io-parser
  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>
2022-11-14 13:50:15 +05:30
a48607eb25 build(deps): bump loader-utils from 2.0.2 to 2.0.3 in /src/packages/excalidraw (#5851)
build(deps): bump loader-utils in /src/packages/excalidraw

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

---
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>
2022-11-14 13:49:39 +05:30
7831b6e74b fix: SVG element attributes in icons.tsx (#5871)
Update icons.tsx
2022-11-14 11:42:28 +05:30
640affe7c0 build(deps): bump loader-utils from 2.0.2 to 2.0.3 in /dev-docs (#5853)
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.2 to 2.0.3.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.3/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.2...v2.0.3)

---
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>
2022-11-10 15:02:18 +05:30
335aff8838 fix: merge existing text with new when pasted (#5856)
* Fix #5855.

* fix test

* tweak

* Add specs

* Add more snaps

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-11-09 23:39:53 +05:30
dc97dc30bf fix: disable FAST_REFRESH to fix live reload (#5852) 2022-11-09 17:13:20 +05:30
a0ecfed4cd fix: Paste clipboard contents into unbound text elements (#5849)
* Fix #5848.

* Add test.

* some tweaks

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-11-09 11:30:22 +05:30
e201e79cd0 fix: compute dimensions of container correctly when text pasted on container (#5845)
* fix: compute dimensions of container correctly when text pasted on container

* add test

* remove only
2022-11-08 19:50:41 +05:30
e1c5c706c6 build: stops ignoring .env files from docker context so env variables get set during react app build. (#5809)
build: stops ignoring .env.development and .env.production files from docker context so env variables get set during react app build.
* this fixes the issue where Browse Libraries button link was broken in
  docker/self-hosted versions of excalidraw
2022-11-07 16:48:38 +05:30
bdc56090d7 feat: reintroduce x shortcut for freedraw (#5840) 2022-11-06 23:07:15 +01:00
58accc9310 feat: tweak toolbar shortcuts & remove library shortcut (#5832) 2022-11-06 20:14:53 +01:00
b91158198e feat: clean unused images only after 24hrs (local-only) (#5839)
* feat: clean unused images only after 24hrs (local-only)

* fix test

* make optional for now
2022-11-06 19:41:14 +01:00
938ce241ff feat: refetch errored/pending images on collab room init load (#5833) 2022-11-05 15:55:14 +01:00
0228646507 fix: line editor points rendering below elements (#5781)
* fix: line editor points rendering below elements

* add test
2022-11-05 11:35:53 +01:00
25ea97d0f9 test: fix failing tests and API (#5823)
* tests: fix failing tests

* fix selection.test.tsx

* fix excalidraw.test.tsx and don't show image export when SaveAsImage is false in UIOptions.canvasActions

* more fixes

* require fake index db in setUp test to fix the tests

* fix regression
2022-11-04 18:22:21 +05:30
8d5d68e589 feat: stop deleting whole line when no point select in line editor (#5676)
* feat: stop deleting whole line when no point select in line editor

* Comments typo

Co-authored-by: DanielJGeiger <1852529+DanielJGeiger@users.noreply.github.com>
2022-11-02 14:52:32 +01:00
6c15d9948b fix: syncing 1-point lines to remote clients (#5677) 2022-11-02 14:39:12 +01:00
e8fba43cf6 fix: incorrectly selecting linear elements on creation while tool-locked (#5785) 2022-11-02 14:38:58 +01:00
2e5c798c71 fix: Corrected typo in toggle theme shortcut (#5813) 2022-11-02 14:32:21 +01:00
8c298336fc fix: hide canvas-modifying UI in view mode (#5815)
* fix: hide canvas-modifying UI in view mode

* add class for better targeting

* fix missing `key`

* fix: useOutsideClick not working in view mode
2022-11-01 22:25:12 +01:00
7f91cdc0c9 fix: fix vertical/horizntal centering icons (#5812) 2022-11-01 18:39:31 +01:00
6334bd832f feat: editor redesign 🔥 (#5780)
* Placed eraser into shape switcher (top toolbar).
Redesigned top toolbar.

* Redesigned zoom and undo-redo buttons.

* Started redesigning left toolbar.

* Redesigned help dialog.

* Colour picker now somewhat in line with new design

* [WIP] Changed a bunch of icons.
TODO: organise new icons.

* [WIP] Organised a bunch of icons. Still some to do

* [WIP] Started working on hamburger menu.

* Fixed some bugs with hamburger menu.

* Menu and left toolbar positioning.

* Added some more items to hamburger menu.

* Changed some icons.

* Modal/dialog styling & bunch of fixes.

* Some more dialog improvements & fixes.

* Mobile menu changes.

* Menu can now be closed with outside click.

* Collab avatars and button changes.

* Icon sizing. Left toolbar positioning.

* Implemented welcome screen rendering logic.

* [WIP] Welcome screen content + design.

* Some more welcome screen content and design.

* Merge fixes.

* Tweaked icon set.

* Welcome screen darkmode fix.

* Content updates.

* Various small fixes & adjustments.
Moved language selection into menu.
Fixed some problematic icons.
Slightly moved encryption icon.

* Sidebar header redesign.

* Libraries content rendering logic + some styling.

* Somem more library sidebar styling.

* Publish library dialog styling.

* scroll-back-to-content btn styling

* ColorPicker positioning.

* Library button styling.

* ColorPicker positioning "fix".

* Misc adjustments.

* PenMode button changes.

* Trying to make mobile somewhat usable.

* Added a couple of icons.

* Added some shortcuts.

* Prevent welcome screen flickering.
Fix issue with welcome screen interactivity.
Don't show sidebar button when docked.

* Icon sizing on smaller screens.

* Sidebar styling changes.

* Alignment button... well... alignments.

* Fix inconsistent padding in left toolbar.

* HintViewer changes.

* Hamburger menu changes.

* Move encryption badge back to its original pos.

* Arrowhead changes.
Active state, colours + stronger shadow.

* Added new custom font.

* Fixed bug with library button not rendering.

* Fixed issue with lang selection colours.

* Add tooltips for undo, redo.

* Address some dark mode contrast issues.

* (Re)introduce counter for selectedItems in sidebar

* [WIP] Tweaked bounding box colour & padding.

* Dashed bounding box for remote clients.

* Some more bounding box tweaks.

* Removed docking animation for now...

* Address some RTL issues.

* Welcome screen responsiveness.

* use lighter selection color in dark mode & align naming

* use rounded corners for transform handles

* use lighter gray for welcomeScreen text in dark mode

* disable selection on dialog buttons

* change selection button icon

* fix library item width being flexible

* library: visually align spinner with first section heading

* lint

* fix scrollbar color in dark mode & make thinner

* adapt properties panel max-height

* add shrotcut label to save-to-current-file

* fix unrelated `useOutsideClick` firing for active modal

* add promo color to e+ menu item

* fix type

* lowered button size

* fix transform handles raidus not accounting for zoom

* attempt fix for excal logo on safari

* final fix for excal logo on safari

* fixing fhd resolution button sized

* remove TODO shortcut

* Collab related styling changes.
Expanding avatar list no longer offsets top toolbar.
Added active state & collaborator count badge for collab button.

* Tweaked collab button active colours.

* Added active style to collab btn in hamburger menu

* Remove unnecessary comment.

* Added back promo link for non (signed in) E+ users

* Go to E+ button added for signed in E+ users.

* Close menu & dropdown on modal close.

* tweak icons & fix rendering on smaller sizes [part one]

* align welcomeScreen icons with other UI

* switch icon resize mq to `device-width`

* disable welcomeScreen items `:hover` when selecting on canvas

* change selection box color and style

* reduce selection padding and fix group selection styling

* improve collab cursor styling

- make name borders round
- hide status when "active"
- remove black/gray colors

* add Twitter to hamburger menu

* align collab button

* add shortcut for image export dialog

* revert yarn.lock

* fix more tabler icons

* slightly better-looking penMode button

* change penMode button & tooltip

* revert hamburger menu icon

* align padding on lang picker & canvas bg

* updated robot txt to allow twitter bot and fb bot

* added new OG and tweaked the OG state

* add tooltip to collab button

* align style for scroll-to-content button

* fix pointer-events around toolbar

* fix decor arrow positioning and RTL

* fix welcomeScreen-item active state in dark mode

* change `load` button copy

* prevent shadow anim when opening a docked sidebar

* update E+ links ga params

* show redirect-to-eplus welcomeScreen subheading for signed-in users

* make more generic

* add ga for eplus redirect button

* change copy and icons for hamburger export buttons

* update snaps

* trim the username to account for trailing spaces

* tweaks around decor breakpoints

* fix linear element editor test

* remove .env change

* remove `it.only`

Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Maielo <maielo.mv@gmail.com>
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-11-01 17:29:58 +01:00
149 changed files with 6744 additions and 3564 deletions

View File

@ -1,5 +1,6 @@
* *
!.env !.env.development
!.env.production
!.eslintrc.json !.eslintrc.json
!.npmrc !.npmrc
!.prettierrc !.prettierrc

View File

@ -20,3 +20,5 @@ REACT_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when # whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers. # debugging Service Workers.
REACT_APP_DEV_DISABLE_LIVE_RELOAD= REACT_APP_DEV_DISABLE_LIVE_RELOAD=
FAST_REFRESH=false

View File

@ -4755,9 +4755,9 @@ loader-runner@^4.2.0:
integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==
loader-utils@^2.0.0: loader-utils@^2.0.0:
version "2.0.2" version "2.0.3"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.2.tgz#d6e3b4fb81870721ae4e0868ab11dd638368c129" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.3.tgz#d4b15b8504c63d1fc3f2ade52d41bc8459d6ede1"
integrity sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A== integrity sha512-THWqIsn8QRnvLl0shHYVBN9syumU8pYWEHPTmkiVGd+7K5eFNVSY6AJhRvgGF70gg1Dz+l/k8WicvFCxdEs60A==
dependencies: dependencies:
big.js "^5.2.2" big.js "^5.2.2"
emojis-list "^3.0.0" emojis-list "^3.0.0"

BIN
public/Assistant-Bold.woff2 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -11,3 +11,28 @@
src: url("Cascadia.woff2"); src: url("Cascadia.woff2");
font-display: swap; font-display: swap;
} }
@font-face {
font-family: "Assistant";
src: url("Assistant-Regular.woff2");
font-display: swap;
font-weight: 400;
}
@font-face {
font-family: "Assistant";
src: url("Assistant-Medium.woff2");
font-display: swap;
font-weight: 500;
}
@font-face {
font-family: "Assistant";
src: url("Assistant-SemiBold.woff2");
font-display: swap;
font-weight: 600;
}
@font-face {
font-family: "Assistant";
src: url("Assistant-Bold.woff2");
font-display: swap;
font-weight: 700;
}

View File

@ -8,49 +8,57 @@
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
/> />
<meta name="referrer" content="origin" /> <meta name="referrer" content="origin" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#121212" />
<meta name="theme-color" content="#000" /> <!-- Primary Meta Tags -->
<meta
name="title"
content="Excalidraw — Collaborative whiteboarding made easy"
/>
<meta
name="description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<meta name="image" content="https://excalidraw.com/og-general-v1.png" />
<!-- Open Graph / Facebook -->
<meta property="og:site_name" content="Excalidraw" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://excalidraw.com" />
<meta
property="og:title"
content="Excalidraw — Collaborative whiteboarding made easy"
/>
<meta property="og:image:alt" content="Excalidraw logo" />
<meta
property="og:description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<meta property="og:image" content="https://excalidraw.com/og-fb-v1.png" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:site" content="@excalidraw" />
<meta property="twitter:url" content="https://excalidraw.com" />
<meta
property="twitter:title"
content="Excalidraw — Collaborative whiteboarding made easy"
/>
<meta
property="twitter:description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<meta
property="twitter:image"
content="https://excalidraw.com/og-twitter-v1.png"
/>
<!-- General tags --> <!-- General tags -->
<meta <meta
name="description" name="description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/> />
<meta name="image" content="og-image.png" />
<!-- OpenGraph tags -->
<meta property="og:url" content="https://excalidraw.com" />
<meta property="og:site_name" content="Excalidraw" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Excalidraw" />
<meta
property="og:description"
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<!-- OG tags require an absolute url for images -->
<meta
property="og:image"
name="twitter:image"
content="https://excalidraw.com/og-image.png"
/>
<meta
property="og:image:secure_url"
name="twitter:image"
content="https://excalidraw.com/og-image.png"
/>
<meta property="og:image:width" content="1280" />
<meta property="og:image:height" content="669" />
<meta property="og:image:alt" content="Excalidraw logo with byline." />
<!-- Twitter Card tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Excalidraw" />
<meta
name="twitter:description"
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<!-------------------------------------------------------------------------> <!------------------------------------------------------------------------->
<!-- to minimize white flash on load when user has dark mode enabled --> <!-- to minimize white flash on load when user has dark mode enabled -->
@ -158,8 +166,8 @@
body, body,
html { html {
margin: 0; margin: 0;
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI, --ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system,
Roboto, Helvetica, Arial, sans-serif; Segoe UI, Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font); font-family: var(--ui-font);
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;

BIN
public/og-fb-v1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
public/og-general-v1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
public/og-twitter-v1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -1,3 +1,9 @@
User-agent: Twitterbot
Disallow:
User-agent: facebookexternalhit
Disallow:
user-agent: * user-agent: *
Allow: /$ Allow: /$
Disallow: / Disallow: /

View File

@ -60,7 +60,7 @@ export const actionAlignTop = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={<AlignTopIcon theme={appState.theme} />} icon={AlignTopIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.alignTop")}${getShortcutKey( title={`${t("labels.alignTop")}${getShortcutKey(
"CtrlOrCmd+Shift+Up", "CtrlOrCmd+Shift+Up",
@ -90,7 +90,7 @@ export const actionAlignBottom = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={<AlignBottomIcon theme={appState.theme} />} icon={AlignBottomIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.alignBottom")}${getShortcutKey( title={`${t("labels.alignBottom")}${getShortcutKey(
"CtrlOrCmd+Shift+Down", "CtrlOrCmd+Shift+Down",
@ -120,7 +120,7 @@ export const actionAlignLeft = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={<AlignLeftIcon theme={appState.theme} />} icon={AlignLeftIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.alignLeft")}${getShortcutKey( title={`${t("labels.alignLeft")}${getShortcutKey(
"CtrlOrCmd+Shift+Left", "CtrlOrCmd+Shift+Left",
@ -151,7 +151,7 @@ export const actionAlignRight = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={<AlignRightIcon theme={appState.theme} />} icon={AlignRightIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.alignRight")}${getShortcutKey( title={`${t("labels.alignRight")}${getShortcutKey(
"CtrlOrCmd+Shift+Right", "CtrlOrCmd+Shift+Right",
@ -180,7 +180,7 @@ export const actionAlignVerticallyCentered = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={<CenterVerticallyIcon theme={appState.theme} />} icon={CenterVerticallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={t("labels.centerVertically")} title={t("labels.centerVertically")}
aria-label={t("labels.centerVertically")} aria-label={t("labels.centerVertically")}
@ -206,7 +206,7 @@ export const actionAlignHorizontallyCentered = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={<CenterHorizontallyIcon theme={appState.theme} />} icon={CenterHorizontallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={t("labels.centerHorizontally")} title={t("labels.centerHorizontally")}
aria-label={t("labels.centerHorizontally")} aria-label={t("labels.centerHorizontally")}

View File

@ -1,7 +1,12 @@
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { eraser, zoomIn, zoomOut } from "../components/icons"; import {
eraser,
MoonIcon,
SunIcon,
ZoomInIcon,
ZoomOutIcon,
} from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants"; import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element"; import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
@ -18,6 +23,8 @@ import { newElementWith } from "../element/mutateElement";
import { getDefaultAppState, isEraserActive } from "../appState"; import { getDefaultAppState, isEraserActive } from "../appState";
import ClearCanvas from "../components/ClearCanvas"; import ClearCanvas from "../components/ClearCanvas";
import clsx from "clsx"; import clsx from "clsx";
import MenuItem from "../components/MenuItem";
import { getShortcutFromShortcutName } from "./shortcuts";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@ -103,13 +110,13 @@ export const actionZoomIn = register({
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={zoomIn} className="zoom-in-button zoom-button"
icon={ZoomInIcon}
title={`${t("buttons.zoomIn")}${getShortcutKey("CtrlOrCmd++")}`} title={`${t("buttons.zoomIn")}${getShortcutKey("CtrlOrCmd++")}`}
aria-label={t("buttons.zoomIn")} aria-label={t("buttons.zoomIn")}
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
size="small"
/> />
), ),
keyTest: (event) => keyTest: (event) =>
@ -139,13 +146,13 @@ export const actionZoomOut = register({
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={zoomOut} className="zoom-out-button zoom-button"
icon={ZoomOutIcon}
title={`${t("buttons.zoomOut")}${getShortcutKey("CtrlOrCmd+-")}`} title={`${t("buttons.zoomOut")}${getShortcutKey("CtrlOrCmd+-")}`}
aria-label={t("buttons.zoomOut")} aria-label={t("buttons.zoomOut")}
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
size="small"
/> />
), ),
keyTest: (event) => keyTest: (event) =>
@ -176,13 +183,12 @@ export const actionResetZoom = register({
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}> <Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
<ToolButton <ToolButton
type="button" type="button"
className="reset-zoom-button" className="reset-zoom-button zoom-button"
title={t("buttons.resetZoom")} title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")} aria-label={t("buttons.resetZoom")}
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
size="small"
> >
{(appState.zoom.value * 100).toFixed(0)}% {(appState.zoom.value * 100).toFixed(0)}%
</ToolButton> </ToolButton>
@ -288,14 +294,19 @@ export const actionToggleTheme = register({
}; };
}, },
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
<div style={{ marginInlineStart: "0.25rem" }}> <MenuItem
<DarkModeToggle label={
value={appState.theme} appState.theme === "dark"
onChange={(theme) => { ? t("buttons.lightMode")
updateData(theme); : t("buttons.darkMode")
}} }
/> onClick={() => {
</div> 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, keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
}); });

View File

@ -1,7 +1,6 @@
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { trash } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
@ -13,6 +12,7 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import { updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -72,13 +72,22 @@ export const actionDeleteSelected = register({
if (!element) { if (!element) {
return false; return false;
} }
if ( // case: no point selected → do nothing, as deleting the whole element
// case: no point selected delete whole element // is most likely a mistake, where you wanted to delete a specific point
selectedPointsIndices == null || // but failed to select it (or you thought it's selected, while it was
// case: deleting last remaining point // only in a hover state)
element.points.length < 2 if (selectedPointsIndices == null) {
) { return false;
const nextElements = elements.filter((el) => el.id !== element.id); }
// case: deleting last remaining point
if (element.points.length < 2) {
const nextElements = elements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
const nextAppState = handleGroupEditingState(appState, nextElements); const nextAppState = handleGroupEditingState(appState, nextElements);
return { return {
@ -149,7 +158,7 @@ export const actionDeleteSelected = register({
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={trash} icon={TrashIcon}
title={t("labels.delete")} title={t("labels.delete")}
aria-label={t("labels.delete")} aria-label={t("labels.delete")}
onClick={() => updateData(null)} onClick={() => updateData(null)}

View File

@ -56,7 +56,7 @@ export const distributeHorizontally = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={<DistributeHorizontallyIcon theme={appState.theme} />} icon={DistributeHorizontallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.distributeHorizontally")}${getShortcutKey( title={`${t("labels.distributeHorizontally")}${getShortcutKey(
"Alt+H", "Alt+H",
@ -86,7 +86,7 @@ export const distributeVertically = register({
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={<DistributeVerticallyIcon theme={appState.theme} />} icon={DistributeVerticallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.distributeVertically")}${getShortcutKey("Alt+V")}`} title={`${t("labels.distributeVertically")}${getShortcutKey("Alt+V")}`}
aria-label={t("labels.distributeVertically")} aria-label={t("labels.distributeVertically")}

View File

@ -4,7 +4,6 @@ import { ExcalidrawElement } from "../element/types";
import { duplicateElement, getNonDeletedElements } from "../element"; import { duplicateElement, getNonDeletedElements } from "../element";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { clone } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
@ -19,6 +18,7 @@ import { ActionResult } from "./types";
import { GRID_SIZE } from "../constants"; import { GRID_SIZE } from "../constants";
import { bindTextToShapeAfterDuplication } from "../element/textElement"; import { bindTextToShapeAfterDuplication } from "../element/textElement";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import { DuplicateIcon } from "../components/icons";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
name: "duplicateSelection", name: "duplicateSelection",
@ -49,7 +49,7 @@ export const actionDuplicateSelection = register({
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={clone} icon={DuplicateIcon}
title={`${t("labels.duplicateSelection")}${getShortcutKey( title={`${t("labels.duplicateSelection")}${getShortcutKey(
"CtrlOrCmd+D", "CtrlOrCmd+D",
)}`} )}`}

View File

@ -1,4 +1,4 @@
import { load, questionCircle, saveAs } from "../components/icons"; import { LoadIcon, questionCircle, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName"; import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import "../components/ToolIcon.scss"; import "../components/ToolIcon.scss";
@ -19,6 +19,8 @@ import { ActiveFile } from "../components/ActiveFile";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types"; import { Theme } from "../element/types";
import MenuItem from "../components/MenuItem";
import { getShortcutFromShortcutName } from "./shortcuts";
export const actionChangeProjectName = register({ export const actionChangeProjectName = register({
name: "changeProjectName", name: "changeProjectName",
@ -245,14 +247,12 @@ export const actionLoadScene = register({
}, },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <MenuItem
type="button" label={t("buttons.load")}
icon={load} icon={LoadIcon}
title={t("buttons.load")}
aria-label={t("buttons.load")}
showAriaLabel={useDevice().isMobile}
onClick={updateData} onClick={updateData}
data-testid="load-button" dataTestId="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
/> />
), ),
}); });

View File

@ -1,5 +1,5 @@
import { Action, ActionResult } from "./types"; import { Action, ActionResult } from "./types";
import { undo, redo } from "../components/icons"; import { UndoIcon, RedoIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import History, { HistoryEntry } from "../history"; import History, { HistoryEntry } from "../history";
@ -72,7 +72,7 @@ export const createUndoAction: ActionCreator = (history) => ({
PanelComponent: ({ updateData, data }) => ( PanelComponent: ({ updateData, data }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={undo} icon={UndoIcon}
aria-label={t("buttons.undo")} aria-label={t("buttons.undo")}
onClick={updateData} onClick={updateData}
size={data?.size || "medium"} size={data?.size || "medium"}
@ -94,7 +94,7 @@ export const createRedoAction: ActionCreator = (history) => ({
PanelComponent: ({ updateData, data }) => ( PanelComponent: ({ updateData, data }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={redo} icon={RedoIcon}
aria-label={t("buttons.redo")} aria-label={t("buttons.redo")}
onClick={updateData} onClick={updateData}
size={data?.size || "medium"} size={data?.size || "medium"}

View File

@ -1,11 +1,12 @@
import { menu, palette } from "../components/icons"; import { HamburgerMenuIcon, HelpIcon, palette } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element"; import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register"; import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils"; import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { HelpIcon } from "../components/HelpIcon"; import { HelpButton } from "../components/HelpButton";
import MenuItem from "../components/MenuItem";
export const actionToggleCanvasMenu = register({ export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu", name: "toggleCanvasMenu",
@ -20,7 +21,7 @@ export const actionToggleCanvasMenu = register({
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={menu} icon={HamburgerMenuIcon}
aria-label={t("buttons.menu")} aria-label={t("buttons.menu")}
onClick={updateData} onClick={updateData}
selected={appState.openMenu === "canvas"} selected={appState.openMenu === "canvas"}
@ -74,19 +75,28 @@ export const actionShortcuts = register({
name: "toggleShortcuts", name: "toggleShortcuts",
trackEvent: { category: "menu", action: "toggleHelpDialog" }, trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => { perform: (_elements, appState, _, { focusContainer }) => {
if (appState.showHelpDialog) { if (appState.openDialog === "help") {
focusContainer(); focusContainer();
} }
return { return {
appState: { appState: {
...appState, ...appState,
showHelpDialog: !appState.showHelpDialog, openDialog: appState.openDialog === "help" ? null : "help",
}, },
commitToHistory: false, commitToHistory: false,
}; };
}, },
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData, isInHamburgerMenu }) =>
<HelpIcon title={t("helpDialog.title")} onClick={updateData} /> 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, keyTest: (event) => event.key === KEYS.QUESTION_MARK,
}); });

View File

@ -2,37 +2,41 @@ import { AppState } from "../../src/types";
import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
// TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
// ArrowHead icons
import { import {
ArrowheadArrowIcon, ArrowheadArrowIcon,
ArrowheadBarIcon, ArrowheadBarIcon,
ArrowheadDotIcon, ArrowheadDotIcon,
ArrowheadTriangleIcon, ArrowheadTriangleIcon,
ArrowheadNoneIcon, ArrowheadNoneIcon,
EdgeRoundIcon,
EdgeSharpIcon,
FillCrossHatchIcon,
FillHachureIcon,
FillSolidIcon,
FontFamilyCodeIcon,
FontFamilyHandDrawnIcon,
FontFamilyNormalIcon,
FontSizeExtraLargeIcon,
FontSizeLargeIcon,
FontSizeMediumIcon,
FontSizeSmallIcon,
SloppinessArchitectIcon,
SloppinessArtistIcon,
SloppinessCartoonistIcon,
StrokeStyleDashedIcon, StrokeStyleDashedIcon,
StrokeStyleDottedIcon, StrokeStyleDottedIcon,
StrokeStyleSolidIcon,
StrokeWidthIcon,
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
TextAlignTopIcon, TextAlignTopIcon,
TextAlignBottomIcon, TextAlignBottomIcon,
TextAlignMiddleIcon, TextAlignMiddleIcon,
FillHachureIcon,
FillCrossHatchIcon,
FillSolidIcon,
SloppinessArchitectIcon,
SloppinessArtistIcon,
SloppinessCartoonistIcon,
StrokeWidthBaseIcon,
StrokeWidthBoldIcon,
StrokeWidthExtraBoldIcon,
FontSizeSmallIcon,
FontSizeMediumIcon,
FontSizeLargeIcon,
FontSizeExtraLargeIcon,
EdgeSharpIcon,
EdgeRoundIcon,
FreedrawIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon,
TextAlignCenterIcon,
TextAlignRightIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
@ -307,17 +311,17 @@ export const actionChangeFillStyle = register({
{ {
value: "hachure", value: "hachure",
text: t("labels.hachure"), text: t("labels.hachure"),
icon: <FillHachureIcon theme={appState.theme} />, icon: FillHachureIcon,
}, },
{ {
value: "cross-hatch", value: "cross-hatch",
text: t("labels.crossHatch"), text: t("labels.crossHatch"),
icon: <FillCrossHatchIcon theme={appState.theme} />, icon: FillCrossHatchIcon,
}, },
{ {
value: "solid", value: "solid",
text: t("labels.solid"), text: t("labels.solid"),
icon: <FillSolidIcon theme={appState.theme} />, icon: FillSolidIcon,
}, },
]} ]}
group="fill" group="fill"
@ -358,17 +362,17 @@ export const actionChangeStrokeWidth = register({
{ {
value: 1, value: 1,
text: t("labels.thin"), text: t("labels.thin"),
icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={2} />, icon: StrokeWidthBaseIcon,
}, },
{ {
value: 2, value: 2,
text: t("labels.bold"), text: t("labels.bold"),
icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={6} />, icon: StrokeWidthBoldIcon,
}, },
{ {
value: 4, value: 4,
text: t("labels.extraBold"), text: t("labels.extraBold"),
icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={10} />, icon: StrokeWidthExtraBoldIcon,
}, },
]} ]}
value={getFormValue( value={getFormValue(
@ -407,17 +411,17 @@ export const actionChangeSloppiness = register({
{ {
value: 0, value: 0,
text: t("labels.architect"), text: t("labels.architect"),
icon: <SloppinessArchitectIcon theme={appState.theme} />, icon: SloppinessArchitectIcon,
}, },
{ {
value: 1, value: 1,
text: t("labels.artist"), text: t("labels.artist"),
icon: <SloppinessArtistIcon theme={appState.theme} />, icon: SloppinessArtistIcon,
}, },
{ {
value: 2, value: 2,
text: t("labels.cartoonist"), text: t("labels.cartoonist"),
icon: <SloppinessCartoonistIcon theme={appState.theme} />, icon: SloppinessCartoonistIcon,
}, },
]} ]}
value={getFormValue( value={getFormValue(
@ -455,17 +459,17 @@ export const actionChangeStrokeStyle = register({
{ {
value: "solid", value: "solid",
text: t("labels.strokeStyle_solid"), text: t("labels.strokeStyle_solid"),
icon: <StrokeStyleSolidIcon theme={appState.theme} />, icon: StrokeWidthBaseIcon,
}, },
{ {
value: "dashed", value: "dashed",
text: t("labels.strokeStyle_dashed"), text: t("labels.strokeStyle_dashed"),
icon: <StrokeStyleDashedIcon theme={appState.theme} />, icon: StrokeStyleDashedIcon,
}, },
{ {
value: "dotted", value: "dotted",
text: t("labels.strokeStyle_dotted"), text: t("labels.strokeStyle_dotted"),
icon: <StrokeStyleDottedIcon theme={appState.theme} />, icon: StrokeStyleDottedIcon,
}, },
]} ]}
value={getFormValue( value={getFormValue(
@ -535,25 +539,25 @@ export const actionChangeFontSize = register({
{ {
value: 16, value: 16,
text: t("labels.small"), text: t("labels.small"),
icon: <FontSizeSmallIcon theme={appState.theme} />, icon: FontSizeSmallIcon,
testId: "fontSize-small", testId: "fontSize-small",
}, },
{ {
value: 20, value: 20,
text: t("labels.medium"), text: t("labels.medium"),
icon: <FontSizeMediumIcon theme={appState.theme} />, icon: FontSizeMediumIcon,
testId: "fontSize-medium", testId: "fontSize-medium",
}, },
{ {
value: 28, value: 28,
text: t("labels.large"), text: t("labels.large"),
icon: <FontSizeLargeIcon theme={appState.theme} />, icon: FontSizeLargeIcon,
testId: "fontSize-large", testId: "fontSize-large",
}, },
{ {
value: 36, value: 36,
text: t("labels.veryLarge"), text: t("labels.veryLarge"),
icon: <FontSizeExtraLargeIcon theme={appState.theme} />, icon: FontSizeExtraLargeIcon,
testId: "fontSize-veryLarge", testId: "fontSize-veryLarge",
}, },
]} ]}
@ -658,17 +662,17 @@ export const actionChangeFontFamily = register({
{ {
value: FONT_FAMILY.Virgil, value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"), text: t("labels.handDrawn"),
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />, icon: FreedrawIcon,
}, },
{ {
value: FONT_FAMILY.Helvetica, value: FONT_FAMILY.Helvetica,
text: t("labels.normal"), text: t("labels.normal"),
icon: <FontFamilyNormalIcon theme={appState.theme} />, icon: FontFamilyNormalIcon,
}, },
{ {
value: FONT_FAMILY.Cascadia, value: FONT_FAMILY.Cascadia,
text: t("labels.code"), text: t("labels.code"),
icon: <FontFamilyCodeIcon theme={appState.theme} />, icon: FontFamilyCodeIcon,
}, },
]; ];
@ -739,17 +743,17 @@ export const actionChangeTextAlign = register({
{ {
value: "left", value: "left",
text: t("labels.left"), text: t("labels.left"),
icon: <TextAlignLeftIcon theme={appState.theme} />, icon: TextAlignLeftIcon,
}, },
{ {
value: "center", value: "center",
text: t("labels.center"), text: t("labels.center"),
icon: <TextAlignCenterIcon theme={appState.theme} />, icon: TextAlignCenterIcon,
}, },
{ {
value: "right", value: "right",
text: t("labels.right"), text: t("labels.right"),
icon: <TextAlignRightIcon theme={appState.theme} />, icon: TextAlignRightIcon,
}, },
]} ]}
value={getFormValue( value={getFormValue(
@ -882,12 +886,12 @@ export const actionChangeSharpness = register({
{ {
value: "sharp", value: "sharp",
text: t("labels.sharp"), text: t("labels.sharp"),
icon: <EdgeSharpIcon theme={appState.theme} />, icon: EdgeSharpIcon,
}, },
{ {
value: "round", value: "round",
text: t("labels.round"), text: t("labels.round"),
icon: <EdgeRoundIcon theme={appState.theme} />, icon: EdgeRoundIcon,
}, },
]} ]}
value={getFormValue( value={getFormValue(
@ -949,42 +953,38 @@ export const actionChangeArrowhead = register({
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.arrowheads")}</legend> <legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList"> <div className="iconSelectList buttonList">
<IconPicker <IconPicker
label="arrowhead_start" label="arrowhead_start"
options={[ options={[
{ {
value: null, value: null,
text: t("labels.arrowhead_none"), text: t("labels.arrowhead_none"),
icon: <ArrowheadNoneIcon theme={appState.theme} />, icon: ArrowheadNoneIcon,
keyBinding: "q", keyBinding: "q",
}, },
{ {
value: "arrow", value: "arrow",
text: t("labels.arrowhead_arrow"), text: t("labels.arrowhead_arrow"),
icon: ( icon: <ArrowheadArrowIcon flip={!isRTL} />,
<ArrowheadArrowIcon theme={appState.theme} flip={!isRTL} />
),
keyBinding: "w", keyBinding: "w",
}, },
{ {
value: "bar", value: "bar",
text: t("labels.arrowhead_bar"), text: t("labels.arrowhead_bar"),
icon: <ArrowheadBarIcon theme={appState.theme} flip={!isRTL} />, icon: <ArrowheadBarIcon flip={!isRTL} />,
keyBinding: "e", keyBinding: "e",
}, },
{ {
value: "dot", value: "dot",
text: t("labels.arrowhead_dot"), text: t("labels.arrowhead_dot"),
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />, icon: <ArrowheadDotIcon flip={!isRTL} />,
keyBinding: "r", keyBinding: "r",
}, },
{ {
value: "triangle", value: "triangle",
text: t("labels.arrowhead_triangle"), text: t("labels.arrowhead_triangle"),
icon: ( icon: <ArrowheadTriangleIcon flip={!isRTL} />,
<ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
),
keyBinding: "t", keyBinding: "t",
}, },
]} ]}
@ -1007,34 +1007,30 @@ export const actionChangeArrowhead = register({
value: null, value: null,
text: t("labels.arrowhead_none"), text: t("labels.arrowhead_none"),
keyBinding: "q", keyBinding: "q",
icon: <ArrowheadNoneIcon theme={appState.theme} />, icon: ArrowheadNoneIcon,
}, },
{ {
value: "arrow", value: "arrow",
text: t("labels.arrowhead_arrow"), text: t("labels.arrowhead_arrow"),
keyBinding: "w", keyBinding: "w",
icon: ( icon: <ArrowheadArrowIcon flip={isRTL} />,
<ArrowheadArrowIcon theme={appState.theme} flip={isRTL} />
),
}, },
{ {
value: "bar", value: "bar",
text: t("labels.arrowhead_bar"), text: t("labels.arrowhead_bar"),
keyBinding: "e", keyBinding: "e",
icon: <ArrowheadBarIcon theme={appState.theme} flip={isRTL} />, icon: <ArrowheadBarIcon flip={isRTL} />,
}, },
{ {
value: "dot", value: "dot",
text: t("labels.arrowhead_dot"), text: t("labels.arrowhead_dot"),
keyBinding: "r", keyBinding: "r",
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />, icon: <ArrowheadDotIcon flip={isRTL} />,
}, },
{ {
value: "triangle", value: "triangle",
text: t("labels.arrowhead_triangle"), text: t("labels.arrowhead_triangle"),
icon: ( icon: <ArrowheadTriangleIcon flip={isRTL} />,
<ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} />
),
keyBinding: "t", keyBinding: "t",
}, },
]} ]}

View File

@ -10,10 +10,10 @@ import { t } from "../i18n";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { import {
SendBackwardIcon,
BringToFrontIcon,
SendToBackIcon,
BringForwardIcon, BringForwardIcon,
BringToFrontIcon,
SendBackwardIcon,
SendToBackIcon,
} from "../components/icons"; } from "../components/icons";
export const actionSendBackward = register({ export const actionSendBackward = register({
@ -39,7 +39,7 @@ export const actionSendBackward = register({
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.sendBackward")}${getShortcutKey("CtrlOrCmd+[")}`} title={`${t("labels.sendBackward")}${getShortcutKey("CtrlOrCmd+[")}`}
> >
<SendBackwardIcon theme={appState.theme} /> {SendBackwardIcon}
</button> </button>
), ),
}); });
@ -67,7 +67,7 @@ export const actionBringForward = register({
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.bringForward")}${getShortcutKey("CtrlOrCmd+]")}`} title={`${t("labels.bringForward")}${getShortcutKey("CtrlOrCmd+]")}`}
> >
<BringForwardIcon theme={appState.theme} /> {BringForwardIcon}
</button> </button>
), ),
}); });
@ -102,7 +102,7 @@ export const actionSendToBack = register({
: getShortcutKey("CtrlOrCmd+Shift+[") : getShortcutKey("CtrlOrCmd+Shift+[")
}`} }`}
> >
<SendToBackIcon theme={appState.theme} /> {SendToBackIcon}
</button> </button>
), ),
}); });
@ -138,7 +138,7 @@ export const actionBringToFront = register({
: getShortcutKey("CtrlOrCmd+Shift+]") : getShortcutKey("CtrlOrCmd+Shift+]")
}`} }`}
> >
<BringToFrontIcon theme={appState.theme} /> {BringToFrontIcon}
</button> </button>
), ),
}); });

View File

@ -135,8 +135,13 @@ export class ActionManager {
/** /**
* @param data additional data sent to the PanelComponent * @param data additional data sent to the PanelComponent
*/ */
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => { renderAction = (
name: ActionName,
data?: PanelComponentProps["data"],
isInHamburgerMenu = false,
) => {
const canvasActions = this.app.props.UIOptions.canvasActions; const canvasActions = this.app.props.UIOptions.canvasActions;
if ( if (
this.actions[name] && this.actions[name] &&
"PanelComponent" in this.actions[name] && "PanelComponent" in this.actions[name] &&
@ -169,6 +174,7 @@ export class ActionManager {
updateData={updateData} updateData={updateData}
appProps={this.app.props} appProps={this.app.props}
data={data} data={data}
isInHamburgerMenu={isInHamburgerMenu}
/> />
); );
} }

View File

@ -3,36 +3,45 @@ import { isDarwin } from "../keys";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { ActionName } from "./types"; import { ActionName } from "./types";
export type ShortcutName = SubtypeOf< export type ShortcutName =
ActionName, | SubtypeOf<
| "cut" ActionName,
| "copy" | "toggleTheme"
| "paste" | "loadScene"
| "copyStyles" | "cut"
| "pasteStyles" | "copy"
| "selectAll" | "paste"
| "deleteSelectedElements" | "copyStyles"
| "duplicateSelection" | "pasteStyles"
| "sendBackward" | "selectAll"
| "bringForward" | "deleteSelectedElements"
| "sendToBack" | "duplicateSelection"
| "bringToFront" | "sendBackward"
| "copyAsPng" | "bringForward"
| "copyAsSvg" | "sendToBack"
| "group" | "bringToFront"
| "ungroup" | "copyAsPng"
| "gridMode" | "copyAsSvg"
| "zenMode" | "group"
| "stats" | "ungroup"
| "addToLibrary" | "gridMode"
| "viewMode" | "zenMode"
| "flipHorizontal" | "stats"
| "flipVertical" | "addToLibrary"
| "hyperlink" | "viewMode"
| "toggleLock" | "flipHorizontal"
>; | "flipVertical"
| "hyperlink"
| "toggleLock"
>
| "saveScene"
| "imageExport";
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
saveScene: [getShortcutKey("CtrlOrCmd+S")],
loadScene: [getShortcutKey("CtrlOrCmd+O")],
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
cut: [getShortcutKey("CtrlOrCmd+X")], cut: [getShortcutKey("CtrlOrCmd+X")],
copy: [getShortcutKey("CtrlOrCmd+C")], copy: [getShortcutKey("CtrlOrCmd+C")],
paste: [getShortcutKey("CtrlOrCmd+V")], paste: [getShortcutKey("CtrlOrCmd+V")],

View File

@ -124,7 +124,9 @@ export type PanelComponentProps = {
export interface Action { export interface Action {
name: ActionName; name: ActionName;
PanelComponent?: React.FC<PanelComponentProps>; PanelComponent?: React.FC<
PanelComponentProps & { isInHamburgerMenu: boolean }
>;
perform: ActionFn; perform: ActionFn;
keyPriority?: number; keyPriority?: number;
keyTest?: ( keyTest?: (

View File

@ -19,6 +19,7 @@ export const getDefaultAppState = (): Omit<
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft" | "width" | "height"
> => { > => {
return { return {
showWelcomeScreen: false,
theme: THEME.LIGHT, theme: THEME.LIGHT,
collaborators: new Map(), collaborators: new Map(),
currentChartType: "bar", currentChartType: "bar",
@ -67,6 +68,7 @@ export const getDefaultAppState = (): Omit<
openMenu: null, openMenu: null,
openPopup: null, openPopup: null,
openSidebar: null, openSidebar: null,
openDialog: null,
pasteDialog: { shown: false, data: null }, pasteDialog: { shown: false, data: null },
previousSelectedElementIds: {}, previousSelectedElementIds: {},
resizingElement: null, resizingElement: null,
@ -77,7 +79,6 @@ export const getDefaultAppState = (): Omit<
selectedGroupIds: {}, selectedGroupIds: {},
selectionElement: null, selectionElement: null,
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
showHelpDialog: false,
showStats: false, showStats: false,
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
@ -110,6 +111,7 @@ const APP_STATE_STORAGE_CONF = (<
T extends Record<keyof AppState, Values>, T extends Record<keyof AppState, Values>,
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) => >(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
config)({ config)({
showWelcomeScreen: { browser: true, export: false, server: false },
theme: { browser: true, export: false, server: false }, theme: { browser: true, export: false, server: false },
collaborators: { browser: false, export: false, server: false }, collaborators: { browser: false, export: false, server: false },
currentChartType: { browser: true, export: false, server: false }, currentChartType: { browser: true, export: false, server: false },
@ -160,6 +162,7 @@ const APP_STATE_STORAGE_CONF = (<
openMenu: { browser: true, export: false, server: false }, openMenu: { browser: true, export: false, server: false },
openPopup: { browser: false, export: false, server: false }, openPopup: { browser: false, export: false, server: false },
openSidebar: { browser: true, export: false, server: false }, openSidebar: { browser: true, export: false, server: false },
openDialog: { browser: false, export: false, server: false },
pasteDialog: { browser: false, export: false, server: false }, pasteDialog: { browser: false, export: false, server: false },
previousSelectedElementIds: { browser: true, export: false, server: false }, previousSelectedElementIds: { browser: true, export: false, server: false },
resizingElement: { browser: false, export: false, server: false }, resizingElement: { browser: false, export: false, server: false },
@ -170,7 +173,6 @@ const APP_STATE_STORAGE_CONF = (<
selectedGroupIds: { browser: true, export: false, server: false }, selectedGroupIds: { browser: true, export: false, server: false },
selectionElement: { browser: false, export: false, server: false }, selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
showHelpDialog: { browser: false, export: false, server: false },
showStats: { browser: true, export: false, server: false }, showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false }, suggestedBindings: { browser: false, export: false, server: false },

View File

@ -11,27 +11,18 @@ export const getClientColors = (clientId: string, appState: AppState) => {
// Naive way of getting an integer out of the clientId // Naive way of getting an integer out of the clientId
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0); const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
// Skip transparent background. // Skip transparent & gray colors
const backgrounds = colors.elementBackground.slice(1); const backgrounds = colors.elementBackground.slice(3);
const strokes = colors.elementStroke.slice(1); const strokes = colors.elementStroke.slice(3);
return { return {
background: backgrounds[sum % backgrounds.length], background: backgrounds[sum % backgrounds.length],
stroke: strokes[sum % strokes.length], stroke: strokes[sum % strokes.length],
}; };
}; };
export const getClientInitials = (username?: string | null) => { export const getClientInitials = (userName?: string | null) => {
if (!username) { if (!userName) {
return "?"; return "?";
} }
const names = username.trim().split(" "); return userName.trim()[0].toUpperCase();
if (names.length < 2) {
return names[0].substring(0, 2).toUpperCase();
}
const firstName = names[0];
const lastName = names[names.length - 1];
return (firstName[0] + lastName[0]).toUpperCase();
}; };

27
src/clipboard.test.ts Normal file
View File

@ -0,0 +1,27 @@
import { parseClipboard } from "./clipboard";
describe("Test parseClipboard", () => {
it("should parse valid json correctly", async () => {
let text = "123";
let clipboardData = await parseClipboard({
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
expect(clipboardData.text).toBe(text);
text = "[123]";
clipboardData = await parseClipboard({
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
expect(clipboardData.text).toBe(text);
});
});

View File

@ -156,15 +156,13 @@ export const parseClipboard = async (
files: systemClipboardData.files, files: systemClipboardData.files,
}; };
} }
return appClipboardData; } catch (e) {}
} catch { // system clipboard doesn't contain excalidraw elements → return plaintext
// system clipboard doesn't contain excalidraw elements → return plaintext // unless we set a flag to prefer in-app clipboard because browser didn't
// unless we set a flag to prefer in-app clipboard because browser didn't // support storing to system clipboard on copy
// support storing to system clipboard on copy return PREFER_APP_CLIPBOARD && appClipboardData.elements
return PREFER_APP_CLIPBOARD && appClipboardData.elements ? appClipboardData
? appClipboardData : { text: systemClipboard };
: { text: systemClipboard };
}
}; };
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => { export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {

View File

@ -0,0 +1,92 @@
.zoom-actions,
.undo-redo-buttons {
background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg);
}
.zoom-button,
.undo-redo-buttons button {
border: 1px solid var(--default-border-color) !important;
border-radius: 0 !important;
background-color: transparent !important;
font-size: 0.875rem !important;
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size) !important;
height: var(--lg-icon-size) !important;
}
.ToolIcon__icon {
width: 100%;
height: 100%;
}
}
.reset-zoom-button {
border-left: 0 !important;
border-right: 0 !important;
padding: 0 0.625rem !important;
width: 3.75rem !important;
justify-content: center;
color: var(--text-primary-color);
}
.zoom-out-button {
border-top-left-radius: var(--border-radius-lg) !important;
border-bottom-left-radius: var(--border-radius-lg) !important;
:root[dir="rtl"] & {
transform: scaleX(-1);
}
.ToolIcon__icon {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
}
.zoom-in-button {
border-top-right-radius: var(--border-radius-lg) !important;
border-bottom-right-radius: var(--border-radius-lg) !important;
:root[dir="rtl"] & {
transform: scaleX(-1);
}
.ToolIcon__icon {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
}
.undo-redo-buttons {
.undo-button-container button {
border-top-left-radius: var(--border-radius-lg) !important;
border-bottom-left-radius: var(--border-radius-lg) !important;
border-right: 0 !important;
:root[dir="rtl"] & {
transform: scaleX(-1);
}
.ToolIcon__icon {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
}
.redo-button-container button {
border-top-right-radius: var(--border-radius-lg) !important;
border-bottom-right-radius: var(--border-radius-lg) !important;
:root[dir="rtl"] & {
transform: scaleX(-1);
}
.ToolIcon__icon {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
}
}

View File

@ -28,6 +28,8 @@ import { trackEvent } from "../analytics";
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks"; import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
import clsx from "clsx"; import clsx from "clsx";
import { actionToggleZenMode } from "../actions"; import { actionToggleZenMode } from "../actions";
import "./Actions.scss";
import { Tooltip } from "./Tooltip";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
@ -79,12 +81,16 @@ export const SelectedShapeActions = ({
return ( return (
<div className="panelColumn"> <div className="panelColumn">
{((hasStrokeColor(appState.activeTool.type) && <div>
appState.activeTool.type !== "image" && {((hasStrokeColor(appState.activeTool.type) &&
commonSelectedType !== "image") || appState.activeTool.type !== "image" &&
targetElements.some((element) => hasStrokeColor(element.type))) && commonSelectedType !== "image") ||
renderAction("changeStrokeColor")} targetElements.some((element) => hasStrokeColor(element.type))) &&
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")} renderAction("changeStrokeColor")}
</div>
{showChangeBackgroundIcons && (
<div>{renderAction("changeBackgroundColor")}</div>
)}
{showFillIcons && renderAction("changeFillStyle")} {showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(appState.activeTool.type) || {(hasStrokeWidth(appState.activeTool.type) ||
@ -163,7 +169,16 @@ export const SelectedShapeActions = ({
)} )}
{targetElements.length > 2 && {targetElements.length > 2 &&
renderAction("distributeHorizontally")} renderAction("distributeHorizontally")}
<div className="iconRow"> {/* breaks the row ˇˇ */}
<div style={{ flexBasis: "100%", height: 0 }} />
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: ".5rem",
marginTop: "-0.5rem",
}}
>
{renderAction("alignTop")} {renderAction("alignTop")}
{renderAction("alignVerticallyCentered")} {renderAction("alignVerticallyCentered")}
{renderAction("alignBottom")} {renderAction("alignBottom")}
@ -203,25 +218,25 @@ export const ShapesSwitcher = ({
appState: AppState; appState: AppState;
}) => ( }) => (
<> <>
{SHAPES.map(({ value, icon, key }, index) => { {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
const label = t(`toolBar.${value}`); const label = t(`toolBar.${value}`);
const letter = key && (typeof key === "string" ? key : key[0]); const letter = key && (typeof key === "string" ? key : key[0]);
const shortcut = letter const shortcut = letter
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}` ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numericKey}`
: `${index + 1}`; : `${numericKey}`;
return ( return (
<ToolButton <ToolButton
className="Shape" className={clsx("Shape", { fillable })}
key={value} key={value}
type="radio" type="radio"
icon={icon} icon={icon}
checked={activeTool.type === value} checked={activeTool.type === value}
name="editor-current-shape" name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`} title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={`${index + 1}`} keyBindingLabel={numericKey}
aria-label={capitalizeString(label)} aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut} aria-keyshortcuts={shortcut}
data-testid={value} data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => { onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") { if (!appState.penDetected && pointerType === "pen") {
setAppState({ setAppState({
@ -263,11 +278,11 @@ export const ZoomActions = ({
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
zoom: Zoom; zoom: Zoom;
}) => ( }) => (
<Stack.Col gap={1}> <Stack.Col gap={1} className="zoom-actions">
<Stack.Row gap={1} align="center"> <Stack.Row align="center">
{renderAction("zoomOut")} {renderAction("zoomOut")}
{renderAction("zoomIn")}
{renderAction("resetZoom")} {renderAction("resetZoom")}
{renderAction("zoomIn")}
</Stack.Row> </Stack.Row>
</Stack.Col> </Stack.Col>
); );
@ -280,8 +295,12 @@ export const UndoRedoActions = ({
className?: string; className?: string;
}) => ( }) => (
<div className={`undo-redo-buttons ${className}`}> <div className={`undo-redo-buttons ${className}`}>
{renderAction("undo", { size: "small" })} <div className="undo-button-container">
{renderAction("redo", { size: "small" })} <Tooltip label={t("buttons.undo")}>{renderAction("undo")}</Tooltip>
</div>
<div className="redo-button-container">
<Tooltip label={t("buttons.redo")}> {renderAction("redo")}</Tooltip>
</div>
</div> </div>
); );

View File

@ -1,9 +1,11 @@
import Stack from "../components/Stack"; // TODO barnabasmolnar/editor-redesign
import { ToolButton } from "../components/ToolButton"; // this icon is not great
import { save, file } from "../components/icons"; import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { save } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import "./ActiveFile.scss"; import "./ActiveFile.scss";
import MenuItem from "./MenuItem";
type ActiveFileProps = { type ActiveFileProps = {
fileName?: string; fileName?: string;
@ -11,18 +13,11 @@ type ActiveFileProps = {
}; };
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => ( export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
<Stack.Row className="ActiveFile" gap={1} align="center"> <MenuItem
<span className="ActiveFile__fileName"> label={`${t("buttons.save")}`}
{file} shortcut={getShortcutFromShortcutName("saveScene")}
<span>{fileName}</span> dataTestId="save-button"
</span> onClick={onSave}
<ToolButton icon={save}
type="icon" />
icon={save}
title={t("buttons.save")}
aria-label={t("buttons.save")}
onClick={onSave}
data-testid="save-button"
/>
</Stack.Row>
); );

View File

@ -266,6 +266,10 @@ import {
isLocalLink, isLocalLink,
} from "../element/Hyperlink"; } from "../element/Hyperlink";
import { shouldShowBoundingBox } from "../element/transformHandles"; import { shouldShowBoundingBox } from "../element/transformHandles";
import { atom } from "jotai";
export const isMenuOpenAtom = atom(false);
export const isDropdownOpenAtom = atom(false);
const deviceContextInitialValue = { const deviceContextInitialValue = {
isSmScreen: false, isSmScreen: false,
@ -571,6 +575,11 @@ class App extends React.Component<AppProps, AppState> {
library={this.library} library={this.library}
id={this.id} id={this.id}
onImageAction={this.onImageAction} onImageAction={this.onImageAction}
renderWelcomeScreen={
this.state.showWelcomeScreen &&
this.state.activeTool.type === "selection" &&
!this.scene.getElementsIncludingDeleted().length
}
/> />
<div className="excalidraw-textEditorContainer" /> <div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" /> <div className="excalidraw-contextMenuContainer" />
@ -1085,6 +1094,13 @@ class App extends React.Component<AppProps, AppState> {
} }
componentDidUpdate(prevProps: AppProps, prevState: AppState) { componentDidUpdate(prevProps: AppProps, prevState: AppState) {
if (
!this.state.showWelcomeScreen &&
!this.scene.getElementsIncludingDeleted().length
) {
this.setState({ showWelcomeScreen: true });
}
if ( if (
this.excalidrawContainerRef.current && this.excalidrawContainerRef.current &&
prevProps.UIOptions.dockedSidebarBreakpoint !== prevProps.UIOptions.dockedSidebarBreakpoint !==
@ -1276,6 +1292,10 @@ class App extends React.Component<AppProps, AppState> {
); );
}); });
const selectionColor = getComputedStyle(
document.querySelector(".excalidraw")!,
).getPropertyValue("--color-selection");
renderScene( renderScene(
{ {
elements: renderingElements, elements: renderingElements,
@ -1284,6 +1304,7 @@ class App extends React.Component<AppProps, AppState> {
rc: this.rc!, rc: this.rc!,
canvas: this.canvas!, canvas: this.canvas!,
renderConfig: { renderConfig: {
selectionColor,
scrollX: this.state.scrollX, scrollX: this.state.scrollX,
scrollY: this.state.scrollY, scrollY: this.state.scrollY,
viewBackgroundColor: this.state.viewBackgroundColor, viewBackgroundColor: this.state.viewBackgroundColor,
@ -1867,8 +1888,16 @@ class App extends React.Component<AppProps, AppState> {
if (event.key === KEYS.QUESTION_MARK) { if (event.key === KEYS.QUESTION_MARK) {
this.setState({ this.setState({
showHelpDialog: true, openDialog: "help",
}); });
return;
} else if (
event.key.toLowerCase() === KEYS.E &&
event.shiftKey &&
event[KEYS.CTRL_OR_CMD]
) {
this.setState({ openDialog: "imageExport" });
return;
} }
if (this.actionManager.handleKeyDown(event)) { if (this.actionManager.handleKeyDown(event)) {
@ -1883,18 +1912,6 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ isBindingEnabled: false }); this.setState({ isBindingEnabled: false });
} }
if (event.code === CODES.ZERO) {
const nextState = this.toggleMenu("library");
// track only openings
if (nextState) {
trackEvent(
"library",
"toggleLibrary (open)",
`keyboard (${this.device.isMobile ? "mobile" : "desktop"})`,
);
}
}
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
const step = const step =
(this.state.gridSize && (this.state.gridSize &&
@ -4807,10 +4824,6 @@ class App extends React.Component<AppProps, AppState> {
} else { } else {
this.setState((prevState) => ({ this.setState((prevState) => ({
draggingElement: null, draggingElement: null,
selectedElementIds: {
...prevState.selectedElementIds,
[draggingElement.id]: true,
},
})); }));
} }
} }
@ -5218,6 +5231,7 @@ class App extends React.Component<AppProps, AppState> {
id: fileId, id: fileId,
dataURL, dataURL,
created: Date.now(), created: Date.now(),
lastRetrieved: Date.now(),
}, },
}; };
const cachedImageData = this.imageCache.get(fileId); const cachedImageData = this.imageCache.get(fileId);

View File

@ -2,16 +2,19 @@
.excalidraw { .excalidraw {
.Avatar { .Avatar {
width: 2.5rem; width: 1.25rem;
height: 2.5rem; height: 1.25rem;
border-radius: 1.25rem; border-radius: 100%;
outline: 2px solid var(--avatar-border-color);
outline-offset: 2px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
color: $oc-white; color: $oc-white;
cursor: pointer; cursor: pointer;
font-size: 0.8rem; font-size: 0.625rem;
font-weight: 500; font-weight: 500;
line-height: 1;
&-img { &-img {
width: 100%; width: 100%;

View File

@ -11,13 +11,11 @@ type AvatarProps = {
src?: string; src?: string;
}; };
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => { export const Avatar = ({ color, onClick, name, src }: AvatarProps) => {
const shortName = getClientInitials(name); const shortName = getClientInitials(name);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const loadImg = !error && src; const loadImg = !error && src;
const style = loadImg const style = loadImg ? undefined : { background: color };
? undefined
: { background: color, border: `1px solid ${border}` };
return ( return (
<div className="Avatar" style={style} onClick={onClick}> <div className="Avatar" style={style} onClick={onClick}>
{loadImg ? ( {loadImg ? (

View File

@ -1,12 +0,0 @@
import { ActionManager } from "../actions/manager";
export const BackgroundPickerAndDarkModeToggle = ({
actionManager,
}: {
actionManager: ActionManager;
}) => (
<div style={{ display: "flex" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
{actionManager.renderAction("toggleTheme")}
</div>
);

View File

@ -64,6 +64,8 @@
color: #{$oc-blue-7}; color: #{$oc-blue-7};
border: 0;
&:focus { &:focus {
box-shadow: 0 0 0 3px #{$oc-blue-7}; box-shadow: 0 0 0 3px #{$oc-blue-7};
} }

View File

@ -1,10 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "./App"; import { TrashIcon } from "./icons";
import { trash } from "./icons";
import { ToolButton } from "./ToolButton";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import MenuItem from "./MenuItem";
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
@ -14,14 +13,11 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
return ( return (
<> <>
<ToolButton <MenuItem
type="button" label={t("buttons.clearReset")}
icon={trash} icon={TrashIcon}
title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")}
showAriaLabel={useDevice().isMobile}
onClick={toggleDialog} onClick={toggleDialog}
data-testid="clear-canvas-button" dataTestId="clear-canvas-button"
/> />
{showDialog && ( {showDialog && (

View File

@ -1,6 +1,51 @@
@import "../css/variables.module"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.collab-button {
@include outlineButtonStyles;
width: var(--lg-button-size);
height: var(--lg-button-size);
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 {
background-color: #0fb884;
border-color: #0fb884;
svg {
color: #fff;
}
&:hover,
&:active {
background-color: #0fb884;
border-color: #0fb884;
}
}
}
&.theme--dark {
.collab-button {
color: var(--color-gray-90);
}
}
.CollabButton.is-collaborating { .CollabButton.is-collaborating {
background-color: var(--button-special-active-bg-color); background-color: var(--button-special-active-bg-color);
@ -24,9 +69,9 @@
bottom: -5px; bottom: -5px;
padding: 3px; padding: 3px;
border-radius: 50%; border-radius: 50%;
background-color: $oc-green-6; background-color: $oc-green-2;
color: $oc-white; color: $oc-green-9;
font-size: 0.6em; font-size: 0.6rem;
font-family: "Cascadia"; font-family: "Cascadia";
} }
} }

View File

@ -1,37 +1,47 @@
import clsx from "clsx";
import { ToolButton } from "./ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "../components/App"; import { UsersIcon } from "./icons";
import { users } from "./icons";
import "./CollabButton.scss"; import "./CollabButton.scss";
import MenuItem from "./MenuItem";
import clsx from "clsx";
const CollabButton = ({ const CollabButton = ({
isCollaborating, isCollaborating,
collaboratorCount, collaboratorCount,
onClick, onClick,
isInHamburgerMenu = true,
}: { }: {
isCollaborating: boolean; isCollaborating: boolean;
collaboratorCount: number; collaboratorCount: number;
onClick: () => void; onClick: () => void;
isInHamburgerMenu?: boolean;
}) => { }) => {
return ( return (
<> <>
<ToolButton {isInHamburgerMenu ? (
className={clsx("CollabButton", { <MenuItem
"is-collaborating": isCollaborating, label={t("labels.liveCollaboration")}
})} dataTestId="collab-button"
onClick={onClick} icon={UsersIcon}
icon={users} onClick={onClick}
type="button" isCollaborating={isCollaborating}
title={t("labels.liveCollaboration")} />
aria-label={t("labels.liveCollaboration")} ) : (
showAriaLabel={useDevice().isMobile} <button
> className={clsx("collab-button", { active: isCollaborating })}
{isCollaborating && ( type="button"
<div className="CollabButton-collaborators">{collaboratorCount}</div> onClick={onClick}
)} style={{ position: "relative" }}
</ToolButton> title={t("labels.liveCollaboration")}
>
{UsersIcon}
{collaboratorCount > 0 && (
<div className="CollabButton-collaborators">
{collaboratorCount}
</div>
)}
</button>
)}
</> </>
); );
}; };

View File

@ -21,6 +21,23 @@
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
align-items: center; align-items: center;
column-gap: 0.5rem;
}
.color-picker-control-container + .popover {
position: static;
}
.color-picker-popover-container {
margin-top: -0.25rem;
:root[dir="ltr"] & {
margin-left: 0.5rem;
}
:root[dir="rtl"] & {
margin-left: -3rem;
}
} }
.color-picker-triangle { .color-picker-triangle {
@ -30,20 +47,29 @@
border-width: 0 9px 10px; border-width: 0 9px 10px;
border-color: transparent transparent var(--popup-bg-color); border-color: transparent transparent var(--popup-bg-color);
position: absolute; position: absolute;
top: -10px; top: 10px;
:root[dir="ltr"] & { :root[dir="ltr"] & {
left: 12px; transform: rotate(270deg);
left: -14px;
} }
:root[dir="rtl"] & { :root[dir="rtl"] & {
right: 12px; transform: rotate(90deg);
right: -14px;
} }
} }
.color-picker-triangle-shadow { .color-picker-triangle-shadow {
border-color: transparent transparent transparentize($oc-black, 0.9); border-color: transparent transparent transparentize($oc-black, 0.9);
top: -11px;
:root[dir="ltr"] & {
left: -14px;
}
:root[dir="rtl"] & {
right: -16px;
}
} }
.color-picker-content--default { .color-picker-content--default {
@ -119,16 +145,21 @@
} }
.color-picker-hash { .color-picker-hash {
background: var(--input-border-color); height: var(--default-button-size);
height: 1.875rem; flex-shrink: 0;
width: 1.875rem; padding: 0.5rem 0.5rem 0.5rem 0.75rem;
border: 1px solid var(--default-border-color);
border-right: 0;
box-sizing: border-box;
:root[dir="ltr"] & { :root[dir="ltr"] & {
border-radius: 4px 0 0 4px; border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
} }
:root[dir="rtl"] & { :root[dir="rtl"] & {
border-radius: 0 4px 4px 0; border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
border-right: 1px solid var(--default-border-color);
border-left: 0;
} }
color: var(--input-label-color); color: var(--input-label-color);
@ -138,81 +169,64 @@
position: relative; position: relative;
} }
.color-input-container:focus-within .color-picker-hash {
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
.color-input-container:focus-within .color-picker-hash::before,
.color-input-container:focus-within .color-picker-hash::after {
content: "";
width: 1px;
height: 100%;
position: absolute;
top: 0;
}
.color-input-container:focus-within .color-picker-hash::before {
background: var(--input-border-color);
:root[dir="ltr"] & {
right: -1px;
}
:root[dir="rtl"] & {
left: -1px;
}
}
.color-input-container:focus-within .color-picker-hash::after {
background: var(--input-bg-color);
:root[dir="ltr"] & {
right: -2px;
}
:root[dir="rtl"] & {
left: -2px;
}
}
.color-input-container { .color-input-container {
display: flex; display: flex;
&:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-lg);
}
} }
.color-picker-input { .color-picker-input {
width: 11ch; /* length of `transparent` */ box-sizing: border-box;
width: 100%;
margin: 0; margin: 0;
font-size: 1rem; font-size: 0.875rem;
background-color: var(--input-bg-color); background-color: transparent;
color: var(--text-primary-color); color: var(--text-primary-color);
border: 0; border: 0;
outline: none; outline: none;
height: 1.75em; height: var(--default-button-size);
box-shadow: var(--input-border-color) 0 0 0 1px inset; border: 1px solid var(--default-border-color);
border-left: 0;
letter-spacing: 0.4px;
:root[dir="ltr"] & { :root[dir="ltr"] & {
border-radius: 0 4px 4px 0; border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
} }
:root[dir="rtl"] & { :root[dir="rtl"] & {
border-radius: 4px 0 0 4px; border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
border-left: 1px solid var(--default-border-color);
border-right: 0;
} }
float: left; padding: 0.5rem;
padding: 1px; padding-left: 0.25rem;
padding-inline-start: 0.5em;
appearance: none; appearance: none;
&:focus-visible {
box-shadow: none;
}
}
.color-picker-label-swatch-container {
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
width: var(--default-button-size);
height: var(--default-button-size);
box-sizing: border-box;
overflow: hidden;
} }
.color-picker-label-swatch { .color-picker-label-swatch {
height: 1.875rem; @include outlineButtonStyles;
width: 1.875rem; background-color: var(--swatch-color) !important;
margin-inline-end: 0.25rem;
border: 1px solid $oc-gray-3;
position: relative;
overflow: hidden; overflow: hidden;
background-color: transparent !important; position: relative;
filter: var(--theme-filter); filter: var(--theme-filter);
border: 0 !important;
&:after { &:after {
content: ""; content: "";

View File

@ -365,17 +365,20 @@ export const ColorPicker = ({
appState: AppState; appState: AppState;
}) => { }) => {
const pickerButton = React.useRef<HTMLButtonElement>(null); const pickerButton = React.useRef<HTMLButtonElement>(null);
const coords = pickerButton.current?.getBoundingClientRect();
return ( return (
<div> <div>
<div className="color-picker-control-container"> <div className="color-picker-control-container">
<button <div className="color-picker-label-swatch-container">
className="color-picker-label-swatch" <button
aria-label={label} className="color-picker-label-swatch"
style={color ? { "--swatch-color": color } : undefined} aria-label={label}
onClick={() => setActive(!isActive)} style={color ? { "--swatch-color": color } : undefined}
ref={pickerButton} onClick={() => setActive(!isActive)}
/> ref={pickerButton}
/>
</div>
<ColorInput <ColorInput
color={color} color={color}
label={label} label={label}
@ -386,27 +389,37 @@ export const ColorPicker = ({
</div> </div>
<React.Suspense fallback=""> <React.Suspense fallback="">
{isActive ? ( {isActive ? (
<Popover <div
onCloseRequest={(event) => className="color-picker-popover-container"
event.target !== pickerButton.current && setActive(false) style={{
} position: "fixed",
top: coords?.top,
left: coords?.right,
zIndex: 1,
}}
> >
<Picker <Popover
colors={colors[type]} onCloseRequest={(event) =>
color={color || null} event.target !== pickerButton.current && setActive(false)
onChange={(changedColor) => { }
onChange(changedColor); >
}} <Picker
onClose={() => { colors={colors[type]}
setActive(false); color={color || null}
pickerButton.current?.focus(); onChange={(changedColor) => {
}} onChange(changedColor);
label={label} }}
showInput={false} onClose={() => {
type={type} setActive(false);
elements={elements} pickerButton.current?.focus();
/> }}
</Popover> label={label}
showInput={false}
type={type}
elements={elements}
/>
</Popover>
</div>
) : null} ) : null}
</React.Suspense> </React.Suspense>
</div> </div>

View File

@ -4,34 +4,8 @@
.confirm-dialog { .confirm-dialog {
&-buttons { &-buttons {
display: flex; display: flex;
padding: 0.2rem 0; column-gap: 0.5rem;
justify-content: flex-end; justify-content: flex-end;
} }
.ToolIcon__icon {
min-width: 2.5rem;
width: auto;
font-size: 1rem;
}
.ToolIcon_type_button {
margin-left: 0.8rem;
padding: 0 0.5rem;
}
&__content {
font-size: 1rem;
}
&--confirm.ToolIcon_type_button {
background-color: $oc-red-6;
&:hover {
background-color: $oc-red-8;
}
.ToolIcon__icon {
color: $oc-white;
}
}
} }
} }

View File

@ -1,8 +1,11 @@
import { t } from "../i18n"; import { t } from "../i18n";
import { Dialog, DialogProps } from "./Dialog"; import { Dialog, DialogProps } from "./Dialog";
import { ToolButton } from "./ToolButton";
import "./ConfirmDialog.scss"; import "./ConfirmDialog.scss";
import DialogActionButton from "./DialogActionButton";
import { isMenuOpenAtom } from "./App";
import { isDropdownOpenAtom } from "./App";
import { useSetAtom } from "jotai";
interface Props extends Omit<DialogProps, "onCloseRequest"> { interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void; onConfirm: () => void;
@ -20,6 +23,10 @@ const ConfirmDialog = (props: Props) => {
className = "", className = "",
...rest ...rest
} = props; } = props;
const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
return ( return (
<Dialog <Dialog
onCloseRequest={onCancel} onCloseRequest={onCancel}
@ -29,21 +36,22 @@ const ConfirmDialog = (props: Props) => {
> >
{children} {children}
<div className="confirm-dialog-buttons"> <div className="confirm-dialog-buttons">
<ToolButton <DialogActionButton
type="button"
title={cancelText}
aria-label={cancelText}
label={cancelText} label={cancelText}
onClick={onCancel} onClick={() => {
className="confirm-dialog--cancel" setIsMenuOpen(false);
setIsDropdownOpen(false);
onCancel();
}}
/> />
<ToolButton <DialogActionButton
type="button"
title={confirmText}
aria-label={confirmText}
label={confirmText} label={confirmText}
onClick={onConfirm} onClick={() => {
className="confirm-dialog--confirm" setIsMenuOpen(false);
setIsDropdownOpen(false);
onConfirm();
}}
actionType="danger"
/> />
</div> </div>
</Dialog> </Dialog>

View File

@ -7,68 +7,11 @@
} }
.Dialog__title { .Dialog__title {
display: grid;
align-items: center;
margin-top: 0;
grid-template-columns: 1fr calc(var(--space-factor) * 7);
grid-gap: var(--metric);
padding: calc(var(--space-factor) * 2);
text-align: center;
font-variant: small-caps;
font-size: 1.2em;
}
.Dialog__titleContent {
flex: 1;
}
.Dialog .Modal__close {
color: var(--icon-fill-color);
margin: 0; margin: 0;
} text-align: left;
font-size: 1.25rem;
.Dialog__content { border-bottom: 1px solid var(--dialog-border-color);
padding: 0 16px 16px; padding: 0 0 0.75rem;
} margin-bottom: 1.5rem;
@include isMobile {
.Dialog {
--metric: calc(var(--space-factor) * 4);
--inset-left: #{"max(var(--metric), var(--sal))"};
--inset-right: #{"max(var(--metric), var(--sar))"};
}
.Dialog__title {
grid-template-columns: calc(var(--space-factor) * 7) 1fr calc(
var(--space-factor) * 7
);
position: sticky;
top: 0;
padding: calc(var(--space-factor) * 2);
background: var(--island-bg-color);
font-size: 1.25em;
box-sizing: border-box;
border-bottom: 1px solid var(--button-gray-2);
z-index: 1;
}
.Dialog__titleContent {
text-align: center;
}
.Dialog .Island {
width: 100vw;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
padding-left: #{"max(calc(var(--padding) * var(--space-factor)), var(--sal))"};
padding-right: #{"max(calc(var(--padding) * var(--space-factor)), var(--sar))"};
padding-bottom: #{"max(calc(var(--padding) * var(--space-factor)), var(--sab))"};
}
.Dialog .Modal__close {
order: -1;
}
} }
} }

View File

@ -5,11 +5,13 @@ import { t } from "../i18n";
import { useExcalidrawContainer, useDevice } from "../components/App"; import { useExcalidrawContainer, useDevice } from "../components/App";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import "./Dialog.scss"; import "./Dialog.scss";
import { back, close } from "./icons"; import { back, CloseIcon } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { AppState } from "../types"; import { AppState } from "../types";
import { queryFocusableElements } from "../utils"; import { queryFocusableElements } from "../utils";
import { isMenuOpenAtom, isDropdownOpenAtom } from "./App";
import { useSetAtom } from "jotai";
export interface DialogProps { export interface DialogProps {
children: React.ReactNode; children: React.ReactNode;
@ -65,7 +67,12 @@ export const Dialog = (props: DialogProps) => {
return () => islandNode.removeEventListener("keydown", handleKeyDown); return () => islandNode.removeEventListener("keydown", handleKeyDown);
}, [islandNode, props.autofocus]); }, [islandNode, props.autofocus]);
const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
const onClose = () => { const onClose = () => {
setIsMenuOpen(false);
setIsDropdownOpen(false);
(lastActiveElement as HTMLElement).focus(); (lastActiveElement as HTMLElement).focus();
props.onCloseRequest(); props.onCloseRequest();
}; };
@ -88,7 +95,7 @@ export const Dialog = (props: DialogProps) => {
title={t("buttons.close")} title={t("buttons.close")}
aria-label={t("buttons.close")} aria-label={t("buttons.close")}
> >
{useDevice().isMobile ? back : close} {useDevice().isMobile ? back : CloseIcon}
</button> </button>
</h2> </h2>
<div className="Dialog__content">{props.children}</div> <div className="Dialog__content">{props.children}</div>

View File

@ -0,0 +1,47 @@
.excalidraw {
.Dialog__action-button {
position: relative;
display: flex;
column-gap: 0.5rem;
align-items: center;
padding: 0.5rem 1.5rem;
border: 1px solid var(--default-border-color);
background-color: transparent;
height: 3rem;
border-radius: var(--border-radius-lg);
letter-spacing: 0.4px;
color: inherit;
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
user-select: none;
svg {
display: block;
width: 1rem;
height: 1rem;
}
&--danger {
background-color: var(--color-danger);
border-color: var(--color-danger);
color: #fff;
}
&--primary {
background-color: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
}
&.theme--dark {
.Dialog__action-button--danger {
color: var(--color-gray-100);
}
.Dialog__action-button--primary {
color: var(--color-gray-100);
}
}
}

View File

@ -0,0 +1,46 @@
import clsx from "clsx";
import { ReactNode } from "react";
import "./DialogActionButton.scss";
import Spinner from "./Spinner";
interface DialogActionButtonProps {
label: string;
children?: ReactNode;
actionType?: "primary" | "danger";
isLoading?: boolean;
}
const DialogActionButton = ({
label,
onClick,
className,
children,
actionType,
type = "button",
isLoading,
...rest
}: DialogActionButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const cs = actionType ? `Dialog__action-button--${actionType}` : "";
return (
<button
className={clsx("Dialog__action-button", cs, className)}
type={type}
aria-label={label}
onClick={onClick}
{...rest}
>
{children && (
<div style={isLoading ? { visibility: "hidden" } : {}}>{children}</div>
)}
<div style={isLoading ? { visibility: "hidden" } : {}}>{label}</div>
{isLoading && (
<div style={{ position: "absolute", inset: 0 }}>
<Spinner />
</div>
)}
</button>
);
};
export default DialogActionButton;

View File

@ -0,0 +1,19 @@
import { t } from "../i18n";
import { shield } from "./icons";
import { Tooltip } from "./Tooltip";
const EncryptedIcon = () => (
<a
className="encrypted-icon tooltip"
href="https://blog.excalidraw.com/end-to-end-encryption/"
target="_blank"
rel="noopener noreferrer"
aria-label={t("encrypted.link")}
>
<Tooltip label={t("encrypted.tooltip")} long={true}>
{shield}
</Tooltip>
</a>
);
export default EncryptedIcon;

View File

@ -91,6 +91,8 @@
} }
button.ExportDialog-imageExportButton { button.ExportDialog-imageExportButton {
border: 0;
width: 5rem; width: 5rem;
height: 5rem; height: 5rem;
margin: 0 0.2em; margin: 0 0.2em;

View File

@ -9,9 +9,10 @@
} }
.FixedSideContainer_side_top { .FixedSideContainer_side_top {
left: var(--space-factor); left: 1rem;
top: var(--space-factor); top: 1rem;
right: var(--space-factor); right: 1rem;
bottom: 1rem;
z-index: 2; z-index: 2;
} }

View File

@ -1,5 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { t } from "../i18n";
import { AppState, ExcalidrawProps } from "../types"; import { AppState, ExcalidrawProps } from "../types";
import { import {
ExitZenModeAction, ExitZenModeAction,
@ -8,20 +9,23 @@ import {
ZoomActions, ZoomActions,
} from "./Actions"; } from "./Actions";
import { useDevice } from "./App"; import { useDevice } from "./App";
import { Island } from "./Island"; import { WelcomeScreenHelpArrow } from "./icons";
import { Section } from "./Section"; import { Section } from "./Section";
import Stack from "./Stack"; import Stack from "./Stack";
import WelcomeScreenDecor from "./WelcomeScreenDecor";
const Footer = ({ const Footer = ({
appState, appState,
actionManager, actionManager,
renderCustomFooter, renderCustomFooter,
showExitZenModeBtn, showExitZenModeBtn,
renderWelcomeScreen,
}: { }: {
appState: AppState; appState: AppState;
actionManager: ActionManager; actionManager: ActionManager;
renderCustomFooter?: ExcalidrawProps["renderFooter"]; renderCustomFooter?: ExcalidrawProps["renderFooter"];
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
renderWelcomeScreen: boolean;
}) => { }) => {
const device = useDevice(); const device = useDevice();
const showFinalize = const showFinalize =
@ -39,31 +43,19 @@ const Footer = ({
> >
<Stack.Col gap={2}> <Stack.Col gap={2}>
<Section heading="canvasActions"> <Section heading="canvasActions">
<Island padding={1}> <ZoomActions
<ZoomActions renderAction={actionManager.renderAction}
renderAction={actionManager.renderAction} zoom={appState.zoom}
zoom={appState.zoom} />
/>
</Island>
{!appState.viewModeEnabled && (
<>
<UndoRedoActions
renderAction={actionManager.renderAction}
className={clsx("zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
})}
/>
<div {!appState.viewModeEnabled && (
className={clsx("eraser-buttons zen-mode-transition", { <UndoRedoActions
"layer-ui__wrapper__footer-left--transition-left": renderAction={actionManager.renderAction}
appState.zenModeEnabled, className={clsx("zen-mode-transition", {
})} "layer-ui__wrapper__footer-left--transition-bottom":
> appState.zenModeEnabled,
{actionManager.renderAction("eraser", { size: "small" })} })}
</div> />
</>
)} )}
{showFinalize && ( {showFinalize && (
<FinalizeAction <FinalizeAction
@ -93,7 +85,18 @@ const Footer = ({
"transition-right disable-pointerEvents": appState.zenModeEnabled, "transition-right disable-pointerEvents": appState.zenModeEnabled,
})} })}
> >
{actionManager.renderAction("toggleShortcuts")} <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")}
</div>
</div> </div>
<ExitZenModeAction <ExitZenModeAction
actionManager={actionManager} actionManager={actionManager}

View File

@ -1,13 +1,13 @@
import { questionCircle } from "../components/icons"; import { HelpIcon } from "./icons";
type HelpIconProps = { type HelpButtonProps = {
title?: string; title?: string;
name?: string; name?: string;
id?: string; id?: string;
onClick?(): void; onClick?(): void;
}; };
export const HelpIcon = (props: HelpIconProps) => ( export const HelpButton = (props: HelpButtonProps) => (
<button <button
className="help-icon" className="help-icon"
onClick={props.onClick} onClick={props.onClick}
@ -15,6 +15,6 @@ export const HelpIcon = (props: HelpIconProps) => (
title={`${props.title} — ?`} title={`${props.title} — ?`}
aria-label={props.title} aria-label={props.title}
> >
{questionCircle} {HelpIcon}
</button> </button>
); );

View File

@ -1,56 +1,115 @@
@import "../css/variables.module"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.HelpDialog h3 { .HelpDialog {
border-bottom: 1px solid var(--button-gray-2); .Modal__content {
padding-bottom: 4px; max-width: 960px;
} }
.HelpDialog--island { h3 {
border: 1px solid var(--button-gray-2); margin: 1.5rem 0;
margin-bottom: 16px; font-weight: bold;
} font-size: 1.125rem;
}
.HelpDialog--island-title { &__header {
margin: 0; display: flex;
padding: 4px; flex-wrap: wrap;
background-color: var(--button-gray-1); gap: 0.75rem;
text-align: center; }
}
.HelpDialog--shortcut { &__btn {
border-top: 1px solid var(--button-gray-2); display: flex;
} column-gap: 0.5rem;
align-items: center;
border: 1px solid var(--default-border-color);
padding: 0.625rem 1rem;
border-radius: var(--border-radius-lg);
color: var(--text-primary-color);
font-weight: 600;
font-size: 0.75rem;
letter-spacing: 0.4px;
.HelpDialog--key { &:hover {
word-break: keep-all; text-decoration: none;
border: 1px solid var(--button-gray-2); }
padding: 2px 8px; }
margin: auto 4px;
background-color: var(--button-gray-1);
border-radius: 2px;
font-size: 0.8em;
min-height: 26px;
box-sizing: border-box;
display: flex;
align-items: center;
font-family: inherit;
}
.HelpDialog--header { &__link-icon {
display: flex; line-height: 0;
flex-direction: row; svg {
justify-content: space-evenly; width: 1rem;
margin-bottom: 32px; height: 1rem;
padding-bottom: 16px; }
} }
.HelpDialog--btn { &__islands-container {
border: 1px solid var(--link-color); display: grid;
padding: 8px 32px; @media screen and (min-width: 1024px) {
border-radius: 4px; grid-template-columns: 1fr 1fr;
} }
.HelpDialog--btn:hover { grid-column-gap: 1.5rem;
text-decoration: none; grid-row-gap: 2rem;
}
@media screen and (min-width: 1024px) {
&__island--tools {
grid-area: 1 / 1 / 2 / 2;
}
&__island--view {
grid-area: 2 / 1 / 3 / 2;
}
&__island--editor {
grid-area: 1 / 2 / 3 / 3;
}
}
&__island {
h4 {
font-size: 1rem;
font-weight: bold;
margin: 0;
margin-bottom: 0.625rem;
}
&-content {
border: 1px solid var(--dialog-border-color);
border-radius: var(--border-radius-lg);
}
}
&__shortcut {
border-bottom: 1px solid var(--dialog-border-color);
padding: 0.375rem 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
column-gap: 0.5rem;
&:last-child {
border-bottom: none;
}
}
&__key-container {
display: flex;
align-items: center;
column-gap: 0.25rem;
flex-shrink: 0;
}
&__key {
display: flex;
box-sizing: border-box;
font-size: 0.625rem;
background-color: var(--color-primary-light);
border-radius: var(--border-radius-md);
padding: 0.5rem;
word-break: keep-all;
align-items: center;
font-family: inherit;
line-height: 1;
}
} }
} }

View File

@ -1,35 +1,39 @@
import React from "react"; import React from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { isDarwin, isWindows } from "../keys"; import { isDarwin, isWindows, KEYS } from "../keys";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import "./HelpDialog.scss"; import "./HelpDialog.scss";
import { ExternalLinkIcon } from "./icons";
const Header = () => ( const Header = () => (
<div className="HelpDialog--header"> <div className="HelpDialog__header">
<a <a
className="HelpDialog--btn" className="HelpDialog__btn"
href="https://github.com/excalidraw/excalidraw#documentation" href="https://github.com/excalidraw/excalidraw#documentation"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{t("helpDialog.documentation")} {t("helpDialog.documentation")}
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
</a> </a>
<a <a
className="HelpDialog--btn" className="HelpDialog__btn"
href="https://blog.excalidraw.com" href="https://blog.excalidraw.com"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{t("helpDialog.blog")} {t("helpDialog.blog")}
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
</a> </a>
<a <a
className="HelpDialog--btn" className="HelpDialog__btn"
href="https://github.com/excalidraw/excalidraw/issues" href="https://github.com/excalidraw/excalidraw/issues"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{t("helpDialog.github")} {t("helpDialog.github")}
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
</a> </a>
</div> </div>
); );
@ -37,88 +41,61 @@ const Header = () => (
const Section = (props: { title: string; children: React.ReactNode }) => ( const Section = (props: { title: string; children: React.ReactNode }) => (
<> <>
<h3>{props.title}</h3> <h3>{props.title}</h3>
{props.children} <div className="HelpDialog__islands-container">{props.children}</div>
</> </>
); );
const Columns = (props: { children: React.ReactNode }) => (
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "space-between",
}}
>
{props.children}
</div>
);
const Column = (props: { children: React.ReactNode }) => (
<div style={{ width: "49%" }}>{props.children}</div>
);
const ShortcutIsland = (props: { const ShortcutIsland = (props: {
caption: string; caption: string;
children: React.ReactNode; children: React.ReactNode;
className?: string;
}) => ( }) => (
<div className="HelpDialog--island"> <div className={`HelpDialog__island ${props.className}`}>
<h3 className="HelpDialog--island-title">{props.caption}</h3> <h4 className="HelpDialog__island-title">{props.caption}</h4>
{props.children} <div className="HelpDialog__island-content">{props.children}</div>
</div> </div>
); );
const Shortcut = (props: { function* intersperse(as: JSX.Element[][], delim: string | null) {
let first = true;
for (const x of as) {
if (!first) {
yield delim;
}
first = false;
yield x;
}
}
const Shortcut = ({
label,
shortcuts,
isOr = true,
}: {
label: string; label: string;
shortcuts: string[]; shortcuts: string[];
isOr: boolean; isOr?: boolean;
}) => { }) => {
const splitShortcutKeys = shortcuts.map((shortcut) => {
const keys = shortcut.endsWith("++")
? [...shortcut.slice(0, -2).split("+"), "+"]
: shortcut.split("+");
return keys.map((key) => <ShortcutKey key={key}>{key}</ShortcutKey>);
});
return ( return (
<div className="HelpDialog--shortcut"> <div className="HelpDialog__shortcut">
<div <div>{label}</div>
style={{ <div className="HelpDialog__key-container">
display: "flex", {[...intersperse(splitShortcutKeys, isOr ? t("helpDialog.or") : null)]}
margin: "0",
padding: "4px 8px",
alignItems: "center",
}}
>
<div
style={{
lineHeight: 1.4,
}}
>
{props.label}
</div>
<div
style={{
display: "flex",
flex: "0 0 auto",
justifyContent: "flex-end",
marginInlineStart: "auto",
minWidth: "30%",
}}
>
{props.shortcuts.map((shortcut, index) => (
<React.Fragment key={index}>
<ShortcutKey>{shortcut}</ShortcutKey>
{props.isOr &&
index !== props.shortcuts.length - 1 &&
t("helpDialog.or")}
</React.Fragment>
))}
</div>
</div> </div>
</div> </div>
); );
}; };
Shortcut.defaultProps = {
isOr: true,
};
const ShortcutKey = (props: { children: React.ReactNode }) => ( const ShortcutKey = (props: { children: React.ReactNode }) => (
<kbd className="HelpDialog--key" {...props} /> <kbd className="HelpDialog__key" {...props} />
); );
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
@ -137,286 +114,296 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
> >
<Header /> <Header />
<Section title={t("helpDialog.shortcuts")}> <Section title={t("helpDialog.shortcuts")}>
<Columns> <ShortcutIsland
<Column> className="HelpDialog__island--tools"
<ShortcutIsland caption={t("helpDialog.tools")}> caption={t("helpDialog.tools")}
<Shortcut >
label={t("toolBar.selection")} <Shortcut
shortcuts={["V", "1"]} label={t("toolBar.selection")}
/> shortcuts={[KEYS.V, KEYS["1"]]}
<Shortcut />
label={t("toolBar.rectangle")} <Shortcut
shortcuts={["R", "2"]} label={t("toolBar.rectangle")}
/> shortcuts={[KEYS.R, KEYS["2"]]}
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} /> />
<Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} /> <Shortcut
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} /> label={t("toolBar.diamond")}
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} /> shortcuts={[KEYS.D, KEYS["3"]]}
<Shortcut />
label={t("toolBar.freedraw")} <Shortcut
shortcuts={["Shift + P", "X", "7"]} label={t("toolBar.ellipse")}
/> shortcuts={[KEYS.O, KEYS["4"]]}
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} /> />
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} /> <Shortcut
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} /> label={t("toolBar.arrow")}
<Shortcut shortcuts={[KEYS.A, KEYS["5"]]}
label={t("toolBar.eraser")} />
shortcuts={[getShortcutKey("E")]} <Shortcut
/> label={t("toolBar.line")}
<Shortcut shortcuts={[KEYS.P, KEYS["6"]]}
label={t("helpDialog.editSelectedShape")} />
shortcuts={[ <Shortcut
getShortcutKey("Enter"), label={t("toolBar.freedraw")}
t("helpDialog.doubleClick"), shortcuts={["Shift + P", KEYS["7"]]}
]} />
/> <Shortcut
<Shortcut label={t("toolBar.text")}
label={t("helpDialog.textNewLine")} shortcuts={[KEYS.T, KEYS["8"]]}
shortcuts={[ />
getShortcutKey("Enter"), <Shortcut label={t("toolBar.image")} shortcuts={[KEYS["9"]]} />
getShortcutKey("Shift+Enter"), <Shortcut
]} label={t("toolBar.eraser")}
/> shortcuts={[KEYS.E, KEYS["0"]]}
<Shortcut />
label={t("helpDialog.textFinish")} <Shortcut
shortcuts={[ label={t("helpDialog.editSelectedShape")}
getShortcutKey("Esc"), shortcuts={[getShortcutKey("Enter"), t("helpDialog.doubleClick")]}
getShortcutKey("CtrlOrCmd+Enter"), />
]} <Shortcut
/> label={t("helpDialog.textNewLine")}
<Shortcut shortcuts={[
label={t("helpDialog.curvedArrow")} getShortcutKey("Enter"),
shortcuts={[ getShortcutKey("Shift+Enter"),
"A", ]}
t("helpDialog.click"), />
t("helpDialog.click"), <Shortcut
t("helpDialog.click"), label={t("helpDialog.textFinish")}
]} shortcuts={[
isOr={false} getShortcutKey("Esc"),
/> getShortcutKey("CtrlOrCmd+Enter"),
<Shortcut ]}
label={t("helpDialog.curvedLine")} />
shortcuts={[ <Shortcut
"L", label={t("helpDialog.curvedArrow")}
t("helpDialog.click"), shortcuts={[
t("helpDialog.click"), "A",
t("helpDialog.click"), t("helpDialog.click"),
]} t("helpDialog.click"),
isOr={false} t("helpDialog.click"),
/> ]}
<Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} /> isOr={false}
<Shortcut />
label={t("helpDialog.preventBinding")} <Shortcut
shortcuts={[getShortcutKey("CtrlOrCmd")]} label={t("helpDialog.curvedLine")}
/> shortcuts={[
<Shortcut "L",
label={t("toolBar.link")} t("helpDialog.click"),
shortcuts={[getShortcutKey("CtrlOrCmd+K")]} t("helpDialog.click"),
/> t("helpDialog.click"),
</ShortcutIsland> ]}
<ShortcutIsland caption={t("helpDialog.view")}> isOr={false}
<Shortcut />
label={t("buttons.zoomIn")} <Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
shortcuts={[getShortcutKey("CtrlOrCmd++")]} <Shortcut
/> label={t("helpDialog.preventBinding")}
<Shortcut shortcuts={[getShortcutKey("CtrlOrCmd")]}
label={t("buttons.zoomOut")} />
shortcuts={[getShortcutKey("CtrlOrCmd+-")]} <Shortcut
/> label={t("toolBar.link")}
<Shortcut shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
label={t("buttons.resetZoom")} />
shortcuts={[getShortcutKey("CtrlOrCmd+0")]} </ShortcutIsland>
/> <ShortcutIsland
<Shortcut className="HelpDialog__island--view"
label={t("helpDialog.zoomToFit")} caption={t("helpDialog.view")}
shortcuts={["Shift+1"]} >
/> <Shortcut
<Shortcut label={t("buttons.zoomIn")}
label={t("helpDialog.zoomToSelection")} shortcuts={[getShortcutKey("CtrlOrCmd++")]}
shortcuts={["Shift+2"]} />
/> <Shortcut
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} /> label={t("buttons.zoomOut")}
<Shortcut shortcuts={[getShortcutKey("CtrlOrCmd+-")]}
label={t("buttons.zenMode")} />
shortcuts={[getShortcutKey("Alt+Z")]} <Shortcut
/> label={t("buttons.resetZoom")}
<Shortcut shortcuts={[getShortcutKey("CtrlOrCmd+0")]}
label={t("labels.showGrid")} />
shortcuts={[getShortcutKey("CtrlOrCmd+'")]} <Shortcut
/> label={t("helpDialog.zoomToFit")}
<Shortcut shortcuts={["Shift+1"]}
label={t("labels.viewMode")} />
shortcuts={[getShortcutKey("Alt+R")]} <Shortcut
/> label={t("helpDialog.zoomToSelection")}
<Shortcut shortcuts={["Shift+2"]}
label={t("labels.toggleTheme")} />
shortcuts={[getShortcutKey("Alt+Shift+D")]} <Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
/> <Shortcut
<Shortcut label={t("buttons.zenMode")}
label={t("stats.title")} shortcuts={[getShortcutKey("Alt+Z")]}
shortcuts={[getShortcutKey("Alt+/")]} />
/> <Shortcut
</ShortcutIsland> label={t("labels.showGrid")}
</Column> shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
<Column> />
<ShortcutIsland caption={t("helpDialog.editor")}> <Shortcut
<Shortcut label={t("labels.viewMode")}
label={t("labels.selectAll")} shortcuts={[getShortcutKey("Alt+R")]}
shortcuts={[getShortcutKey("CtrlOrCmd+A")]} />
/> <Shortcut
<Shortcut label={t("labels.toggleTheme")}
label={t("labels.multiSelect")} shortcuts={[getShortcutKey("Alt+Shift+D")]}
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]} />
/> <Shortcut
<Shortcut label={t("stats.title")}
label={t("helpDialog.deepSelect")} shortcuts={[getShortcutKey("Alt+/")]}
shortcuts={[ />
getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`), </ShortcutIsland>
]} <ShortcutIsland
/> className="HelpDialog__island--editor"
<Shortcut caption={t("helpDialog.editor")}
label={t("helpDialog.deepBoxSelect")} >
shortcuts={[ <Shortcut
getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`), label={t("labels.selectAll")}
]} shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
/> />
<Shortcut <Shortcut
label={t("labels.moveCanvas")} label={t("labels.multiSelect")}
shortcuts={[ shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
getShortcutKey(`Space+${t("helpDialog.drag")}`), />
getShortcutKey(`Wheel+${t("helpDialog.drag")}`), <Shortcut
]} label={t("helpDialog.deepSelect")}
isOr={true} shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`)]}
/> />
<Shortcut <Shortcut
label={t("labels.cut")} label={t("helpDialog.deepBoxSelect")}
shortcuts={[getShortcutKey("CtrlOrCmd+X")]} shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`)]}
/> />
<Shortcut <Shortcut
label={t("labels.copy")} label={t("labels.moveCanvas")}
shortcuts={[getShortcutKey("CtrlOrCmd+C")]} shortcuts={[
/> getShortcutKey(`Space+${t("helpDialog.drag")}`),
<Shortcut getShortcutKey(`Wheel+${t("helpDialog.drag")}`),
label={t("labels.paste")} ]}
shortcuts={[getShortcutKey("CtrlOrCmd+V")]} isOr={true}
/> />
<Shortcut <Shortcut
label={t("labels.copyAsPng")} label={t("labels.cut")}
shortcuts={[getShortcutKey("Shift+Alt+C")]} shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
/> />
<Shortcut <Shortcut
label={t("labels.copyStyles")} label={t("labels.copy")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]} shortcuts={[getShortcutKey("CtrlOrCmd+C")]}
/> />
<Shortcut <Shortcut
label={t("labels.pasteStyles")} label={t("labels.paste")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]} shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
/> />
<Shortcut <Shortcut
label={t("labels.delete")} label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("Del")]} shortcuts={[getShortcutKey("Shift+Alt+C")]}
/> />
<Shortcut <Shortcut
label={t("labels.sendToBack")} label={t("labels.copyStyles")}
shortcuts={[ shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
isDarwin />
? getShortcutKey("CtrlOrCmd+Alt+[") <Shortcut
: getShortcutKey("CtrlOrCmd+Shift+["), label={t("labels.pasteStyles")}
]} shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
/> />
<Shortcut <Shortcut
label={t("labels.bringToFront")} label={t("labels.delete")}
shortcuts={[ shortcuts={[getShortcutKey("Del")]}
isDarwin />
? getShortcutKey("CtrlOrCmd+Alt+]") <Shortcut
: getShortcutKey("CtrlOrCmd+Shift+]"), label={t("labels.sendToBack")}
]} shortcuts={[
/> isDarwin
<Shortcut ? getShortcutKey("CtrlOrCmd+Alt+[")
label={t("labels.sendBackward")} : getShortcutKey("CtrlOrCmd+Shift+["),
shortcuts={[getShortcutKey("CtrlOrCmd+[")]} ]}
/> />
<Shortcut <Shortcut
label={t("labels.bringForward")} label={t("labels.bringToFront")}
shortcuts={[getShortcutKey("CtrlOrCmd+]")]} shortcuts={[
/> isDarwin
<Shortcut ? getShortcutKey("CtrlOrCmd+Alt+]")
label={t("labels.alignTop")} : getShortcutKey("CtrlOrCmd+Shift+]"),
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]} ]}
/> />
<Shortcut <Shortcut
label={t("labels.alignBottom")} label={t("labels.sendBackward")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]} shortcuts={[getShortcutKey("CtrlOrCmd+[")]}
/> />
<Shortcut <Shortcut
label={t("labels.alignLeft")} label={t("labels.bringForward")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]} shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
/> />
<Shortcut <Shortcut
label={t("labels.alignRight")} label={t("labels.alignTop")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]} shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
/> />
<Shortcut <Shortcut
label={t("labels.duplicateSelection")} label={t("labels.alignBottom")}
shortcuts={[ shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
getShortcutKey("CtrlOrCmd+D"), />
getShortcutKey(`Alt+${t("helpDialog.drag")}`), <Shortcut
]} label={t("labels.alignLeft")}
/> shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
<Shortcut />
label={t("helpDialog.toggleElementLock")} <Shortcut
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]} label={t("labels.alignRight")}
/> shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
<Shortcut />
label={t("buttons.undo")} <Shortcut
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]} label={t("labels.duplicateSelection")}
/> shortcuts={[
<Shortcut getShortcutKey("CtrlOrCmd+D"),
label={t("buttons.redo")} getShortcutKey(`Alt+${t("helpDialog.drag")}`),
shortcuts={ ]}
isWindows />
? [ <Shortcut
getShortcutKey("CtrlOrCmd+Y"), label={t("helpDialog.toggleElementLock")}
getShortcutKey("CtrlOrCmd+Shift+Z"), shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
] />
: [getShortcutKey("CtrlOrCmd+Shift+Z")] <Shortcut
} label={t("buttons.undo")}
/> shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
<Shortcut />
label={t("labels.group")} <Shortcut
shortcuts={[getShortcutKey("CtrlOrCmd+G")]} label={t("buttons.redo")}
/> shortcuts={
<Shortcut isWindows
label={t("labels.ungroup")} ? [
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]} getShortcutKey("CtrlOrCmd+Y"),
/> getShortcutKey("CtrlOrCmd+Shift+Z"),
<Shortcut ]
label={t("labels.flipHorizontal")} : [getShortcutKey("CtrlOrCmd+Shift+Z")]
shortcuts={[getShortcutKey("Shift+H")]} }
/> />
<Shortcut <Shortcut
label={t("labels.flipVertical")} label={t("labels.group")}
shortcuts={[getShortcutKey("Shift+V")]} shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
/> />
<Shortcut <Shortcut
label={t("labels.showStroke")} label={t("labels.ungroup")}
shortcuts={[getShortcutKey("S")]} shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
/> />
<Shortcut <Shortcut
label={t("labels.showBackground")} label={t("labels.flipHorizontal")}
shortcuts={[getShortcutKey("G")]} shortcuts={[getShortcutKey("Shift+H")]}
/> />
<Shortcut <Shortcut
label={t("labels.decreaseFontSize")} label={t("labels.flipVertical")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]} shortcuts={[getShortcutKey("Shift+V")]}
/> />
<Shortcut <Shortcut
label={t("labels.increaseFontSize")} label={t("labels.showStroke")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]} shortcuts={[getShortcutKey("S")]}
/> />
</ShortcutIsland> <Shortcut
</Column> label={t("labels.showBackground")}
</Columns> shortcuts={[getShortcutKey("G")]}
/>
<Shortcut
label={t("labels.decreaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
/>
<Shortcut
label={t("labels.increaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]}
/>
</ShortcutIsland>
</Section> </Section>
</Dialog> </Dialog>
</> </>

View File

@ -14,20 +14,24 @@ $wide-viewport-width: 1000px;
top: 100%; top: 100%;
max-width: 100%; max-width: 100%;
width: 100%; width: 100%;
margin-top: 6px; margin-top: 0.5rem;
text-align: center; text-align: center;
color: $oc-gray-6; color: var(--color-gray-40);
font-size: 0.8rem; font-size: 0.75rem;
@include isMobile { @include isMobile {
position: static; position: static;
padding-right: 2em; padding-right: 2rem;
} }
> span { > span {
padding: 0.2rem 0.4rem; padding: 0.25rem;
background-color: var(--overlay-bg-color); }
border-radius: 4px; }
&.theme--dark {
.HintViewer {
color: var(--color-gray-60);
} }
} }
} }

View File

@ -10,7 +10,8 @@
.picker { .picker {
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75); border: 0 solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0 1px 4px; // ˇˇ yeah, i dunno, open to suggestions here :D
box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px;
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
} }
@ -46,7 +47,6 @@
margin: 0; margin: 0;
width: 36px; width: 36px;
height: 18px; height: 18px;
opacity: 0.6;
pointer-events: none; pointer-events: none;
} }
} }

View File

@ -4,6 +4,7 @@ import { Popover } from "./Popover";
import "./IconPicker.scss"; import "./IconPicker.scss";
import { isArrowKey, KEYS } from "../keys"; import { isArrowKey, KEYS } from "../keys";
import { getLanguage } from "../i18n"; import { getLanguage } from "../i18n";
import clsx from "clsx";
function Picker<T>({ function Picker<T>({
options, options,
@ -102,7 +103,9 @@ function Picker<T>({
<div className="picker-content" ref={rGallery}> <div className="picker-content" ref={rGallery}>
{options.map((option, i) => ( {options.map((option, i) => (
<button <button
className="picker-option" className={clsx("picker-option", {
active: value === option.value,
})}
onClick={(event) => { onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus(); (event.currentTarget as HTMLButtonElement).focus();
onChange(option.value); onChange(option.value);
@ -150,7 +153,7 @@ export function IconPicker<T>({
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
return ( return (
<label className={"picker-container"}> <div>
<button <button
name={group} name={group}
className={isActive ? "active" : ""} className={isActive ? "active" : ""}
@ -184,6 +187,6 @@ export function IconPicker<T>({
</> </>
) : null} ) : null}
</React.Suspense> </React.Suspense>
</label> </div>
); );
} }

View File

@ -5,14 +5,12 @@ import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "./App";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export"; import { exportToCanvas } from "../scene/export";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { clipboard, exportImage } from "./icons"; import { clipboard } from "./icons";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import "./ExportDialog.scss"; import "./ExportDialog.scss";
import OpenColor from "open-color"; import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
@ -221,6 +219,7 @@ const ImageExportModal = ({
export const ImageExportDialog = ({ export const ImageExportDialog = ({
elements, elements,
appState, appState,
setAppState,
files, files,
exportPadding = DEFAULT_EXPORT_PADDING, exportPadding = DEFAULT_EXPORT_PADDING,
actionManager, actionManager,
@ -229,6 +228,7 @@ export const ImageExportDialog = ({
onExportToClipboard, onExportToClipboard,
}: { }: {
appState: AppState; appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number; exportPadding?: number;
@ -237,26 +237,13 @@ export const ImageExportDialog = ({
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
onExportToClipboard: ExportCB; onExportToClipboard: ExportCB;
}) => { }) => {
const [modalIsShown, setModalIsShown] = useState(false);
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
setModalIsShown(false); setAppState({ openDialog: null });
}, []); }, [setAppState]);
return ( return (
<> <>
<ToolButton {appState.openDialog === "imageExport" && (
onClick={() => {
setModalIsShown(true);
}}
data-testid="image-export-button"
icon={exportImage}
type="button"
aria-label={t("buttons.exportImage")}
showAriaLabel={useDevice().isMobile}
title={t("buttons.exportImage")}
/>
{modalIsShown && (
<Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}> <Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
<ImageExportModal <ImageExportModal
elements={elements} elements={elements}

View File

@ -1,6 +1,7 @@
.excalidraw { .excalidraw {
.Island { .Island {
--padding: 0; --padding: 0;
box-sizing: border-box;
background-color: var(--island-bg-color); background-color: var(--island-bg-color);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);

View File

@ -1,10 +1,10 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "./App";
import { AppState, ExportOpts, BinaryFiles } from "../types"; import { AppState, ExportOpts, BinaryFiles } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons"; import { ExportIcon, exportToFileIcon, LinkIcon } from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { actionSaveFileToDisk } from "../actions/actionExport"; import { actionSaveFileToDisk } from "../actions/actionExport";
import { Card } from "./Card"; import { Card } from "./Card";
@ -14,6 +14,7 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { getFrame } from "../utils"; import { getFrame } from "../utils";
import MenuItem from "./MenuItem";
export type ExportCB = ( export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@ -63,7 +64,7 @@ const JSONExportModal = ({
)} )}
{onExportToBackend && ( {onExportToBackend && (
<Card color="pink"> <Card color="pink">
<div className="Card-icon">{link}</div> <div className="Card-icon">{LinkIcon}</div>
<h2>{t("exportDialog.link_title")}</h2> <h2>{t("exportDialog.link_title")}</h2>
<div className="Card-details">{t("exportDialog.link_details")}</div> <div className="Card-details">{t("exportDialog.link_details")}</div>
<ToolButton <ToolButton
@ -109,16 +110,13 @@ export const JSONExportDialog = ({
return ( return (
<> <>
<ToolButton <MenuItem
icon={ExportIcon}
label={t("buttons.export")}
onClick={() => { onClick={() => {
setModalIsShown(true); setModalIsShown(true);
}} }}
data-testid="json-export-button" dataTestId="json-export-button"
icon={exportFile}
type="button"
aria-label={t("buttons.export")}
showAriaLabel={useDevice().isMobile}
title={t("buttons.export")}
/> />
{modalIsShown && ( {modalIsShown && (
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}> <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>

View File

@ -16,8 +16,10 @@
height: 100%; height: 100%;
pointer-events: none; pointer-events: none;
z-index: var(--zIndex-layerUI); z-index: var(--zIndex-layerUI);
&__top-right { &__top-right {
display: flex; display: flex;
gap: 0.75rem;
} }
&__footer { &__footer {
@ -48,13 +50,6 @@
transform: translate(-999px, 0); transform: translate(-999px, 0);
} }
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
transform: translate(-76px, 0);
}
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
transform: translate(76px, 0);
}
&.layer-ui__wrapper__footer-left--transition-bottom { &.layer-ui__wrapper__footer-left--transition-bottom {
transform: translate(0, 92px); transform: translate(0, 92px);
} }
@ -97,14 +92,9 @@
pointer-events: all; pointer-events: all;
} }
.layer-ui__wrapper__footer-left {
margin-bottom: 0.2em;
}
.layer-ui__wrapper__footer-right { .layer-ui__wrapper__footer-right {
margin-top: auto; margin-top: auto;
margin-bottom: auto; margin-bottom: auto;
margin-inline-end: 1em;
} }
} }
} }

View File

@ -11,7 +11,6 @@ import { ExportType } from "../scene/types";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import CollabButton from "./CollabButton"; import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog"; import { ErrorDialog } from "./ErrorDialog";
import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
@ -36,13 +35,26 @@ import "./LayerUI.scss";
import "./Toolbar.scss"; import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { useDevice } from "../components/App"; import { isMenuOpenAtom, useDevice } from "../components/App";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats"; import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./Footer"; import Footer from "./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 { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
import { useAtom } 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";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@ -68,6 +80,7 @@ interface LayerUIProps {
library: Library; library: Library;
id: string; id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderWelcomeScreen: boolean;
} }
const LayerUI = ({ const LayerUI = ({
actionManager, actionManager,
@ -92,6 +105,7 @@ const LayerUI = ({
library, library,
id, id,
onImageAction, onImageAction,
renderWelcomeScreen,
}: LayerUIProps) => { }: LayerUIProps) => {
const device = useDevice(); const device = useDevice();
@ -151,6 +165,7 @@ const LayerUI = ({
<ImageExportDialog <ImageExportDialog
elements={elements} elements={elements}
appState={appState} appState={appState}
setAppState={setAppState}
files={files} files={files}
actionManager={actionManager} actionManager={actionManager}
onExportToPng={createExporter("png")} onExportToPng={createExporter("png")}
@ -160,71 +175,107 @@ const LayerUI = ({
); );
}; };
const Separator = () => { const [isMenuOpen, setIsMenuOpen] = useAtom(isMenuOpenAtom);
return <div style={{ width: ".625em" }} />; const menuRef = useOutsideClickHook(() => setIsMenuOpen(false));
};
const renderViewModeCanvasActions = () => {
return (
<Section
heading="canvasActions"
className={clsx("zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
})}
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
<Island padding={2} style={{ zIndex: 1 }}>
<Stack.Col gap={4}>
<Stack.Row gap={1} justifyContent="space-between">
{renderJSONExportDialog()}
{renderImageExportDialog()}
</Stack.Row>
</Stack.Col>
</Island>
</Section>
);
};
const renderCanvasActions = () => ( const renderCanvasActions = () => (
<Section <div style={{ position: "relative" }}>
heading="canvasActions" <WelcomeScreenDecor
className={clsx("zen-mode-transition", { shouldRender={renderWelcomeScreen && !appState.isLoading}
"transition-left": appState.zenModeEnabled, >
})} <div className="virgil WelcomeScreen-decor WelcomeScreen-decor--menu-pointer">
> {WelcomeScreenMenuArrow}
{/* the zIndex ensures this menu has higher stacking order, <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 */} see https://github.com/excalidraw/excalidraw/pull/1445 */}
<Island padding={2} style={{ zIndex: 1 }}> <Island
<Stack.Col gap={4}> className="menu-container"
<Stack.Row gap={1} justifyContent="space-between"> padding={2}
{actionManager.renderAction("clearCanvas")} style={{ zIndex: 1 }}
<Separator /> >
{actionManager.renderAction("loadScene")} {!appState.viewModeEnabled &&
{renderJSONExportDialog()} actionManager.renderAction("loadScene")}
{renderImageExportDialog()} {/* // TODO barnabasmolnar/editor-redesign */}
<Separator /> {/* is this fine here? */}
{onCollabButtonClick && ( {appState.fileHandle &&
<CollabButton actionManager.renderAction("saveToActiveFile")}
isCollaborating={isCollaborating} {renderJSONExportDialog()}
collaboratorCount={appState.collaborators.size} {UIOptions.canvasActions.saveAsImage && (
onClick={onCollabButtonClick} <MenuItem
/> label={t("buttons.exportImage")}
)} icon={ExportImageIcon}
</Stack.Row> dataTestId="image-export-button"
<BackgroundPickerAndDarkModeToggle actionManager={actionManager} /> onClick={() => setAppState({ openDialog: "imageExport" })}
{appState.fileHandle && ( shortcut={getShortcutFromShortcutName("imageExport")}
<>{actionManager.renderAction("saveToActiveFile")}</> />
)} )}
</Stack.Col> {onCollabButtonClick && (
</Island> <CollabButton
</Section> 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>
)}
</div>
); );
const renderSelectedShapeActions = () => ( const renderSelectedShapeActions = () => (
<Section <Section
heading="selectedShapeActions" heading="selectedShapeActions"
className={clsx("zen-mode-transition", { className={clsx("selected-shape-actions zen-mode-transition", {
"transition-left": appState.zenModeEnabled, "transition-left": appState.zenModeEnabled,
})} })}
> >
@ -232,10 +283,9 @@ const LayerUI = ({
className={CLASSES.SHAPE_ACTIONS_MENU} className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2} padding={2}
style={{ style={{
// we want to make sure this doesn't overflow so subtracting 200 // we want to make sure this doesn't overflow so subtracting the
// which is approximately height of zoom footer and top left menu items with some buffer // approximate height of hamburgerMenu + footer
// if active file name is displayed, subtracting 248 to account for its height maxHeight: `${appState.height - 166}px`,
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
}} }}
> >
<SelectedShapeActions <SelectedShapeActions
@ -255,74 +305,89 @@ const LayerUI = ({
return ( return (
<FixedSideContainer side="top"> <FixedSideContainer side="top">
{renderWelcomeScreen && !appState.isLoading && (
<WelcomeScreen appState={appState} actionManager={actionManager} />
)}
<div className="App-menu App-menu_top"> <div className="App-menu App-menu_top">
<Stack.Col <Stack.Col
gap={4} gap={6}
className={clsx({ className={clsx("App-menu_top__left", {
"disable-pointerEvents": appState.zenModeEnabled, "disable-pointerEvents": appState.zenModeEnabled,
})} })}
> >
{appState.viewModeEnabled {renderCanvasActions()}
? renderViewModeCanvasActions()
: renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col> </Stack.Col>
{!appState.viewModeEnabled && ( {!appState.viewModeEnabled && (
<Section heading="shapes"> <Section heading="shapes" className="shapes-section">
{(heading: React.ReactNode) => ( {(heading: React.ReactNode) => (
<Stack.Col gap={4} align="start"> <div style={{ position: "relative" }}>
<Stack.Row <WelcomeScreenDecor
gap={1} shouldRender={renderWelcomeScreen && !appState.isLoading}
className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled,
})}
> >
<PenModeButton <div className="virgil WelcomeScreen-decor WelcomeScreen-decor--top-toolbar-pointer">
zenModeEnabled={appState.zenModeEnabled} <div className="WelcomeScreen-decor--top-toolbar-pointer__label">
checked={appState.penMode} {t("welcomeScreen.toolbarHints")}
onChange={onPenModeToggle} </div>
title={t("toolBar.penMode")} {WelcomeScreenTopToolbarArrow}
penDetected={appState.penDetected} </div>
/> </WelcomeScreenDecor>
<LockButton
zenModeEnabled={appState.zenModeEnabled} <Stack.Col gap={4} align="start">
checked={appState.activeTool.locked} <Stack.Row
onChange={() => onLockToggle()} gap={1}
title={t("toolBar.lock")} className={clsx("App-toolbar-container", {
/>
<Island
padding={1}
className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled, "zen-mode": appState.zenModeEnabled,
})} })}
> >
<HintViewer <Island
appState={appState} padding={1}
elements={elements} className={clsx("App-toolbar", {
isMobile={device.isMobile} "zen-mode": appState.zenModeEnabled,
device={device} })}
/> >
{heading} <HintViewer
<Stack.Row gap={1}>
<ShapesSwitcher
appState={appState} appState={appState}
canvas={canvas} elements={elements}
activeTool={appState.activeTool} isMobile={device.isMobile}
setAppState={setAppState} device={device}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
/> />
</Stack.Row> {heading}
</Island> <Stack.Row gap={1}>
<LibraryButton <PenModeButton
appState={appState} zenModeEnabled={appState.zenModeEnabled}
setAppState={setAppState} checked={appState.penMode}
/> onChange={onPenModeToggle}
</Stack.Row> title={t("toolBar.penMode")}
</Stack.Col> penDetected={appState.penDetected}
/>
<LockButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.activeTool.locked}
onChange={() => onLockToggle()}
title={t("toolBar.lock")}
/>
<div className="App-toolbar__divider"></div>
<ShapesSwitcher
appState={appState}
canvas={canvas}
activeTool={appState.activeTool}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
/>
{/* {actionManager.renderAction("eraser", {
// size: "small",
})} */}
</Stack.Row>
</Island>
</Stack.Row>
</Stack.Col>
</div>
)} )}
</Section> </Section>
)} )}
@ -338,7 +403,19 @@ const LayerUI = ({
collaborators={appState.collaborators} collaborators={appState.collaborators}
actionManager={actionManager} actionManager={actionManager}
/> />
{renderTopRightUI?.(device.isMobile, appState)} {onCollabButtonClick && (
<CollabButton
isInHamburgerMenu={false}
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{!appState.viewModeEnabled &&
renderTopRightUI?.(device.isMobile, appState)}
{!appState.viewModeEnabled && (
<LibraryButton appState={appState} setAppState={setAppState} />
)}
</div> </div>
</div> </div>
</FixedSideContainer> </FixedSideContainer>
@ -371,13 +448,14 @@ const LayerUI = ({
onClose={() => setAppState({ errorMessage: null })} onClose={() => setAppState({ errorMessage: null })}
/> />
)} )}
{appState.showHelpDialog && ( {appState.openDialog === "help" && (
<HelpDialog <HelpDialog
onClose={() => { onClose={() => {
setAppState({ showHelpDialog: false }); setAppState({ openDialog: null });
}} }}
/> />
)} )}
{renderImageExportDialog()}
{appState.pasteDialog.shown && ( {appState.pasteDialog.shown && (
<PasteChartDialog <PasteChartDialog
setAppState={setAppState} setAppState={setAppState}
@ -392,6 +470,7 @@ const LayerUI = ({
)} )}
{device.isMobile && ( {device.isMobile && (
<MobileMenu <MobileMenu
renderWelcomeScreen={renderWelcomeScreen}
appState={appState} appState={appState}
elements={elements} elements={elements}
actionManager={actionManager} actionManager={actionManager}
@ -433,6 +512,7 @@ const LayerUI = ({
> >
{renderFixedSideContainer()} {renderFixedSideContainer()}
<Footer <Footer
renderWelcomeScreen={renderWelcomeScreen}
appState={appState} appState={appState}
actionManager={actionManager} actionManager={actionManager}
renderCustomFooter={renderCustomFooter} renderCustomFooter={renderCustomFooter}

View File

@ -0,0 +1,32 @@
@import "../css/variables.module";
.library-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: auto;
height: var(--lg-button-size);
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 0;
font-size: 0.75rem;
letter-spacing: 0.4px;
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
&__label {
display: none;
@media screen and (min-width: 1024px) {
display: block;
}
}
}

View File

@ -1,19 +1,11 @@
import React from "react"; import React from "react";
import clsx from "clsx";
import { t } from "../i18n"; import { t } from "../i18n";
import { AppState } from "../types"; import { AppState } from "../types";
import { capitalizeString } from "../utils"; import { capitalizeString } from "../utils";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { useDevice } from "./App"; import { useDevice } from "./App";
import "./LibraryButton.scss";
const LIBRARY_ICON = ( import { LibraryIcon } from "./icons";
<svg viewBox="0 0 576 512">
<path
fill="currentColor"
d="M542.22 32.05c-54.8 3.11-163.72 14.43-230.96 55.59-4.64 2.84-7.27 7.89-7.27 13.17v363.87c0 11.55 12.63 18.85 23.28 13.49 69.18-34.82 169.23-44.32 218.7-46.92 16.89-.89 30.02-14.43 30.02-30.66V62.75c.01-17.71-15.35-31.74-33.77-30.7zM264.73 87.64C197.5 46.48 88.58 35.17 33.78 32.05 15.36 31.01 0 45.04 0 62.75V400.6c0 16.24 13.13 29.78 30.02 30.66 49.49 2.6 149.59 12.11 218.77 46.95 10.62 5.35 23.21-1.94 23.21-13.46V100.63c0-5.29-2.62-10.14-7.27-12.99z"
></path>
</svg>
);
export const LibraryButton: React.FC<{ export const LibraryButton: React.FC<{
appState: AppState; appState: AppState;
@ -21,17 +13,16 @@ export const LibraryButton: React.FC<{
isMobile?: boolean; isMobile?: boolean;
}> = ({ appState, setAppState, isMobile }) => { }> = ({ appState, setAppState, isMobile }) => {
const device = useDevice(); const device = useDevice();
const showLabel = !isMobile;
// TODO barnabasmolnar/redesign
// not great, toolbar jumps in a jarring manner
if (appState.isSidebarDocked && appState.openSidebar === "library") {
return null;
}
return ( return (
<label <label title={`${capitalizeString(t("toolBar.library"))}`}>
className={clsx(
"ToolIcon ToolIcon_type_floating ToolIcon__library",
`ToolIcon_size_medium`,
{
"is-mobile": isMobile,
},
)}
title={`${capitalizeString(t("toolBar.library"))} — 0`}
>
<input <input
className="ToolIcon_type_checkbox" className="ToolIcon_type_checkbox"
type="checkbox" type="checkbox"
@ -55,7 +46,12 @@ export const LibraryButton: React.FC<{
aria-label={capitalizeString(t("toolBar.library"))} aria-label={capitalizeString(t("toolBar.library"))}
aria-keyshortcuts="0" aria-keyshortcuts="0"
/> />
<div className="ToolIcon__icon">{LIBRARY_ICON}</div> <div className="library-button">
<div>{LibraryIcon}</div>
{showLabel && (
<div className="library-button__label">{t("toolBar.library")}</div>
)}
</div>
</label> </label>
); );
}; };

View File

@ -35,103 +35,32 @@
} }
} }
.library-actions { .library-actions-counter {
width: 100%; background-color: var(--color-primary);
color: var(--color-primary-light);
font-weight: bold;
display: flex; display: flex;
margin-right: auto;
align-items: center; align-items: center;
justify-content: center;
button .library-actions-counter { border-radius: 50%;
position: absolute; width: 1rem;
right: 2px; height: 1rem;
bottom: 2px; position: absolute;
border-radius: 50%; bottom: -0.25rem;
width: 1em; right: -0.25rem;
height: 1em; font-size: 0.625rem;
padding: 1px; pointer-events: none;
font-size: 0.7rem;
background: #fff;
}
&--remove {
background-color: $oc-red-7;
&:hover {
background-color: $oc-red-8;
}
&:active {
background-color: $oc-red-9;
}
svg {
color: $oc-white;
}
.library-actions-counter {
color: $oc-red-7;
}
}
&--export {
background-color: $oc-lime-5;
&:hover {
background-color: $oc-lime-7;
}
&:active {
background-color: $oc-lime-8;
}
svg {
color: $oc-white;
}
.library-actions-counter {
color: $oc-lime-5;
}
}
&--publish {
background-color: $oc-cyan-6;
&:hover {
background-color: $oc-cyan-7;
}
&:active {
background-color: $oc-cyan-9;
}
svg {
color: $oc-white;
}
label {
margin-left: -0.2em;
margin-right: 1.1em;
color: $oc-white;
font-size: 0.86em;
}
.library-actions-counter {
color: $oc-cyan-6;
}
}
&--load {
background-color: $oc-blue-6;
&:hover {
background-color: $oc-blue-7;
}
&:active {
background-color: $oc-blue-9;
}
svg {
color: $oc-white;
}
}
} }
.layer-ui__library-message { .layer-ui__library-message {
padding: 2em 4em; padding: 2rem;
min-width: 200px; min-width: 200px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
.Spinner { flex-grow: 1;
margin-bottom: 1em; justify-content: center;
}
span { span {
font-size: 0.8em; font-size: 0.8em;
} }
@ -159,11 +88,10 @@
} }
.library-menu-browse-button { .library-menu-browse-button {
width: 80%; margin: 1rem auto;
min-height: 22px;
margin: 0 auto; padding: 0.875rem 1rem;
margin-top: 1rem;
padding: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -176,6 +104,10 @@
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
text-decoration: none !important; text-decoration: none !important;
font-weight: 600;
font-size: 0.75rem;
&:hover { &:hover {
background-color: var(--color-primary-darker); background-color: var(--color-primary-darker);
} }
@ -184,6 +116,12 @@
} }
} }
&.theme--dark {
.library-menu-browse-button {
color: var(--color-gray-100);
}
}
.library-menu-browse-button--mobile { .library-menu-browse-button--mobile {
min-height: 22px; min-height: 22px;
margin-left: auto; margin-left: auto;

View File

@ -16,7 +16,7 @@ import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types";
import "./LibraryMenu.scss"; import "./LibraryMenu.scss";
import LibraryMenuItems from "./LibraryMenuItems"; import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT, VERSIONS } from "../constants"; import { EVENT } from "../constants";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@ -31,6 +31,7 @@ import { Sidebar } from "./Sidebar/Sidebar";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { LibraryMenuHeader } from "./LibraryMenuHeaderContent"; import { LibraryMenuHeader } from "./LibraryMenuHeaderContent";
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
const useOnClickOutside = ( const useOnClickOutside = (
ref: RefObject<HTMLElement>, ref: RefObject<HTMLElement>,
@ -94,9 +95,6 @@ export const LibraryMenuContent = ({
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void; onSelectItems: (id: LibraryItem["id"][]) => void;
}) => { }) => {
const referrer =
libraryReturnUrl || window.location.origin + window.location.pathname;
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const addToLibrary = useCallback( const addToLibrary = useCallback(
@ -131,13 +129,18 @@ export const LibraryMenuContent = ({
return ( return (
<LibraryMenuWrapper> <LibraryMenuWrapper>
<div className="layer-ui__library-message"> <div className="layer-ui__library-message">
<Spinner size="2em" /> <div>
<span>{t("labels.libraryLoadingMessage")}</span> <Spinner size="2em" />
<span>{t("labels.libraryLoadingMessage")}</span>
</div>
</div> </div>
</LibraryMenuWrapper> </LibraryMenuWrapper>
); );
} }
const showBtn =
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
return ( return (
<LibraryMenuWrapper> <LibraryMenuWrapper>
<LibraryMenuItems <LibraryMenuItems
@ -150,18 +153,17 @@ export const LibraryMenuContent = ({
pendingElements={pendingElements} pendingElements={pendingElements}
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={onSelectItems} onSelectItems={onSelectItems}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={appState.theme}
/> />
<a {showBtn && (
className="library-menu-browse-button" <LibraryMenuBrowseButton
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${ id={id}
window.name || "_blank" libraryReturnUrl={libraryReturnUrl}
}&referrer=${referrer}&useHash=true&token=${id}&theme=${ theme={appState.theme}
appState.theme />
}&version=${VERSIONS.excalidrawLibrary}`} )}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
</LibraryMenuWrapper> </LibraryMenuWrapper>
); );
}; };
@ -265,6 +267,7 @@ export const LibraryMenu: React.FC<{
// is colled correctly // is colled correctly
key="library" key="library"
className="layer-ui__library-sidebar" className="layer-ui__library-sidebar"
initialDockedState={appState.isSidebarDocked}
onDock={(docked) => { onDock={(docked) => {
trackEvent( trackEvent(
"library", "library",

View File

@ -0,0 +1,31 @@
import { VERSIONS } from "../constants";
import { t } from "../i18n";
import { AppState, ExcalidrawProps } from "../types";
const LibraryMenuBrowseButton = ({
theme,
id,
libraryReturnUrl,
}: {
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: AppState["theme"];
id: string;
}) => {
const referrer =
libraryReturnUrl || window.location.origin + window.location.pathname;
return (
<a
className="library-menu-browse-button"
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
VERSIONS.excalidrawLibrary
}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
);
};
export default LibraryMenuBrowseButton;

View File

@ -3,9 +3,14 @@ import { saveLibraryAsJSON } from "../data/json";
import Library, { libraryItemsAtom } from "../data/library"; import Library, { libraryItemsAtom } from "../data/library";
import { t } from "../i18n"; import { t } from "../i18n";
import { AppState, LibraryItem, LibraryItems } from "../types"; import { AppState, LibraryItem, LibraryItems } from "../types";
import { exportToFileIcon, load, publishIcon, trash } from "./icons"; import {
DotsIcon,
ExportIcon,
LoadIcon,
publishIcon,
TrashIcon,
} from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip";
import { fileOpen } from "../data/filesystem"; import { fileOpen } from "../data/filesystem";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@ -13,6 +18,9 @@ import { jotaiScope } from "../jotai";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import PublishLibrary from "./PublishLibrary"; import PublishLibrary from "./PublishLibrary";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { useOutsideClickHook } from "../hooks/useOutsideClick";
import MenuItem from "./MenuItem";
import { isDropdownOpenAtom } from "./App";
const getSelectedItems = ( const getSelectedItems = (
libraryItems: LibraryItems, libraryItems: LibraryItems,
@ -165,93 +173,84 @@ export const LibraryMenuHeader: React.FC<{
}); });
}; };
const [isDropdownOpen, setIsDropdownOpen] = useAtom(isDropdownOpenAtom);
const dropdownRef = useOutsideClickHook(() => setIsDropdownOpen(false));
return ( return (
<div className="library-actions"> <div style={{ position: "relative" }}>
{showRemoveLibAlert && renderRemoveLibAlert()} <button
{showPublishLibraryDialog && ( type="button"
<PublishLibrary className="Sidebar__dropdown-btn"
onClose={() => setShowPublishLibraryDialog(false)} data-prevent-outside-click
libraryItems={getSelectedItems( onClick={() => setIsDropdownOpen(!isDropdownOpen)}
libraryItemsData.libraryItems, >
selectedItems, {DotsIcon}
</button>
{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}
/>
)} )}
appState={appState} {showRemoveLibAlert && renderRemoveLibAlert()}
onSuccess={(data) => {showPublishLibraryDialog && (
onPublishLibSuccess(data, libraryItemsData.libraryItems) <PublishLibrary
} onClose={() => setShowPublishLibraryDialog(false)}
onError={(error) => window.alert(error)} libraryItems={getSelectedItems(
updateItemsInStorage={() => libraryItemsData.libraryItems,
library.setLibrary(libraryItemsData.libraryItems) selectedItems,
} )}
onRemove={(id: string) => appState={appState}
onSelectItems(selectedItems.filter((_id) => _id !== id)) onSuccess={(data) =>
} onPublishLibSuccess(data, libraryItemsData.libraryItems)
/> }
)} onError={(error) => window.alert(error)}
{publishLibSuccess && renderPublishSuccess()} updateItemsInStorage={() =>
{!itemsSelected && ( library.setLibrary(libraryItemsData.libraryItems)
<ToolButton }
key="import" onRemove={(id: string) =>
type="button" onSelectItems(selectedItems.filter((_id) => _id !== id))
title={t("buttons.load")} }
aria-label={t("buttons.load")} />
icon={load} )}
onClick={onLibraryImport} {publishLibSuccess && renderPublishSuccess()}
className="library-actions--load" {!!items.length && (
/> <>
)} <MenuItem
{!!items.length && ( label={t("buttons.export")}
<> icon={ExportIcon}
<ToolButton onClick={onLibraryExport}
key="export" dataTestId="lib-dropdown--export"
type="button" />
title={t("buttons.export")} <MenuItem
aria-label={t("buttons.export")} label={resetLabel}
icon={exportToFileIcon} icon={TrashIcon}
onClick={onLibraryExport} onClick={() => setShowRemoveLibAlert(true)}
className="library-actions--export" dataTestId="lib-dropdown--remove"
> />
{selectedItems.length > 0 && ( </>
<span className="library-actions-counter"> )}
{selectedItems.length} {itemsSelected && (
</span> <MenuItem
)} label={t("buttons.publishLibrary")}
</ToolButton> icon={publishIcon}
<ToolButton dataTestId="lib-dropdown--publish"
key="reset" onClick={() => setShowPublishLibraryDialog(true)}
type="button" />
title={resetLabel} )}
aria-label={resetLabel} </div>
icon={trash}
onClick={() => setShowRemoveLibAlert(true)}
className="library-actions--remove"
>
{selectedItems.length > 0 && (
<span className="library-actions-counter">
{selectedItems.length}
</span>
)}
</ToolButton>
</>
)}
{itemsSelected && (
<Tooltip label={t("hints.publishLibrary")}>
<ToolButton
type="button"
aria-label={t("buttons.publishLibrary")}
label={t("buttons.publishLibrary")}
icon={publishIcon}
className="library-actions--publish"
onClick={() => setShowPublishLibraryDialog(true)}
>
<label>{t("buttons.publishLibrary")}</label>
{selectedItems.length > 0 && (
<span className="library-actions-counter">
{selectedItems.length}
</span>
)}
</ToolButton>
</Tooltip>
)} )}
</div> </div>
); );

View File

@ -1,18 +1,70 @@
@import "open-color/open-color"; @import "open-color/open-color";
.excalidraw { .excalidraw {
--container-padding-y: 1.5rem;
--container-padding-x: 0.75rem;
.library-menu-items__no-items {
text-align: center;
color: var(--color-gray-70);
line-height: 1.5;
font-size: 0.875rem;
width: 100%;
&__label {
color: var(--color-primary);
font-weight: bold;
font-size: 1.125rem;
margin-bottom: 0.75rem;
}
}
&.theme--dark {
.library-menu-items__no-items {
color: var(--color-gray-40);
}
}
.library-menu-items-container { .library-menu-items-container {
display: flex; display: flex;
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
overflow-y: auto;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
justify-content: center;
margin: 0;
border-bottom: 1px solid var(--sidebar-border-color);
position: relative;
&__row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
&__items { &__items {
row-gap: 0.5rem;
padding: var(--container-padding-y) var(--container-padding-x);
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
&__header {
color: var(--color-primary);
font-size: 1.125rem;
font-weight: bold;
margin-bottom: 0.75rem;
&--excal {
margin-top: 2.5rem;
}
}
.separator { .separator {
width: 100%; width: 100%;
display: flex; display: flex;

View File

@ -2,7 +2,7 @@ import React, { useState } from "react";
import { serializeLibraryAsJSON } from "../data/json"; import { serializeLibraryAsJSON } from "../data/json";
import { ExcalidrawElement, NonDeleted } from "../element/types"; import { ExcalidrawElement, NonDeleted } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { LibraryItem, LibraryItems } from "../types"; import { AppState, ExcalidrawProps, LibraryItem, LibraryItems } from "../types";
import { arrayToMap, chunk } from "../utils"; import { arrayToMap, chunk } from "../utils";
import { LibraryUnit } from "./LibraryUnit"; import { LibraryUnit } from "./LibraryUnit";
import Stack from "./Stack"; import Stack from "./Stack";
@ -10,6 +10,8 @@ import Stack from "./Stack";
import "./LibraryMenuItems.scss"; import "./LibraryMenuItems.scss";
import { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import Spinner from "./Spinner"; import Spinner from "./Spinner";
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
import clsx from "clsx";
const CELLS_PER_ROW = 4; const CELLS_PER_ROW = 4;
@ -21,6 +23,9 @@ const LibraryMenuItems = ({
pendingElements, pendingElements,
selectedItems, selectedItems,
onSelectItems, onSelectItems,
theme,
id,
libraryReturnUrl,
}: { }: {
isLoading: boolean; isLoading: boolean;
libraryItems: LibraryItems; libraryItems: LibraryItems;
@ -29,6 +34,9 @@ const LibraryMenuItems = ({
onAddToLibrary: (elements: LibraryItem["elements"]) => void; onAddToLibrary: (elements: LibraryItem["elements"]) => void;
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void; onSelectItems: (id: LibraryItem["id"][]) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: AppState["theme"];
id: string;
}) => { }) => {
const [lastSelectedItem, setLastSelectedItem] = useState< const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null LibraryItem["id"] | null
@ -167,7 +175,11 @@ const LibraryMenuItems = ({
); );
} }
return ( return (
<Stack.Row align="center" gap={1} key={index}> <Stack.Row
align="center"
key={index}
className="library-menu-items-container__row"
>
{rowItems} {rowItems}
</Stack.Row> </Stack.Row>
); );
@ -181,19 +193,21 @@ const LibraryMenuItems = ({
(item) => item.status === "published", (item) => item.status === "published",
); );
const showBtn =
!libraryItems.length &&
!unpublishedItems.length &&
!publishedItems.length &&
!pendingElements.length;
return ( return (
<div <div
className="library-menu-items-container" className="library-menu-items-container"
style={ style={
publishedItems.length || unpublishedItems.length pendingElements.length ||
? { unpublishedItems.length ||
flex: "1 1 0", publishedItems.length
overflowY: "auto", ? { justifyContent: "flex-start" }
} : {}
: {
marginBottom: "2rem",
flex: 0,
}
} }
> >
<Stack.Col <Stack.Col
@ -206,49 +220,37 @@ const LibraryMenuItems = ({
}} }}
> >
<> <>
<div className="separator"> <div>
{(pendingElements.length > 0 || {(pendingElements.length > 0 ||
unpublishedItems.length > 0 || unpublishedItems.length > 0 ||
publishedItems.length > 0) && ( publishedItems.length > 0) && (
<div>{t("labels.personalLib")}</div> <div className="library-menu-items-container__header">
{t("labels.personalLib")}
</div>
)} )}
{isLoading && ( {isLoading && (
<div <div
style={{ style={{
marginLeft: "auto", position: "absolute",
marginRight: "1rem", top: "var(--container-padding-y)",
display: "flex", right: "var(--container-padding-x)",
alignItems: "center", transform: "translateY(50%)",
fontWeight: "normal",
}} }}
> >
<div style={{ transform: "translateY(2px)" }}> <Spinner />
<Spinner />
</div>
</div> </div>
)} )}
</div> </div>
{!pendingElements.length && !unpublishedItems.length ? ( {!pendingElements.length && !unpublishedItems.length ? (
<div <div className="library-menu-items__no-items">
style={{
height: 65,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
fontSize: ".9rem",
}}
>
{t("library.noItems")}
<div <div
style={{ className={clsx({
margin: ".6rem 0", "library-menu-items__no-items__label": showBtn,
fontSize: ".8em", })}
width: "70%",
textAlign: "center",
}}
> >
{t("library.noItems")}
</div>
<div className="library-menu-items__no-items__hint">
{publishedItems.length > 0 {publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary") ? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")} : t("library.hint_emptyLibrary")}
@ -269,7 +271,9 @@ const LibraryMenuItems = ({
{(publishedItems.length > 0 || {(publishedItems.length > 0 ||
pendingElements.length > 0 || pendingElements.length > 0 ||
unpublishedItems.length > 0) && ( unpublishedItems.length > 0) && (
<div className="separator">{t("labels.excalidrawLib")}</div> <div className="library-menu-items-container__header library-menu-items-container__header--excal">
{t("labels.excalidrawLib")}
</div>
)} )}
{publishedItems.length > 0 ? ( {publishedItems.length > 0 ? (
renderLibrarySection(publishedItems) renderLibrarySection(publishedItems)
@ -289,6 +293,14 @@ const LibraryMenuItems = ({
</div> </div>
) : null} ) : null}
</> </>
{showBtn && (
<LibraryMenuBrowseButton
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
/>
)}
</Stack.Col> </Stack.Col>
</div> </div>
); );

View File

@ -7,17 +7,18 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
position: relative; position: relative;
width: 63px; width: 55px;
height: 63px; // match width height: 55px;
box-sizing: border-box;
border-radius: var(--border-radius-lg);
&--hover { &--hover {
box-shadow: inset 0px 0px 0px 2px $oc-blue-5; border-color: var(--color-primary);
border-color: $oc-blue-5;
} }
&--selected { &--selected {
box-shadow: inset 0px 0px 0px 2px $oc-blue-8; border-color: var(--color-primary);
border-color: $oc-blue-8; border-width: 1px;
} }
} }
@ -59,20 +60,34 @@
.library-unit__checkbox { .library-unit__checkbox {
position: absolute; position: absolute;
left: 2.3rem; top: 0.125rem;
bottom: 2.3rem; right: 0.125rem;
margin: 0;
.Checkbox-box { .Checkbox-box {
width: 13px; margin: 0;
height: 13px; width: 1rem;
border-radius: 2px; height: 1rem;
margin: 0.5em 0.5em 0.2em 0.2em; border-radius: 4px;
background-color: $oc-blue-1; background-color: var(--color-primary-light);
border: 1px solid var(--color-primary);
box-shadow: none !important;
padding: 2px;
} }
&.Checkbox:hover { &.Checkbox:hover {
.Checkbox-box { .Checkbox-box {
background-color: $oc-blue-2; background-color: var(--color-primary-light);
}
}
&.is-checked {
.Checkbox-box {
background-color: var(--color-primary) !important;
svg {
color: var(--color-primary-light);
}
} }
} }
} }
@ -85,25 +100,29 @@
.library-unit__adder { .library-unit__adder {
transform: scale(1); transform: scale(1);
animation: library-unit__adder-animation 1s ease-in infinite; animation: library-unit__adder-animation 1s ease-in infinite;
position: absolute;
width: 1.5rem;
height: 1.5rem;
background-color: var(--color-primary);
border-radius: var(--border-radius-md);
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
svg {
color: var(--color-primary-light);
width: 1rem;
height: 1rem;
}
} }
.library-unit__adder {
position: absolute;
left: 40%;
top: 40%;
width: 2rem;
height: 2rem;
margin-left: -10px;
margin-top: -10px;
pointer-events: none;
}
.library-unit:hover .library-unit__adder {
fill: $oc-blue-7;
}
.library-unit:active .library-unit__adder { .library-unit:active .library-unit__adder {
animation: none; animation: none;
transform: scale(0.8); transform: scale(0.8);
fill: $oc-black;
} }
.library-unit__active { .library-unit__active {

View File

@ -6,19 +6,7 @@ import { exportToSvg } from "../scene/export";
import { LibraryItem } from "../types"; import { LibraryItem } from "../types";
import "./LibraryUnit.scss"; import "./LibraryUnit.scss";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
import { PlusIcon } from "./icons";
const PLUS_ICON = (
<svg viewBox="0 0 1792 1792">
<path
d="M1600 736v192c0 26.667-9.33 49.333-28 68-18.67 18.67-41.33 28-68 28h-416v416c0 26.67-9.33 49.33-28 68s-41.33 28-68 28H800c-26.667 0-49.333-9.33-68-28s-28-41.33-28-68v-416H288c-26.667 0-49.333-9.33-68-28-18.667-18.667-28-41.333-28-68V736c0-26.667 9.333-49.333 28-68s41.333-28 68-28h416V224c0-26.667 9.333-49.333 28-68s41.333-28 68-28h192c26.67 0 49.33 9.333 68 28s28 41.333 28 68v416h416c26.67 0 49.33 9.333 68 28s28 41.333 28 68Z"
style={{
stroke: "#fff",
strokeWidth: 140,
}}
transform="translate(0 64)"
/>
</svg>
);
export const LibraryUnit = ({ export const LibraryUnit = ({
id, id,
@ -67,7 +55,7 @@ export const LibraryUnit = ({
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobile = useDevice().isMobile; const isMobile = useDevice().isMobile;
const adder = isPending && ( const adder = isPending && (
<div className="library-unit__adder">{PLUS_ICON}</div> <div className="library-unit__adder">{PlusIcon}</div>
); );
return ( return (

View File

@ -1,8 +1,8 @@
import "./ToolIcon.scss"; import "./ToolIcon.scss";
import React from "react";
import clsx from "clsx"; import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton"; import { ToolButtonSize } from "./ToolButton";
import { LockedIcon, UnlockedIcon } from "./icons";
type LockIconProps = { type LockIconProps = {
title?: string; title?: string;
@ -16,34 +16,15 @@ type LockIconProps = {
const DEFAULT_SIZE: ToolButtonSize = "medium"; const DEFAULT_SIZE: ToolButtonSize = "medium";
const ICONS = { const ICONS = {
CHECKED: ( CHECKED: LockedIcon,
<svg UNCHECKED: UnlockedIcon,
width="1792"
height="1792"
viewBox="0 0 1792 1792"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M640 768h512v-192q0-106-75-181t-181-75-181 75-75 181v192zm832 96v576q0 40-28 68t-68 28h-960q-40 0-68-28t-28-68v-576q0-40 28-68t68-28h32v-192q0-184 132-316t316-132 316 132 132 316v192h32q40 0 68 28t28 68z" />
</svg>
),
UNCHECKED: (
<svg
width="1792"
height="1792"
viewBox="0 0 1792 1792"
xmlns="http://www.w3.org/2000/svg"
className="unlocked-icon rtl-mirror"
>
<path d="M1728 576v256q0 26-19 45t-45 19h-64q-26 0-45-19t-19-45v-256q0-106-75-181t-181-75-181 75-75 181v192h96q40 0 68 28t28 68v576q0 40-28 68t-68 28h-960q-40 0-68-28t-28-68v-576q0-40 28-68t68-28h672v-192q0-185 131.5-316.5t316.5-131.5 316.5 131.5 131.5 316.5z" />
</svg>
),
}; };
export const LockButton = (props: LockIconProps) => { export const LockButton = (props: LockIconProps) => {
return ( return (
<label <label
className={clsx( className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating", "ToolIcon ToolIcon__lock",
`ToolIcon_size_${DEFAULT_SIZE}`, `ToolIcon_size_${DEFAULT_SIZE}`,
{ {
"is-mobile": props.isMobile, "is-mobile": props.isMobile,
@ -58,6 +39,7 @@ export const LockButton = (props: LockIconProps) => {
onChange={props.onChange} onChange={props.onChange}
checked={props.checked} checked={props.checked}
aria-label={props.title} aria-label={props.title}
data-testid="toolbar-lock"
/> />
<div className="ToolIcon__icon"> <div className="ToolIcon__icon">
{props.checked ? ICONS.CHECKED : ICONS.UNCHECKED} {props.checked ? ICONS.CHECKED : ICONS.UNCHECKED}

85
src/components/Menu.scss Normal file
View File

@ -0,0 +1,85 @@
@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

@ -0,0 +1,37 @@
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

@ -0,0 +1,53 @@
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

@ -8,18 +8,21 @@ import { NonDeletedExcalidrawElement } from "../element/types";
import { FixedSideContainer } from "./FixedSideContainer"; import { FixedSideContainer } from "./FixedSideContainer";
import { Island } from "./Island"; import { Island } from "./Island";
import { HintViewer } from "./HintViewer"; import { HintViewer } from "./HintViewer";
import { calculateScrollCenter, getSelectedElements } from "../scene"; import { calculateScrollCenter } from "../scene";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section"; import { Section } from "./Section";
import CollabButton from "./CollabButton"; import CollabButton from "./CollabButton";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars"; import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockButton } from "./LockButton"; import { LockButton } from "./LockButton";
import { UserList } from "./UserList"; import { UserList } from "./UserList";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import { LibraryButton } from "./LibraryButton"; import { LibraryButton } from "./LibraryButton";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions"; import { actionToggleStats } from "../actions";
import { MenuLinks, Separator } from "./MenuUtils";
import WelcomeScreen from "./WelcomeScreen";
import MenuItem from "./MenuItem";
import { ExportImageIcon } from "./icons";
type MobileMenuProps = { type MobileMenuProps = {
appState: AppState; appState: AppState;
@ -45,6 +48,7 @@ type MobileMenuProps = {
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderSidebars: () => JSX.Element | null; renderSidebars: () => JSX.Element | null;
device: Device; device: Device;
renderWelcomeScreen?: boolean;
}; };
export const MobileMenu = ({ export const MobileMenu = ({
@ -65,17 +69,35 @@ export const MobileMenu = ({
renderCustomStats, renderCustomStats,
renderSidebars, renderSidebars,
device, device,
renderWelcomeScreen,
}: MobileMenuProps) => { }: MobileMenuProps) => {
const renderToolbar = () => { const renderToolbar = () => {
return ( return (
<FixedSideContainer side="top" className="App-top-bar"> <FixedSideContainer side="top" className="App-top-bar">
{renderWelcomeScreen && !appState.isLoading && (
<WelcomeScreen appState={appState} actionManager={actionManager} />
)}
<Section heading="shapes"> <Section heading="shapes">
{(heading: React.ReactNode) => ( {(heading: React.ReactNode) => (
<Stack.Col gap={4} align="center"> <Stack.Col gap={4} align="center">
<Stack.Row gap={1} className="App-toolbar-container"> <Stack.Row gap={1} className="App-toolbar-container">
<Island padding={1} className="App-toolbar"> <Island padding={1} className="App-toolbar App-toolbar--mobile">
{heading} {heading}
<Stack.Row gap={1}> <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 <ShapesSwitcher
appState={appState} appState={appState}
canvas={canvas} canvas={canvas}
@ -89,25 +111,31 @@ export const MobileMenu = ({
/> />
</Stack.Row> </Stack.Row>
</Island> </Island>
{renderTopRightUI && renderTopRightUI(true, appState)} <div className="mobile-misc-tools-container">
<LockButton {!appState.viewModeEnabled &&
checked={appState.activeTool.locked} renderTopRightUI?.(true, appState)}
onChange={onLockToggle} <PenModeButton
title={t("toolBar.lock")} checked={appState.penMode}
isMobile onChange={onPenModeToggle}
/> title={t("toolBar.penMode")}
<LibraryButton isMobile
appState={appState} penDetected={appState.penDetected}
setAppState={setAppState} // penDetected={true}
isMobile />
/> <LockButton
<PenModeButton checked={appState.activeTool.locked}
checked={appState.penMode} onChange={onLockToggle}
onChange={onPenModeToggle} title={t("toolBar.lock")}
title={t("toolBar.penMode")} isMobile
isMobile />
penDetected={appState.penDetected} {!appState.viewModeEnabled && (
/> <LibraryButton
appState={appState}
setAppState={setAppState}
isMobile
/>
)}
</div>
</Stack.Row> </Stack.Row>
</Stack.Col> </Stack.Col>
)} )}
@ -123,11 +151,6 @@ export const MobileMenu = ({
}; };
const renderAppToolbar = () => { const renderAppToolbar = () => {
// Render eraser conditionally in mobile
const showEraser =
!appState.editingElement &&
getSelectedElements(elements, appState).length === 0;
if (appState.viewModeEnabled) { if (appState.viewModeEnabled) {
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
@ -140,14 +163,11 @@ export const MobileMenu = ({
<div className="App-toolbar-content"> <div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")} {actionManager.renderAction("toggleCanvasMenu")}
{actionManager.renderAction("toggleEditMenu")} {actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")} {actionManager.renderAction("undo")}
{actionManager.renderAction("redo")} {actionManager.renderAction("redo")}
{showEraser {actionManager.renderAction(
? actionManager.renderAction("eraser") appState.multiElement ? "finalize" : "duplicateSelection",
: actionManager.renderAction( )}
appState.multiElement ? "finalize" : "duplicateSelection",
)}
{actionManager.renderAction("deleteSelectedElements")} {actionManager.renderAction("deleteSelectedElements")}
</div> </div>
); );
@ -158,16 +178,27 @@ export const MobileMenu = ({
return ( return (
<> <>
{renderJSONExportDialog()} {renderJSONExportDialog()}
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
/>
{renderImageExportDialog()} {renderImageExportDialog()}
</> </>
); );
} }
return ( return (
<> <>
{actionManager.renderAction("clearCanvas")} {!appState.viewModeEnabled && actionManager.renderAction("loadScene")}
{actionManager.renderAction("loadScene")}
{renderJSONExportDialog()} {renderJSONExportDialog()}
{renderImageExportDialog()} {renderImageExportDialog()}
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
/>
{onCollabButtonClick && ( {onCollabButtonClick && (
<CollabButton <CollabButton
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
@ -175,7 +206,22 @@ export const MobileMenu = ({
onClick={onCollabButtonClick} onClick={onCollabButtonClick}
/> />
)} )}
{<BackgroundPickerAndDarkModeToggle actionManager={actionManager} />} {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")}
</> </>
); );
}; };
@ -206,7 +252,7 @@ export const MobileMenu = ({
{appState.openMenu === "canvas" ? ( {appState.openMenu === "canvas" ? (
<Section className="App-mobile-menu" heading="canvasActions"> <Section className="App-mobile-menu" heading="canvasActions">
<div className="panelColumn"> <div className="panelColumn">
<Stack.Col gap={4}> <Stack.Col gap={2}>
{renderCanvasActions()} {renderCanvasActions()}
{renderCustomFooter?.(true, appState)} {renderCustomFooter?.(true, appState)}
{appState.collaborators.size > 0 && ( {appState.collaborators.size > 0 && (

View File

@ -17,6 +17,10 @@
justify-content: center; justify-content: center;
overflow: auto; overflow: auto;
padding: calc(var(--space-factor) * 10); padding: calc(var(--space-factor) * 10);
.Island {
padding: 2.5rem !important;
}
} }
.Modal__background { .Modal__background {
@ -26,7 +30,7 @@
right: 0; right: 0;
bottom: 0; bottom: 0;
z-index: 1; z-index: 1;
background-color: transparentize($oc-black, 0.3); background-color: rgba(#121212, 0.2);
} }
.Modal__content { .Modal__content {
@ -46,7 +50,7 @@
background: var(--island-bg-color); background: var(--island-bg-color);
border: 1px solid var(--dialog-border-color); border: 1px solid var(--dialog-border-color);
box-shadow: 0 2px 10px transparentize($oc-black, 0.75); box-shadow: var(--modal-shadow);
border-radius: 6px; border-radius: 6px;
box-sizing: border-box; box-sizing: border-box;
@ -73,14 +77,20 @@
} }
.Modal__close { .Modal__close {
width: calc(var(--space-factor) * 7); color: var(--icon-fill-color);
height: calc(var(--space-factor) * 7); margin: 0;
display: flex; padding: 0.375rem;
align-items: center; position: absolute;
justify-content: center; top: 1rem;
right: 1rem;
border: 0;
background-color: transparent;
line-height: 0;
cursor: pointer;
svg { svg {
height: calc(var(--space-factor) * 5); width: 1.5rem;
height: 1.5rem;
} }
} }

View File

@ -39,6 +39,7 @@ export const Modal: React.FC<{
aria-modal="true" aria-modal="true"
onKeyDown={handleKeydown} onKeyDown={handleKeydown}
aria-labelledby={props.labelledBy} aria-labelledby={props.labelledBy}
data-prevent-outside-click
> >
<div <div
className="Modal__background" className="Modal__background"

View File

@ -2,6 +2,7 @@ import "./ToolIcon.scss";
import clsx from "clsx"; import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton"; import { ToolButtonSize } from "./ToolButton";
import { PenModeIcon } from "./icons";
type PenModeIconProps = { type PenModeIconProps = {
title?: string; title?: string;
@ -15,59 +16,15 @@ type PenModeIconProps = {
const DEFAULT_SIZE: ToolButtonSize = "medium"; const DEFAULT_SIZE: ToolButtonSize = "medium";
const ICONS = {
CHECKED: (
<svg
width="205"
height="205"
viewBox="0 0 205 205"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m35 195-25-29.17V50h50v115l-25 30" />
<path d="M10 40V10h50v30H10" />
<path d="M125 145h70v50h-70" />
<path d="M190 145v-30l-10-20h-40l-10 20v30h15v-30l5-5h20l5 5v30h15" />
</svg>
),
UNCHECKED: (
<svg
width="205"
height="205"
viewBox="0 0 205 205"
xmlns="http://www.w3.org/2000/svg"
className="unlocked-icon rtl-mirror"
>
<path d="m35 195-25-29.17V50h50v115l-25 30" />
<path d="M10 40V10h50v30H10" />
<path d="M125 145h70v50h-70" />
<path d="M145 145v-30l-10-20H95l-10 20v30h15v-30l5-5h20l5 5v30h15" />
</svg>
),
};
export const PenModeButton = (props: PenModeIconProps) => { export const PenModeButton = (props: PenModeIconProps) => {
if (!props.penDetected) { if (!props.penDetected) {
if (props.isMobile) { return null;
return null;
}
return (
<label
className={clsx(
"ToolIcon ToolIcon__penMode ToolIcon_type_floating",
`ToolIcon_size_${DEFAULT_SIZE}`,
{
"is-mobile": props.isMobile,
},
)}
>
<div className="ToolIcon__icon ToolIcon__hidden" />
</label>
);
} }
return ( return (
<label <label
className={clsx( className={clsx(
"ToolIcon ToolIcon__penMode ToolIcon_type_floating", "ToolIcon ToolIcon__penMode",
`ToolIcon_size_${DEFAULT_SIZE}`, `ToolIcon_size_${DEFAULT_SIZE}`,
{ {
"is-mobile": props.isMobile, "is-mobile": props.isMobile,
@ -83,9 +40,7 @@ export const PenModeButton = (props: PenModeIconProps) => {
checked={props.checked} checked={props.checked}
aria-label={props.title} aria-label={props.title}
/> />
<div className="ToolIcon__icon"> <div className="ToolIcon__icon">{PenModeIcon}</div>
{props.checked ? ICONS.CHECKED : ICONS.UNCHECKED}
</div>
</label> </label>
); );
}; };

View File

@ -7,7 +7,7 @@
flex-direction: column; flex-direction: column;
label { label {
padding: 1em; padding: 1em 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -34,6 +34,7 @@
display: flex; display: flex;
padding: 0.2rem 0; padding: 0.2rem 0;
justify-content: flex-end; justify-content: flex-end;
gap: 0.5rem;
.ToolIcon__icon { .ToolIcon__icon {
min-width: 2.5rem; min-width: 2.5rem;
@ -74,7 +75,6 @@
.selected-library-items { .selected-library-items {
display: flex; display: flex;
padding: 0 0.8rem;
flex-wrap: wrap; flex-wrap: wrap;
.single-library-item-wrapper { .single-library-item-wrapper {
@ -87,7 +87,7 @@
} }
&-note { &-note {
padding: 1em; padding: 1em 0;
font-style: italic; font-style: italic;
font-size: 14px; font-size: 14px;
display: block; display: block;

View File

@ -4,8 +4,6 @@ import OpenColor from "open-color";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { t } from "../i18n"; import { t } from "../i18n";
import { ToolButton } from "./ToolButton";
import { AppState, LibraryItems, LibraryItem } from "../types"; import { AppState, LibraryItems, LibraryItem } from "../types";
import { exportToCanvas } from "../packages/utils"; import { exportToCanvas } from "../packages/utils";
import { import {
@ -20,6 +18,7 @@ import "./PublishLibrary.scss";
import SingleLibraryItem from "./SingleLibraryItem"; import SingleLibraryItem from "./SingleLibraryItem";
import { canvasToBlob, resizeImageFile } from "../data/blob"; import { canvasToBlob, resizeImageFile } from "../data/blob";
import { chunk } from "../utils"; import { chunk } from "../utils";
import DialogActionButton from "./DialogActionButton";
interface PublishLibraryDataParams { interface PublishLibraryDataParams {
authorName: string; authorName: string;
@ -434,21 +433,15 @@ const PublishLibrary = ({
</span> </span>
</div> </div>
<div className="publish-library__buttons"> <div className="publish-library__buttons">
<ToolButton <DialogActionButton
type="button"
title={t("buttons.cancel")}
aria-label={t("buttons.cancel")}
label={t("buttons.cancel")} label={t("buttons.cancel")}
onClick={onDialogClose} onClick={onDialogClose}
data-testid="cancel-clear-canvas-button" data-testid="cancel-clear-canvas-button"
className="publish-library__buttons--cancel"
/> />
<ToolButton <DialogActionButton
type="submit" type="submit"
title={t("buttons.submit")}
aria-label={t("buttons.submit")}
label={t("buttons.submit")} label={t("buttons.submit")}
className="publish-library__buttons--confirm" actionType="primary"
isLoading={isSubmitting} isLoading={isSubmitting}
/> />
</div> </div>

View File

@ -2,20 +2,101 @@
@import "../../css/variables.module"; @import "../../css/variables.module";
.excalidraw { .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 {
@include outlineButtonStyles;
width: var(--lg-button-size);
height: var(--lg-button-size);
padding: 0;
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
&__pin-btn {
&--pinned {
background-color: var(--color-primary);
border-color: var(--color-primary);
svg {
color: #fff;
}
&:hover,
&:active {
background-color: var(--color-primary-darker);
}
}
}
}
&.theme--dark {
.Sidebar {
&__pin-btn {
&--pinned {
svg {
color: var(--color-gray-90);
}
}
}
}
}
.layer-ui__sidebar { .layer-ui__sidebar {
position: absolute; position: absolute;
top: var(--sat); top: 0;
bottom: var(--sab); bottom: 0;
right: var(--sar); right: 0;
z-index: 5; z-index: 5;
margin: 0;
:root[dir="rtl"] & {
left: 0;
right: auto;
}
background-color: var(--sidebar-bg-color);
box-shadow: var(--sidebar-shadow);
&--docked {
box-shadow: none;
}
box-shadow: var(--shadow-island);
overflow: hidden; overflow: hidden;
border-radius: var(--border-radius-lg); border-radius: 0;
margin: var(--space-factor);
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2); width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
padding: 0.5rem; border-left: 1px solid var(--sidebar-border-color);
:root[dir="rtl"] & {
border-right: 1px solid var(--sidebar-border-color);
border-left: 0;
}
padding: 0;
box-sizing: border-box; box-sizing: border-box;
.Island { .Island {
@ -48,42 +129,18 @@
} }
.layer-ui__sidebar__header { .layer-ui__sidebar__header {
box-sizing: border-box;
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
width: 100%; width: 100%;
margin: 2px 0 15px 0; padding: 1rem;
&:empty { border-bottom: 1px solid var(--sidebar-border-color);
margin: 0;
}
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
}
} }
.layer-ui__sidebar__header__buttons { .layer-ui__sidebar__header__buttons {
display: flex; display: flex;
align-items: center; align-items: center;
margin-left: auto; gap: 0.625rem;
}
.layer-ui__sidebar-dock-button {
@include toolbarButtonColorStates;
margin-right: 0.2rem;
.ToolIcon_type_floating .ToolIcon__icon {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
svg {
// mirror
transform: scale(-1, 1);
}
}
.ToolIcon_type_checkbox {
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
background-color: var(--color-primary);
}
}
} }
} }

View File

@ -90,10 +90,10 @@ describe("Sidebar", () => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar"); const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null); expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-close"); const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
expect(closeButton).not.toBe(null); expect(closeButton).not.toBe(null);
fireEvent.click(closeButton!.querySelector("button")!); fireEvent.click(closeButton);
await waitFor(() => { await waitFor(() => {
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null); expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();

View File

@ -33,6 +33,13 @@ export const Sidebar = Object.assign(
onClose, onClose,
onDock, onDock,
docked, docked,
/** Undocumented, may be removed later. Generally should either be
* `props.docked` or `appState.isSidebarDocked`. Currently serves to
* prevent unwanted animation of the shadow if initially docked. */
//
// NOTE we'll want to remove this after we sort out how to subscribe to
// individual appState properties
initialDockedState = docked,
dockable = true, dockable = true,
className, className,
__isInternal, __isInternal,
@ -52,7 +59,9 @@ export const Sidebar = Object.assign(
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const [isDockedFallback, setIsDockedFallback] = useState(docked ?? false); const [isDockedFallback, setIsDockedFallback] = useState(
docked ?? initialDockedState ?? false,
);
useLayoutEffect(() => { useLayoutEffect(() => {
if (docked === undefined) { if (docked === undefined) {
@ -119,8 +128,11 @@ export const Sidebar = Object.assign(
return ( return (
<Island <Island
padding={2} className={clsx(
className={clsx("layer-ui__sidebar", className)} "layer-ui__sidebar",
{ "layer-ui__sidebar--docked": isDockedFallback },
className,
)}
ref={ref} ref={ref}
> >
<SidebarPropsContext.Provider value={headerPropsRef.current}> <SidebarPropsContext.Provider value={headerPropsRef.current}>

View File

@ -3,16 +3,10 @@ import { useContext } from "react";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useDevice } from "../App"; import { useDevice } from "../App";
import { SidebarPropsContext } from "./common"; import { SidebarPropsContext } from "./common";
import { close } from "../icons"; import { CloseIcon, PinIcon } from "../icons";
import { withUpstreamOverride } from "../hoc/withUpstreamOverride"; import { withUpstreamOverride } from "../hoc/withUpstreamOverride";
import { Tooltip } from "../Tooltip"; import { Tooltip } from "../Tooltip";
const SIDE_LIBRARY_TOGGLE_ICON = (
<svg viewBox="0 0 24 24" fill="#ffffff">
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
</svg>
);
export const SidebarDockButton = (props: { export const SidebarDockButton = (props: {
checked: boolean; checked: boolean;
onChange?(): void; onChange?(): void;
@ -33,8 +27,13 @@ export const SidebarDockButton = (props: {
checked={props.checked} checked={props.checked}
aria-label={t("labels.sidebarLock")} aria-label={t("labels.sidebarLock")}
/>{" "} />{" "}
<div className="ToolIcon__icon" tabIndex={0}> <div
{SIDE_LIBRARY_TOGGLE_ICON} className={clsx("Sidebar__pin-btn", {
"Sidebar__pin-btn--pinned": props.checked,
})}
tabIndex={0}
>
{PinIcon}
</div>{" "} </div>{" "}
</label>{" "} </label>{" "}
</Tooltip> </Tooltip>
@ -64,24 +63,19 @@ const _SidebarHeader: React.FC<{
<SidebarDockButton <SidebarDockButton
checked={!!props.docked} checked={!!props.docked}
onChange={() => { onChange={() => {
document
.querySelector(".layer-ui__wrapper")
?.classList.add("animate");
props.onDock?.(!props.docked); props.onDock?.(!props.docked);
}} }}
/> />
)} )}
{renderCloseButton && ( {renderCloseButton && (
<div className="ToolIcon__icon__close" data-testid="sidebar-close"> <button
<button data-testid="sidebar-close"
className="Modal__close" className="Sidebar__close-btn"
onClick={props.onClose} onClick={props.onClose}
aria-label={t("buttons.close")} aria-label={t("buttons.close")}
> >
{close} {CloseIcon}
</button> </button>
</div>
)} )}
</div> </div>
)} )}

View File

@ -9,6 +9,7 @@ export type SidebarProps<P = {}> = {
/** if not supplied, sidebar won't be dockable */ /** if not supplied, sidebar won't be dockable */
onDock?: (docked: boolean) => void; onDock?: (docked: boolean) => void;
docked?: boolean; docked?: boolean;
initialDockedState?: boolean;
dockable?: boolean; dockable?: boolean;
className?: string; className?: string;
} & P; } & P;

View File

@ -3,7 +3,7 @@ import { useEffect, useRef } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { exportToSvg } from "../packages/utils"; import { exportToSvg } from "../packages/utils";
import { AppState, LibraryItem } from "../types"; import { AppState, LibraryItem } from "../types";
import { close } from "./icons"; import { CloseIcon } from "./icons";
import "./SingleLibraryItem.scss"; import "./SingleLibraryItem.scss";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@ -54,7 +54,7 @@ const SingleLibraryItem = ({
<ToolButton <ToolButton
aria-label={t("buttons.remove")} aria-label={t("buttons.remove")}
type="button" type="button"
icon={close} icon={CloseIcon}
className="single-library-item--remove" className="single-library-item--remove"
onClick={onRemove.bind(null, libItem.id)} onClick={onRemove.bind(null, libItem.id)}
title={t("buttons.remove")} title={t("buttons.remove")}
@ -62,7 +62,7 @@ const SingleLibraryItem = ({
<div <div
style={{ style={{
display: "flex", display: "flex",
margin: "0.8rem 0.3rem", margin: "0.8rem 0",
width: "100%", width: "100%",
fontSize: "14px", fontSize: "14px",
fontWeight: 500, fontWeight: 500,

View File

@ -4,7 +4,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { getTargetElements } from "../scene"; import { getTargetElements } from "../scene";
import { AppState, ExcalidrawProps } from "../types"; import { AppState, ExcalidrawProps } from "../types";
import { close } from "./icons"; import { CloseIcon } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
import "./Stats.scss"; import "./Stats.scss";
@ -23,7 +23,7 @@ export const Stats = (props: {
<div className="Stats"> <div className="Stats">
<Island padding={2}> <Island padding={2}>
<div className="close" onClick={props.onClose}> <div className="close" onClick={props.onClose}>
{close} {CloseIcon}
</div> </div>
<h3>{t("stats.title")}</h3> <h3>{t("stats.title")}</h3>
<table> <table>

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { close } from "./icons"; import { CloseIcon } from "./icons";
import "./Toast.scss"; import "./Toast.scss";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@ -47,7 +47,7 @@ export const Toast = ({
<p className="Toast__message">{message}</p> <p className="Toast__message">{message}</p>
{closable && ( {closable && (
<ToolButton <ToolButton
icon={close} icon={CloseIcon}
aria-label="close" aria-label="close"
type="icon" type="icon"
onClick={onClose} onClick={onClose}

View File

@ -3,12 +3,19 @@
.excalidraw { .excalidraw {
.ToolIcon { .ToolIcon {
border-radius: var(--border-radius-lg);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
user-select: none; user-select: none;
&__hidden {
display: none !important;
}
@include toolbarButtonColorStates;
} }
.ToolIcon--plain { .ToolIcon--plain {
@ -21,21 +28,15 @@
.ToolIcon_type_radio, .ToolIcon_type_radio,
.ToolIcon_type_checkbox { .ToolIcon_type_checkbox {
& + .ToolIcon__icon { position: absolute;
background-color: var(--button-gray-1); opacity: 0;
pointer-events: none;
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
}
} }
.ToolIcon__icon { .ToolIcon__icon {
width: 2.5rem; box-sizing: border-box;
height: 2.5rem; width: var(--default-button-size);
height: var(--default-button-size);
color: var(--icon-fill-color); color: var(--icon-fill-color);
display: flex; display: flex;
@ -50,8 +51,8 @@
svg { svg {
position: relative; position: relative;
height: 1em; width: var(--default-icon-size);
fill: var(--icon-fill-color); height: var(--default-icon-size);
color: var(--icon-fill-color); color: var(--icon-fill-color);
} }
} }
@ -75,13 +76,14 @@
font-size: 0.8em; font-size: 0.8em;
} }
.excalidraw .ToolIcon_type_button, .ToolIcon_type_button,
.Modal .ToolIcon_type_button, .Modal .ToolIcon_type_button,
.ToolIcon_type_button { .ToolIcon_type_button {
padding: 0; padding: 0;
border: none; border: none;
margin: 0; margin: 0;
font-size: inherit; font-size: inherit;
background-color: initial;
&:focus-visible { &:focus-visible {
box-shadow: 0 0 0 2px var(--focus-highlight-color); box-shadow: 0 0 0 2px var(--focus-highlight-color);
@ -95,9 +97,9 @@
} }
} }
&:hover { // &:hover {
background-color: var(--button-gray-2); // background-color: var(--button-gray-2);
} // }
&:active { &:active {
background-color: var(--button-gray-3); background-color: var(--button-gray-3);
@ -108,29 +110,8 @@
} }
&--hide { &--hide {
visibility: hidden; // visibility: hidden;
} display: none !important;
}
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
position: absolute;
opacity: 0;
pointer-events: none;
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
background-color: var(--button-gray-2);
&:active {
background-color: var(--button-gray-3);
}
}
&:focus-visible + .ToolIcon__icon {
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
&:active + .ToolIcon__icon {
background-color: var(--button-gray-3);
} }
} }
@ -163,66 +144,12 @@
position: absolute; position: absolute;
bottom: 2px; bottom: 2px;
right: 3px; right: 3px;
font-size: 0.5em; font-size: 0.625rem;
color: var(--keybinding-color); color: var(--keybinding-color);
font-family: var(--ui-font); font-family: var(--ui-font);
user-select: none; user-select: none;
} }
// shrink shape icons on small viewports to make them fit
@media (max-width: 425px) {
.Shape .ToolIcon__icon {
width: 2rem;
height: 2rem;
svg {
height: 0.8em;
}
}
}
// move the lock button out of the way on small viewports
// it begins to collide with the GitHub icon before we switch to mobile mode
@media (max-width: 760px) {
.ToolIcon.ToolIcon_type_floating {
display: inline-block;
position: absolute;
right: -8px;
margin-left: 0;
border-radius: 20px 0 0 20px;
z-index: 1;
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-1);
}
&:active {
background-color: var(--button-gray-2);
}
.ToolIcon__icon {
border-radius: inherit;
}
svg {
position: static;
}
}
.ToolIcon.ToolIcon__library {
top: calc(var(--sat) + 100px);
}
.ToolIcon.ToolIcon__lock {
top: calc(var(--sat) + 60px);
}
.ToolIcon.ToolIcon__penMode {
top: calc(var(--sat) + 140px);
}
}
.unlocked-icon { .unlocked-icon {
:root[dir="ltr"] & { :root[dir="ltr"] & {
left: 2px; left: 2px;
@ -232,4 +159,16 @@
right: 2px; right: 2px;
} }
} }
.App-toolbar-container {
.ToolIcon__icon {
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
}
} }

View File

@ -2,101 +2,20 @@
@import "../css/variables.module"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.App-toolbar-container {
.ToolIcon_type_floating {
@include toolbarButtonColorStates;
&:not(.is-mobile) {
.ToolIcon__icon {
padding: 1px;
background-color: var(--island-bg-color);
box-shadow: 1px 3px 4px 0px rgb(0 0 0 / 15%);
border-radius: 50%;
transition: box-shadow 0.5s ease, transform 0.5s ease;
}
}
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
&:focus-within + .ToolIcon__icon {
// override for custom floating button shadow
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
}
}
.ToolIcon__hidden {
box-shadow: none !important;
background-color: transparent !important;
pointer-events: none !important;
}
.ToolIcon.ToolIcon__lock {
&.ToolIcon_type_floating {
margin-left: 0.1rem;
}
}
.ToolIcon__library {
margin-inline-start: var(--space-factor);
}
&.zen-mode {
.ToolIcon_type_floating {
.ToolIcon__icon {
box-shadow: none;
transform: scale(0.9);
}
.ToolIcon_type_checkbox:not(:checked):not(:hover):not(:active) {
& + .ToolIcon__icon {
svg {
fill: $oc-gray-5;
color: $oc-gray-5;
}
}
}
}
}
}
.App-toolbar { .App-toolbar {
border-radius: var(--border-radius-lg);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 15%);
.ToolIcon {
&:hover {
--icon-fill-color: var(
--color-primary-contrast-offset,
var(--color-primary)
);
--keybinding-color: var(
--color-primary-contrast-offset,
var(--color-primary)
);
}
&:active {
--icon-fill-color: #{$oc-gray-9};
--keybinding-color: #{$oc-gray-9};
}
.ToolIcon__icon {
background: transparent;
border-radius: var(--border-radius-lg);
}
@include toolbarButtonColorStates;
}
&.zen-mode { &.zen-mode {
.ToolIcon__keybinding, .ToolIcon__keybinding,
.HintViewer { .HintViewer {
display: none; display: none;
} }
} }
}
&.theme--dark .App-toolbar .ToolIcon:active { &__divider {
--icon-fill-color: #{$oc-gray-3}; width: 1px;
--keybinding-color: #{$oc-gray-3}; height: 1.5rem;
align-self: center;
background-color: var(--default-border-color);
margin: 0 0.5rem;
}
} }
} }

View File

@ -7,23 +7,30 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
gap: 0.625rem;
&:empty { &:empty {
display: none; display: none;
} }
// can fit max 5 avatars in a column
max-height: 140px;
// can fit max 10 avatars in a row when there's enough space
max-width: 290px;
// Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
overflow: hidden;
} }
.UserList > * { .UserList > * {
pointer-events: all; pointer-events: all;
margin: 0 0 var(--space-factor) var(--space-factor);
} }
.UserList_mobile { .UserList_mobile {
padding: 0; padding: 0;
justify-content: normal; justify-content: normal;
} margin: 0.5rem 0;
.UserList_mobile > * {
margin: 0 var(--space-factor) var(--space-factor) 0;
} }
} }

View File

@ -44,6 +44,26 @@ 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 ( return (
<div className={clsx("UserList", className, { UserList_mobile: mobile })}> <div className={clsx("UserList", className, { UserList_mobile: mobile })}>
{avatars} {avatars}

View File

@ -0,0 +1,273 @@
.excalidraw {
.virgil {
font-family: "Virgil";
}
.WelcomeScreen-logo {
display: flex;
align-items: center;
column-gap: 0.75rem;
font-size: 2.25rem;
svg {
width: 1.625rem;
height: auto;
}
}
.WelcomeScreen-decor {
pointer-events: none;
color: var(--color-gray-40);
&--subheading {
font-size: 1.125rem;
text-align: center;
}
&--help-pointer {
display: flex;
position: absolute;
right: 0;
bottom: 100%;
:root[dir="rtl"] & {
left: 0;
right: auto;
}
svg {
margin-top: 0.5rem;
width: 85px;
height: 71px;
transform: scaleX(-1) rotate(80deg);
:root[dir="rtl"] & {
transform: rotate(80deg);
}
}
}
&--top-toolbar-pointer {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 2.5rem;
display: flex;
align-items: baseline;
&__label {
width: 120px;
position: relative;
top: -0.5rem;
}
svg {
width: 38px;
height: 78px;
:root[dir="rtl"] & {
transform: scaleX(-1);
}
}
}
&--menu-pointer {
position: absolute;
width: 320px;
font-size: 1rem;
top: 100%;
margin-top: 0.25rem;
margin-inline-start: 0.6rem;
display: flex;
align-items: flex-end;
gap: 0.5rem;
svg {
width: 41px;
height: 94px;
:root[dir="rtl"] & {
transform: scaleX(-1);
}
}
}
}
.WelcomeScreen-container {
display: flex;
flex-direction: column;
gap: 2rem;
justify-content: center;
align-items: center;
position: absolute;
pointer-events: none;
left: 1rem;
top: 1rem;
right: 1rem;
bottom: 1rem;
}
.WelcomeScreen-items {
display: flex;
flex-direction: column;
gap: 2px;
justify-content: center;
align-items: center;
}
.WelcomeScreen-item {
box-sizing: border-box;
pointer-events: all;
color: var(--color-gray-50);
font-size: 0.875rem;
min-width: 300px;
display: flex;
align-items: center;
justify-content: space-between;
background: none;
border: none;
padding: 0.75rem;
border-radius: var(--border-radius-md);
&__label {
display: flex;
align-items: center;
column-gap: 0.5rem;
svg {
width: var(--default-icon-size);
height: var(--default-icon-size);
}
}
&__shortcut {
color: var(--color-gray-40);
font-size: 0.75rem;
}
}
&:not(:active) .WelcomeScreen-item:hover {
text-decoration: none;
background: var(--color-gray-10);
.WelcomeScreen-item__shortcut {
color: var(--color-gray-50);
}
.WelcomeScreen-item__label {
color: var(--color-gray-100);
}
}
.WelcomeScreen-item:active {
background: var(--color-gray-20);
.WelcomeScreen-item__shortcut {
color: var(--color-gray-50);
}
.WelcomeScreen-item__label {
color: var(--color-gray-100);
}
&--promo {
color: var(--color-promo) !important;
&:hover {
.WelcomeScreen-item__label {
color: var(--color-promo) !important;
}
}
}
}
&.theme--dark {
.WelcomeScreen-decor {
color: var(--color-gray-60);
}
.WelcomeScreen-item {
color: var(--color-gray-60);
&__shortcut {
color: var(--color-gray-60);
}
}
&:not(:active) .WelcomeScreen-item:hover {
background: var(--color-gray-85);
.WelcomeScreen-item__shortcut {
color: var(--color-gray-50);
}
.WelcomeScreen-item__label {
color: var(--color-gray-10);
}
}
.WelcomeScreen-item:active {
background-color: var(--color-gray-90);
.WelcomeScreen-item__label {
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 {
margin-top: 4rem;
}
}
@media (min-height: 600px) and (max-height: 900px) {
.WelcomeScreen-container {
margin-top: 8rem;
}
}
@media (max-height: 630px) {
.WelcomeScreen-decor--top-toolbar-pointer {
display: none;
}
}
@media (max-height: 500px) {
.WelcomeScreen-container {
display: none;
}
}
// @media (max-height: 740px) {
// .WelcomeScreen-decor {
// &--help-pointer,
// &--top-toolbar-pointer,
// &--menu-pointer {
// display: none;
// }
// }
// }
}

View File

@ -0,0 +1,141 @@
import { useAtom } from "jotai";
import { actionLoadScene, actionShortcuts } from "../actions";
import { ActionManager } from "../actions/manager";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { COOKIES } 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 isExcalidrawPlusSignedUser = document.cookie.includes(
COOKIES.AUTH_STATE_COOKIE,
);
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

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

File diff suppressed because it is too large Load Diff

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