1
0
mirror of https://github.com/excalidraw/excalidraw.git synced 2024-11-02 03:25:53 +01:00

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>
This commit is contained in:
Barnabás Molnár 2022-11-01 17:29:58 +01:00 committed by GitHub
parent 4d26993c8f
commit 6334bd832f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
119 changed files with 4963 additions and 3657 deletions

BIN
public/Assistant-Bold.woff2 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -11,3 +11,28 @@
src: url("Cascadia.woff2");
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;
}

@ -8,49 +8,57 @@
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="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 -->
<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="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 -->
@ -158,8 +166,8 @@
body,
html {
margin: 0;
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Helvetica, Arial, sans-serif;
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system,
Segoe UI, Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
-webkit-text-size-adjust: 100%;

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

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

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

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

@ -1,7 +1,6 @@
import { isSomeElementSelected } from "../scene";
import { KEYS } from "../keys";
import { ToolButton } from "../components/ToolButton";
import { trash } from "../components/icons";
import { t } from "../i18n";
import { register } from "./register";
import { getNonDeletedElements } from "../element";
@ -13,6 +12,7 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
@ -149,7 +149,7 @@ export const actionDeleteSelected = register({
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
type="button"
icon={trash}
icon={TrashIcon}
title={t("labels.delete")}
aria-label={t("labels.delete")}
onClick={() => updateData(null)}

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

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

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

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

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

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

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

@ -135,8 +135,13 @@ export class ActionManager {
/**
* @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;
if (
this.actions[name] &&
"PanelComponent" in this.actions[name] &&
@ -169,6 +174,7 @@ export class ActionManager {
updateData={updateData}
appProps={this.app.props}
data={data}
isInHamburgerMenu={isInHamburgerMenu}
/>
);
}

@ -3,8 +3,11 @@ import { isDarwin } from "../keys";
import { getShortcutKey } from "../utils";
import { ActionName } from "./types";
export type ShortcutName = SubtypeOf<
export type ShortcutName =
| SubtypeOf<
ActionName,
| "toggleTheme"
| "loadScene"
| "cut"
| "copy"
| "paste"
@ -30,9 +33,15 @@ export type ShortcutName = SubtypeOf<
| "flipVertical"
| "hyperlink"
| "toggleLock"
>;
>
| "saveScene"
| "imageExport";
const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shit+Alt+D")],
saveScene: [getShortcutKey("CtrlOrCmd+S")],
loadScene: [getShortcutKey("CtrlOrCmd+O")],
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
cut: [getShortcutKey("CtrlOrCmd+X")],
copy: [getShortcutKey("CtrlOrCmd+C")],
paste: [getShortcutKey("CtrlOrCmd+V")],

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

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

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

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

@ -28,6 +28,8 @@ import { trackEvent } from "../analytics";
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
import clsx from "clsx";
import { actionToggleZenMode } from "../actions";
import "./Actions.scss";
import { Tooltip } from "./Tooltip";
export const SelectedShapeActions = ({
appState,
@ -79,12 +81,16 @@ export const SelectedShapeActions = ({
return (
<div className="panelColumn">
<div>
{((hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" &&
commonSelectedType !== "image") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")}
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
</div>
{showChangeBackgroundIcons && (
<div>{renderAction("changeBackgroundColor")}</div>
)}
{showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(appState.activeTool.type) ||
@ -163,7 +169,16 @@ export const SelectedShapeActions = ({
)}
{targetElements.length > 2 &&
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("alignVerticallyCentered")}
{renderAction("alignBottom")}
@ -203,22 +218,23 @@ export const ShapesSwitcher = ({
appState: AppState;
}) => (
<>
{SHAPES.map(({ value, icon, key }, index) => {
{SHAPES.map(({ value, icon, key, fillable }, index) => {
const numberKey = value === "eraser" ? 0 : index + 1;
const label = t(`toolBar.${value}`);
const letter = key && (typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
: `${index + 1}`;
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numberKey}`
: `${numberKey}`;
return (
<ToolButton
className="Shape"
className={clsx("Shape", { fillable })}
key={value}
type="radio"
icon={icon}
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={`${index + 1}`}
keyBindingLabel={`${numberKey}`}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={value}
@ -263,11 +279,11 @@ export const ZoomActions = ({
renderAction: ActionManager["renderAction"];
zoom: Zoom;
}) => (
<Stack.Col gap={1}>
<Stack.Row gap={1} align="center">
<Stack.Col gap={1} className="zoom-actions">
<Stack.Row align="center">
{renderAction("zoomOut")}
{renderAction("zoomIn")}
{renderAction("resetZoom")}
{renderAction("zoomIn")}
</Stack.Row>
</Stack.Col>
);
@ -280,8 +296,12 @@ export const UndoRedoActions = ({
className?: string;
}) => (
<div className={`undo-redo-buttons ${className}`}>
{renderAction("undo", { size: "small" })}
{renderAction("redo", { size: "small" })}
<div className="undo-button-container">
<Tooltip label={t("buttons.undo")}>{renderAction("undo")}</Tooltip>
</div>
<div className="redo-button-container">
<Tooltip label={t("buttons.redo")}> {renderAction("redo")}</Tooltip>
</div>
</div>
);

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

@ -266,6 +266,10 @@ import {
isLocalLink,
} from "../element/Hyperlink";
import { shouldShowBoundingBox } from "../element/transformHandles";
import { atom } from "jotai";
export const isMenuOpenAtom = atom(false);
export const isDropdownOpenAtom = atom(false);
const deviceContextInitialValue = {
isSmScreen: false,
@ -571,6 +575,11 @@ class App extends React.Component<AppProps, AppState> {
library={this.library}
id={this.id}
onImageAction={this.onImageAction}
renderWelcomeScreen={
this.state.showWelcomeScreen &&
this.state.activeTool.type === "selection" &&
!this.scene.getElementsIncludingDeleted().length
}
/>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
@ -1085,6 +1094,13 @@ class App extends React.Component<AppProps, AppState> {
}
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
if (
!this.state.showWelcomeScreen &&
!this.scene.getElementsIncludingDeleted().length
) {
this.setState({ showWelcomeScreen: true });
}
if (
this.excalidrawContainerRef.current &&
prevProps.UIOptions.dockedSidebarBreakpoint !==
@ -1276,6 +1292,10 @@ class App extends React.Component<AppProps, AppState> {
);
});
const selectionColor = getComputedStyle(
document.querySelector(".excalidraw")!,
).getPropertyValue("--color-selection");
renderScene(
{
elements: renderingElements,
@ -1284,6 +1304,7 @@ class App extends React.Component<AppProps, AppState> {
rc: this.rc!,
canvas: this.canvas!,
renderConfig: {
selectionColor,
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
viewBackgroundColor: this.state.viewBackgroundColor,
@ -1867,8 +1888,16 @@ class App extends React.Component<AppProps, AppState> {
if (event.key === KEYS.QUESTION_MARK) {
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)) {

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

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

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

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

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

@ -1,6 +1,51 @@
@import "../css/variables.module";
.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 {
background-color: var(--button-special-active-bg-color);
@ -24,9 +69,9 @@
bottom: -5px;
padding: 3px;
border-radius: 50%;
background-color: $oc-green-6;
color: $oc-white;
font-size: 0.6em;
background-color: $oc-green-2;
color: $oc-green-9;
font-size: 0.6rem;
font-family: "Cascadia";
}
}

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

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

@ -365,10 +365,12 @@ export const ColorPicker = ({
appState: AppState;
}) => {
const pickerButton = React.useRef<HTMLButtonElement>(null);
const coords = pickerButton.current?.getBoundingClientRect();
return (
<div>
<div className="color-picker-control-container">
<div className="color-picker-label-swatch-container">
<button
className="color-picker-label-swatch"
aria-label={label}
@ -376,6 +378,7 @@ export const ColorPicker = ({
onClick={() => setActive(!isActive)}
ref={pickerButton}
/>
</div>
<ColorInput
color={color}
label={label}
@ -386,6 +389,15 @@ export const ColorPicker = ({
</div>
<React.Suspense fallback="">
{isActive ? (
<div
className="color-picker-popover-container"
style={{
position: "fixed",
top: coords?.top,
left: coords?.right,
zIndex: 1,
}}
>
<Popover
onCloseRequest={(event) =>
event.target !== pickerButton.current && setActive(false)
@ -407,6 +419,7 @@ export const ColorPicker = ({
elements={elements}
/>
</Popover>
</div>
) : null}
</React.Suspense>
</div>

@ -4,34 +4,8 @@
.confirm-dialog {
&-buttons {
display: flex;
padding: 0.2rem 0;
column-gap: 0.5rem;
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;
}
}
}
}

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

@ -7,68 +7,11 @@
}
.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;
}
.Dialog__content {
padding: 0 16px 16px;
}
@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;
}
text-align: left;
font-size: 1.25rem;
border-bottom: 1px solid var(--dialog-border-color);
padding: 0 0 0.75rem;
margin-bottom: 1.5rem;
}
}

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

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

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

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

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

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

@ -1,5 +1,6 @@
import clsx from "clsx";
import { ActionManager } from "../actions/manager";
import { t } from "../i18n";
import { AppState, ExcalidrawProps } from "../types";
import {
ExitZenModeAction,
@ -8,20 +9,23 @@ import {
ZoomActions,
} from "./Actions";
import { useDevice } from "./App";
import { Island } from "./Island";
import { WelcomeScreenHelpArrow } from "./icons";
import { Section } from "./Section";
import Stack from "./Stack";
import WelcomeScreenDecor from "./WelcomeScreenDecor";
const Footer = ({
appState,
actionManager,
renderCustomFooter,
showExitZenModeBtn,
renderWelcomeScreen,
}: {
appState: AppState;
actionManager: ActionManager;
renderCustomFooter?: ExcalidrawProps["renderFooter"];
showExitZenModeBtn: boolean;
renderWelcomeScreen: boolean;
}) => {
const device = useDevice();
const showFinalize =
@ -39,14 +43,12 @@ const Footer = ({
>
<Stack.Col gap={2}>
<Section heading="canvasActions">
<Island padding={1}>
<ZoomActions
renderAction={actionManager.renderAction}
zoom={appState.zoom}
/>
</Island>
{!appState.viewModeEnabled && (
<>
<UndoRedoActions
renderAction={actionManager.renderAction}
className={clsx("zen-mode-transition", {
@ -54,16 +56,6 @@ const Footer = ({
appState.zenModeEnabled,
})}
/>
<div
className={clsx("eraser-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
</div>
</>
)}
{showFinalize && (
<FinalizeAction
@ -93,8 +85,19 @@ const Footer = ({
"transition-right disable-pointerEvents": appState.zenModeEnabled,
})}
>
<div style={{ position: "relative" }}>
<WelcomeScreenDecor
shouldRender={renderWelcomeScreen && !appState.isLoading}
>
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--help-pointer">
<div>{t("welcomeScreen.helpHints")}</div>
{WelcomeScreenHelpArrow}
</div>
</WelcomeScreenDecor>
{actionManager.renderAction("toggleShortcuts")}
</div>
</div>
<ExitZenModeAction
actionManager={actionManager}
showExitZenModeBtn={showExitZenModeBtn}

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

@ -1,56 +1,115 @@
@import "../css/variables.module";
.excalidraw {
.HelpDialog h3 {
border-bottom: 1px solid var(--button-gray-2);
padding-bottom: 4px;
.HelpDialog {
.Modal__content {
max-width: 960px;
}
.HelpDialog--island {
border: 1px solid var(--button-gray-2);
margin-bottom: 16px;
h3 {
margin: 1.5rem 0;
font-weight: bold;
font-size: 1.125rem;
}
.HelpDialog--island-title {
margin: 0;
padding: 4px;
background-color: var(--button-gray-1);
text-align: center;
}
.HelpDialog--shortcut {
border-top: 1px solid var(--button-gray-2);
}
.HelpDialog--key {
word-break: keep-all;
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;
&__header {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
&__btn {
display: flex;
column-gap: 0.5rem;
align-items: center;
font-family: inherit;
}
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--header {
display: flex;
flex-direction: row;
justify-content: space-evenly;
margin-bottom: 32px;
padding-bottom: 16px;
}
.HelpDialog--btn {
border: 1px solid var(--link-color);
padding: 8px 32px;
border-radius: 4px;
}
.HelpDialog--btn:hover {
&:hover {
text-decoration: none;
}
}
&__link-icon {
line-height: 0;
svg {
width: 1rem;
height: 1rem;
}
}
&__islands-container {
display: grid;
@media screen and (min-width: 1024px) {
grid-template-columns: 1fr 1fr;
}
grid-column-gap: 1.5rem;
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;
}
}
}

@ -4,32 +4,36 @@ import { isDarwin, isWindows } from "../keys";
import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils";
import "./HelpDialog.scss";
import { ExternalLinkIcon } from "./icons";
const Header = () => (
<div className="HelpDialog--header">
<div className="HelpDialog__header">
<a
className="HelpDialog--btn"
className="HelpDialog__btn"
href="https://github.com/excalidraw/excalidraw#documentation"
target="_blank"
rel="noopener noreferrer"
>
{t("helpDialog.documentation")}
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
</a>
<a
className="HelpDialog--btn"
className="HelpDialog__btn"
href="https://blog.excalidraw.com"
target="_blank"
rel="noopener noreferrer"
>
{t("helpDialog.blog")}
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
</a>
<a
className="HelpDialog--btn"
className="HelpDialog__btn"
href="https://github.com/excalidraw/excalidraw/issues"
target="_blank"
rel="noopener noreferrer"
>
{t("helpDialog.github")}
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
</a>
</div>
);
@ -37,88 +41,61 @@ const Header = () => (
const Section = (props: { title: string; children: React.ReactNode }) => (
<>
<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: {
caption: string;
children: React.ReactNode;
className?: string;
}) => (
<div className="HelpDialog--island">
<h3 className="HelpDialog--island-title">{props.caption}</h3>
{props.children}
<div className={`HelpDialog__island ${props.className}`}>
<h4 className="HelpDialog__island-title">{props.caption}</h4>
<div className="HelpDialog__island-content">{props.children}</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;
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 (
<div className="HelpDialog--shortcut">
<div
style={{
display: "flex",
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 className="HelpDialog__shortcut">
<div>{label}</div>
<div className="HelpDialog__key-container">
{[...intersperse(splitShortcutKeys, isOr ? t("helpDialog.or") : null)]}
</div>
</div>
);
};
Shortcut.defaultProps = {
isOr: true,
};
const ShortcutKey = (props: { children: React.ReactNode }) => (
<kbd className="HelpDialog--key" {...props} />
<kbd className="HelpDialog__key" {...props} />
);
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
@ -137,17 +114,12 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
>
<Header />
<Section title={t("helpDialog.shortcuts")}>
<Columns>
<Column>
<ShortcutIsland caption={t("helpDialog.tools")}>
<Shortcut
label={t("toolBar.selection")}
shortcuts={["V", "1"]}
/>
<Shortcut
label={t("toolBar.rectangle")}
shortcuts={["R", "2"]}
/>
<ShortcutIsland
className="HelpDialog__island--tools"
caption={t("helpDialog.tools")}
>
<Shortcut label={t("toolBar.selection")} shortcuts={["V", "1"]} />
<Shortcut label={t("toolBar.rectangle")} shortcuts={["R", "2"]} />
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
<Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} />
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
@ -165,10 +137,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/>
<Shortcut
label={t("helpDialog.editSelectedShape")}
shortcuts={[
getShortcutKey("Enter"),
t("helpDialog.doubleClick"),
]}
shortcuts={[getShortcutKey("Enter"), t("helpDialog.doubleClick")]}
/>
<Shortcut
label={t("helpDialog.textNewLine")}
@ -214,7 +183,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
/>
</ShortcutIsland>
<ShortcutIsland caption={t("helpDialog.view")}>
<ShortcutIsland
className="HelpDialog__island--view"
caption={t("helpDialog.view")}
>
<Shortcut
label={t("buttons.zoomIn")}
shortcuts={[getShortcutKey("CtrlOrCmd++")]}
@ -257,9 +229,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[getShortcutKey("Alt+/")]}
/>
</ShortcutIsland>
</Column>
<Column>
<ShortcutIsland caption={t("helpDialog.editor")}>
<ShortcutIsland
className="HelpDialog__island--editor"
caption={t("helpDialog.editor")}
>
<Shortcut
label={t("labels.selectAll")}
shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
@ -270,15 +243,11 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/>
<Shortcut
label={t("helpDialog.deepSelect")}
shortcuts={[
getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`),
]}
shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`)]}
/>
<Shortcut
label={t("helpDialog.deepBoxSelect")}
shortcuts={[
getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`),
]}
shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`)]}
/>
<Shortcut
label={t("labels.moveCanvas")}
@ -415,8 +384,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]}
/>
</ShortcutIsland>
</Column>
</Columns>
</Section>
</Dialog>
</>

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

@ -10,7 +10,8 @@
.picker {
background: var(--popup-bg-color);
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;
position: absolute;
}
@ -46,7 +47,6 @@
margin: 0;
width: 36px;
height: 18px;
opacity: 0.6;
pointer-events: none;
}
}

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

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

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

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

@ -16,8 +16,10 @@
height: 100%;
pointer-events: none;
z-index: var(--zIndex-layerUI);
&__top-right {
display: flex;
gap: 0.75rem;
}
&__footer {
@ -48,13 +50,6 @@
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 {
transform: translate(0, 92px);
}
@ -97,14 +92,9 @@
pointer-events: all;
}
.layer-ui__wrapper__footer-left {
margin-bottom: 0.2em;
}
.layer-ui__wrapper__footer-right {
margin-top: auto;
margin-bottom: auto;
margin-inline-end: 1em;
}
}
}

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

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

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

@ -35,103 +35,32 @@
}
}
.library-actions {
width: 100%;
.library-actions-counter {
background-color: var(--color-primary);
color: var(--color-primary-light);
font-weight: bold;
display: flex;
margin-right: auto;
align-items: center;
button .library-actions-counter {
position: absolute;
right: 2px;
bottom: 2px;
justify-content: center;
border-radius: 50%;
width: 1em;
height: 1em;
padding: 1px;
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;
}
}
width: 1rem;
height: 1rem;
position: absolute;
bottom: -0.25rem;
right: -0.25rem;
font-size: 0.625rem;
pointer-events: none;
}
.layer-ui__library-message {
padding: 2em 4em;
padding: 2rem;
min-width: 200px;
display: flex;
flex-direction: column;
align-items: center;
.Spinner {
margin-bottom: 1em;
}
flex-grow: 1;
justify-content: center;
span {
font-size: 0.8em;
}
@ -159,11 +88,10 @@
}
.library-menu-browse-button {
width: 80%;
min-height: 22px;
margin: 0 auto;
margin-top: 1rem;
padding: 10px;
margin: 1rem auto;
padding: 0.875rem 1rem;
display: flex;
align-items: center;
justify-content: center;
@ -176,6 +104,10 @@
text-align: center;
white-space: nowrap;
text-decoration: none !important;
font-weight: 600;
font-size: 0.75rem;
&:hover {
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 {
min-height: 22px;
margin-left: auto;

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

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

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

@ -1,18 +1,70 @@
@import "open-color/open-color";
.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 {
display: flex;
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
overflow-y: auto;
flex-direction: column;
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 {
row-gap: 0.5rem;
padding: var(--container-padding-y) var(--container-padding-x);
flex: 1;
overflow-y: auto;
overflow-x: hidden;
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 {
width: 100%;
display: flex;

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

@ -7,17 +7,18 @@
display: flex;
justify-content: center;
position: relative;
width: 63px;
height: 63px; // match width
width: 55px;
height: 55px;
box-sizing: border-box;
border-radius: var(--border-radius-lg);
&--hover {
box-shadow: inset 0px 0px 0px 2px $oc-blue-5;
border-color: $oc-blue-5;
border-color: var(--color-primary);
}
&--selected {
box-shadow: inset 0px 0px 0px 2px $oc-blue-8;
border-color: $oc-blue-8;
border-color: var(--color-primary);
border-width: 1px;
}
}
@ -59,20 +60,34 @@
.library-unit__checkbox {
position: absolute;
left: 2.3rem;
bottom: 2.3rem;
top: 0.125rem;
right: 0.125rem;
margin: 0;
.Checkbox-box {
width: 13px;
height: 13px;
border-radius: 2px;
margin: 0.5em 0.5em 0.2em 0.2em;
background-color: $oc-blue-1;
margin: 0;
width: 1rem;
height: 1rem;
border-radius: 4px;
background-color: var(--color-primary-light);
border: 1px solid var(--color-primary);
box-shadow: none !important;
padding: 2px;
}
&.Checkbox:hover {
.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 {
transform: scale(1);
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 {
animation: none;
transform: scale(0.8);
fill: $oc-black;
}
.library-unit__active {

@ -6,19 +6,7 @@ import { exportToSvg } from "../scene/export";
import { LibraryItem } from "../types";
import "./LibraryUnit.scss";
import { CheckboxItem } from "./CheckboxItem";
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>
);
import { PlusIcon } from "./icons";
export const LibraryUnit = ({
id,
@ -67,7 +55,7 @@ export const LibraryUnit = ({
const [isHovered, setIsHovered] = useState(false);
const isMobile = useDevice().isMobile;
const adder = isPending && (
<div className="library-unit__adder">{PLUS_ICON}</div>
<div className="library-unit__adder">{PlusIcon}</div>
);
return (

@ -1,8 +1,8 @@
import "./ToolIcon.scss";
import React from "react";
import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
import { LockedIcon, UnlockedIcon } from "./icons";
type LockIconProps = {
title?: string;
@ -16,34 +16,15 @@ type LockIconProps = {
const DEFAULT_SIZE: ToolButtonSize = "medium";
const ICONS = {
CHECKED: (
<svg
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>
),
CHECKED: LockedIcon,
UNCHECKED: UnlockedIcon,
};
export const LockButton = (props: LockIconProps) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
"ToolIcon ToolIcon__lock",
`ToolIcon_size_${DEFAULT_SIZE}`,
{
"is-mobile": props.isMobile,

85
src/components/Menu.scss Normal 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;
}
}
}

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

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

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

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

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

@ -2,6 +2,7 @@ import "./ToolIcon.scss";
import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
import { PenModeIcon } from "./icons";
type PenModeIconProps = {
title?: string;
@ -15,59 +16,15 @@ type PenModeIconProps = {
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) => {
if (!props.penDetected) {
if (props.isMobile) {
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 (
<label
className={clsx(
"ToolIcon ToolIcon__penMode ToolIcon_type_floating",
"ToolIcon ToolIcon__penMode",
`ToolIcon_size_${DEFAULT_SIZE}`,
{
"is-mobile": props.isMobile,
@ -83,9 +40,7 @@ export const PenModeButton = (props: PenModeIconProps) => {
checked={props.checked}
aria-label={props.title}
/>
<div className="ToolIcon__icon">
{props.checked ? ICONS.CHECKED : ICONS.UNCHECKED}
</div>
<div className="ToolIcon__icon">{PenModeIcon}</div>
</label>
);
};

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

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

@ -2,20 +2,101 @@
@import "../../css/variables.module";
.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 {
position: absolute;
top: var(--sat);
bottom: var(--sab);
right: var(--sar);
top: 0;
bottom: 0;
right: 0;
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;
border-radius: var(--border-radius-lg);
margin: var(--space-factor);
border-radius: 0;
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;
.Island {
@ -48,42 +129,18 @@
}
.layer-ui__sidebar__header {
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin: 2px 0 15px 0;
&:empty {
margin: 0;
}
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
}
padding: 1rem;
border-bottom: 1px solid var(--sidebar-border-color);
}
.layer-ui__sidebar__header__buttons {
display: flex;
align-items: center;
margin-left: auto;
}
.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);
}
}
gap: 0.625rem;
}
}

@ -33,6 +33,13 @@ export const Sidebar = Object.assign(
onClose,
onDock,
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,
className,
__isInternal,
@ -52,7 +59,9 @@ export const Sidebar = Object.assign(
const setAppState = useExcalidrawSetAppState();
const [isDockedFallback, setIsDockedFallback] = useState(docked ?? false);
const [isDockedFallback, setIsDockedFallback] = useState(
docked ?? initialDockedState ?? false,
);
useLayoutEffect(() => {
if (docked === undefined) {
@ -119,8 +128,11 @@ export const Sidebar = Object.assign(
return (
<Island
padding={2}
className={clsx("layer-ui__sidebar", className)}
className={clsx(
"layer-ui__sidebar",
{ "layer-ui__sidebar--docked": isDockedFallback },
className,
)}
ref={ref}
>
<SidebarPropsContext.Provider value={headerPropsRef.current}>

@ -3,16 +3,10 @@ import { useContext } from "react";
import { t } from "../../i18n";
import { useDevice } from "../App";
import { SidebarPropsContext } from "./common";
import { close } from "../icons";
import { CloseIcon, PinIcon } from "../icons";
import { withUpstreamOverride } from "../hoc/withUpstreamOverride";
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: {
checked: boolean;
onChange?(): void;
@ -33,8 +27,13 @@ export const SidebarDockButton = (props: {
checked={props.checked}
aria-label={t("labels.sidebarLock")}
/>{" "}
<div className="ToolIcon__icon" tabIndex={0}>
{SIDE_LIBRARY_TOGGLE_ICON}
<div
className={clsx("Sidebar__pin-btn", {
"Sidebar__pin-btn--pinned": props.checked,
})}
tabIndex={0}
>
{PinIcon}
</div>{" "}
</label>{" "}
</Tooltip>
@ -64,24 +63,19 @@ const _SidebarHeader: React.FC<{
<SidebarDockButton
checked={!!props.docked}
onChange={() => {
document
.querySelector(".layer-ui__wrapper")
?.classList.add("animate");
props.onDock?.(!props.docked);
}}
/>
)}
{renderCloseButton && (
<div className="ToolIcon__icon__close" data-testid="sidebar-close">
<button
className="Modal__close"
data-testid="sidebar-close"
className="Sidebar__close-btn"
onClick={props.onClose}
aria-label={t("buttons.close")}
>
{close}
{CloseIcon}
</button>
</div>
)}
</div>
)}

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

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

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

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

@ -3,12 +3,19 @@
.excalidraw {
.ToolIcon {
border-radius: var(--border-radius-lg);
display: inline-flex;
align-items: center;
position: relative;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
user-select: none;
&__hidden {
display: none !important;
}
@include toolbarButtonColorStates;
}
.ToolIcon--plain {
@ -21,21 +28,15 @@
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
& + .ToolIcon__icon {
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
}
position: absolute;
opacity: 0;
pointer-events: none;
}
.ToolIcon__icon {
width: 2.5rem;
height: 2.5rem;
box-sizing: border-box;
width: var(--default-button-size);
height: var(--default-button-size);
color: var(--icon-fill-color);
display: flex;
@ -50,8 +51,8 @@
svg {
position: relative;
height: 1em;
fill: var(--icon-fill-color);
width: var(--default-icon-size);
height: var(--default-icon-size);
color: var(--icon-fill-color);
}
}
@ -75,13 +76,14 @@
font-size: 0.8em;
}
.excalidraw .ToolIcon_type_button,
.ToolIcon_type_button,
.Modal .ToolIcon_type_button,
.ToolIcon_type_button {
padding: 0;
border: none;
margin: 0;
font-size: inherit;
background-color: initial;
&:focus-visible {
box-shadow: 0 0 0 2px var(--focus-highlight-color);
@ -95,9 +97,9 @@
}
}
&:hover {
background-color: var(--button-gray-2);
}
// &:hover {
// background-color: var(--button-gray-2);
// }
&:active {
background-color: var(--button-gray-3);
@ -108,29 +110,8 @@
}
&--hide {
visibility: hidden;
}
}
.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);
// visibility: hidden;
display: none !important;
}
}
@ -163,66 +144,12 @@
position: absolute;
bottom: 2px;
right: 3px;
font-size: 0.5em;
font-size: 0.625rem;
color: var(--keybinding-color);
font-family: var(--ui-font);
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 {
:root[dir="ltr"] & {
left: 2px;
@ -232,4 +159,16 @@
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);
}
}
}
}

@ -2,101 +2,20 @@
@import "../css/variables.module";
.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 {
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 {
.ToolIcon__keybinding,
.HintViewer {
display: none;
}
}
}
&.theme--dark .App-toolbar .ToolIcon:active {
--icon-fill-color: #{$oc-gray-3};
--keybinding-color: #{$oc-gray-3};
&__divider {
width: 1px;
height: 1.5rem;
align-self: center;
background-color: var(--default-border-color);
margin: 0 0.5rem;
}
}
}

@ -7,23 +7,30 @@
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.625rem;
&:empty {
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 > * {
pointer-events: all;
margin: 0 0 var(--space-factor) var(--space-factor);
}
.UserList_mobile {
padding: 0;
justify-content: normal;
}
.UserList_mobile > * {
margin: 0 var(--space-factor) var(--space-factor) 0;
margin: 0.5rem 0;
}
}

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

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

@ -0,0 +1,131 @@
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 {
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 = ({ actionManager }: { actionManager: ActionManager }) => {
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
let subheadingJSX;
if (isExcalidrawPlusSignedUser) {
subheadingJSX = t("welcomeScreen.switchToPlusApp")
.split(/(Excalidraw\+)/)
.map((bit) => {
if (bit === "Excalidraw+") {
return (
<a
style={{ pointerEvents: "all" }}
href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
>
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">
<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;

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

@ -19,6 +19,10 @@
height: 100%;
width: 100%;
button {
cursor: pointer;
}
&:focus {
outline: none;
}
@ -85,15 +89,16 @@
.panelColumn {
display: flex;
flex-direction: column;
row-gap: 0.75rem;
h3,
legend,
.control-label {
margin-top: 0.333rem;
margin-bottom: 0.333rem;
margin: 0;
margin-bottom: 0.25rem;
font-size: 0.75rem;
color: var(--text-primary-color);
font-weight: bold;
font-weight: normal;
display: block;
}
@ -102,12 +107,6 @@
width: 100%;
}
h3:first-child,
legend:first-child,
.control-label:first-child {
margin-top: 0;
}
legend {
padding: 0;
}
@ -119,11 +118,12 @@
.buttonList {
flex-wrap: wrap;
display: flex;
column-gap: 0.5rem;
row-gap: 0.5rem;
label {
margin-right: 0.25rem;
font-size: 0.75rem;
display: inline-block;
}
input[type="radio"],
@ -136,38 +136,10 @@
.iconRow {
margin-top: 8px;
}
.ToolIcon {
margin: 0;
margin-inline-end: 8px;
&:focus {
outline: transparent;
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
&:disabled {
cursor: not-allowed;
}
}
.ToolIcon__icon {
width: 28px;
height: 28px;
}
}
fieldset {
margin: 0;
margin-top: 0.333rem;
padding: 0;
border: none;
}
@ -185,64 +157,26 @@
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
.buttonList {
.ToolIcon__icon {
all: unset !important;
display: flex !important;
}
button {
background-color: transparent;
}
label,
button,
.buttonList label {
user-select: none;
background-color: var(--button-gray-1);
border: 0;
border-radius: var(--border-radius-md);
margin: 0.125rem 0;
padding: 0.25rem;
white-space: nowrap;
.zIndexButton {
@include outlineButtonStyles;
cursor: pointer;
padding: 0;
&:focus-visible {
outline: transparent;
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
&:disabled {
cursor: not-allowed;
}
}
.active,
.buttonList label.active {
background-color: var(--color-primary);
--icon-fill-color: #{$oc-white};
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
}
.buttonList.buttonListIcon {
label {
display: inline-flex;
justify-content: center;
align-items: center;
svg {
width: 35px;
height: 14px;
padding: 2px;
opacity: 0.6;
}
&.active svg {
opacity: 1;
width: var(--default-icon-size);
height: var(--default-icon-size);
}
}
}
@ -289,8 +223,6 @@
.App-toolbar {
width: 100%;
box-sizing: border-box;
.eraser {
&.ToolIcon:hover {
--icon-fill-color: #fff;
@ -322,12 +254,27 @@
color: var(--icon-fill-color);
}
.shapes-section {
display: flex;
justify-content: center;
pointer-events: none !important;
& > * {
pointer-events: all;
}
}
.App-menu_top {
grid-template-columns: auto max-content auto;
grid-gap: 4px;
grid-template-columns: 1fr 2fr 1fr;
grid-gap: 2rem;
align-items: flex-start;
cursor: default;
pointer-events: none !important;
@media (min-width: 1536px) {
grid-template-columns: 1fr 1fr 1fr;
grid-gap: 3rem;
}
}
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_top > * {
@ -344,20 +291,14 @@
.App-menu_bottom {
position: absolute;
bottom: 0;
grid-template-columns: min-content auto min-content;
grid-gap: 15px;
bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
cursor: default;
pointer-events: none !important;
:root[dir="ltr"] & {
left: 0.25rem;
}
:root[dir="rtl"] & {
right: 0.25rem;
}
box-sizing: border-box;
padding: 0 1rem;
&--transition-left {
section {
@ -390,7 +331,10 @@
.App-menu__left {
overflow-y: auto;
box-shadow: var(--shadow-island);
padding: 0.75rem;
width: 202px;
box-sizing: border-box;
position: absolute;
}
.dropdown-select {
@ -426,55 +370,65 @@
&:active {
background-color: var(--button-gray-2);
}
&__language {
height: 2rem;
background-color: var(--island-bg-color);
border-color: var(--default-border-color) !important;
cursor: pointer;
&:hover {
background-color: var(--island-bg-color);
}
}
}
.zIndexButton {
margin: 0;
margin-inline-end: 8px;
padding: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
.disable-zen-mode {
border-radius: var(--border-radius-lg);
background-color: var(--color-gray-20);
border: 1px solid var(--color-gray-30);
padding: 10px 20px;
svg {
width: 18px;
height: 18px;
&:hover {
background-color: var(--color-gray-30);
}
}
.scroll-back-to-content {
color: var(--popup-text-color);
border-radius: var(--border-radius-lg);
background-color: var(--island-bg-color);
color: var(--icon-fill-color);
border: 1px solid var(--default-border-color);
padding: 10px 20px;
position: absolute;
left: 50%;
bottom: 30px;
transform: translateX(-50%);
padding: 10px 20px;
pointer-events: all;
&:hover {
background-color: var(--button-hover);
}
&:active {
border: 1px solid var(--color-primary-darkest);
}
}
.help-icon {
display: flex;
cursor: pointer;
fill: $oc-gray-6;
padding: 0;
margin: 0;
background: none;
color: var(--icon-fill-color);
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: 1.5rem;
height: 1.5rem;
}
&:hover {
background: none;
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
.reset-zoom-button {
padding: 0.2em;
background: transparent;
color: var(--text-primary-color);
font-family: var(--ui-font);
}
@ -491,7 +445,6 @@
.eraser-buttons {
display: grid;
grid-auto-flow: column;
gap: 0.4em;
margin-top: auto;
margin-bottom: auto;
margin-inline-start: 0.6em;
@ -572,17 +525,49 @@
// use custom, minimalistic scrollbar
// (doesn't work in Firefox)
::-webkit-scrollbar {
width: 5px;
width: 3px;
}
::-webkit-scrollbar-thumb {
background: var(--button-gray-2);
background: var(--scrollbar-thumb);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--button-gray-3);
background: var(--scrollbar-thumb-hover);
}
::-webkit-scrollbar-thumb:active {
background: var(--button-gray-2);
background: var(--scrollbar-thumb);
}
.mobile-misc-tools-container {
position: fixed;
top: 5rem;
right: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--sidebar-border-color);
border-top-left-radius: var(--border-radius-lg);
border-bottom-left-radius: var(--border-radius-lg);
border-right: 0;
background-color: var(--island-bg-color);
.ToolIcon__icon {
border-radius: 0;
}
.library-button {
border: 0;
}
}
.App-toolbar--mobile {
overflow-x: hidden;
max-width: 100vw;
.ToolIcon__keybinding {
display: none;
}
}
}

@ -9,10 +9,10 @@
--button-gray-2: #{$oc-gray-4};
--button-gray-3: #{$oc-gray-5};
--button-special-active-bg-color: #{$oc-green-0};
--dialog-border-color: #{$oc-gray-6};
--dialog-border-color: var(--color-gray-20);
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
--focus-highlight-color: #{$oc-blue-2};
--icon-fill-color: #{$oc-gray-9};
--icon-fill-color: var(--color-gray-80);
--icon-green-fill-color: #{$oc-green-9};
--default-bg-color: #{$oc-white};
--input-bg-color: #{$oc-white};
@ -20,7 +20,7 @@
--input-hover-bg-color: #{$oc-gray-1};
--input-label-color: #{$oc-gray-7};
--island-bg-color: rgba(255, 255, 255, 0.96);
--keybinding-color: #{$oc-gray-5};
--keybinding-color: var(--color-gray-40);
--link-color: #{$oc-blue-7};
--overlay-bg-color: #{transparentize($oc-white, 0.12)};
--popup-bg-color: #{$oc-white};
@ -32,22 +32,75 @@
--sar: env(safe-area-inset-right);
--sat: env(safe-area-inset-top);
--select-highlight-color: #{$oc-blue-5};
--shadow-island: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 12%);
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
0px 0px 0.931014px rgba(0, 0, 0, 0.1702);
--button-hover: var(--color-gray-10);
--default-border-color: var(--color-gray-30);
--default-button-size: 2rem;
--default-icon-size: 1rem;
--lg-button-size: 2.25rem;
--lg-icon-size: 1rem;
@media screen and (min-device-width: 1921px) {
--lg-button-size: 2.5rem;
--lg-icon-size: 1.25rem;
--default-button-size: 2.25rem;
--default-icon-size: 1.25rem;
}
--scrollbar-thumb: var(--button-gray-2);
--scrollbar-thumb-hover: var(--button-gray-3);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
0px 12.5216px 10.0172px rgba(0, 0, 0, 0.035),
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
--avatar-border-color: var(--color-gray-20);
--sidebar-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
0px 12.5216px 10.0172px rgba(0, 0, 0, 0.035),
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
--sidebar-border-color: var(--color-gray-20);
--sidebar-bg-color: #fff;
--library-dropdown-shadow: 0px 15px 6px rgba(0, 0, 0, 0.01),
0px 8px 5px rgba(0, 0, 0, 0.05), 0px 4px 4px rgba(0, 0, 0, 0.09),
0px 1px 2px rgba(0, 0, 0, 0.1), 0px 0px 0px rgba(0, 0, 0, 0.1);
--space-factor: 0.25rem;
--text-primary-color: #{$oc-gray-8};
--text-primary-color: var(--color-gray-80);
--color-selection: #6965db;
--color-primary: #6965db;
--color-primary-darker: #5b57d1;
--color-primary-darkest: #4a47b1;
--color-primary-light: #e2e1fc;
--color-primary-light: #e3e2fe;
--color-gray-10: #f5f5f5;
--color-gray-20: #ebebeb;
--color-gray-30: #d6d6d6;
--color-gray-40: #b8b8b8;
--color-gray-50: #999999;
--color-gray-60: #7a7a7a;
--color-gray-70: #5c5c5c;
--color-gray-80: #3d3d3d;
--color-gray-85: #242424;
--color-gray-90: #1e1e1e;
--color-gray-100: #121212;
--color-danger: #db6965;
--color-promo: #e70078;
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
&.theme--dark {
background: $oc-black;
&.theme--dark-background-none {
background: none;
}
@ -57,22 +110,23 @@
--theme-filter: #{$theme-filter};
--button-destructive-bg-color: #5a0000;
--button-destructive-color: #{$oc-red-3};
--button-gray-1: #363636;
--button-gray-2: #272727;
--button-gray-3: #222;
--button-special-active-bg-color: #204624;
--dialog-border-color: #{$oc-gray-9};
--dialog-border-color: var(--color-gray-80);
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
--focus-highlight-color: #{$oc-blue-6};
--icon-fill-color: #{$oc-gray-4};
--icon-fill-color: var(--color-gray-40);
--icon-green-fill-color: #{$oc-green-4};
--default-bg-color: #121212;
--input-bg-color: #121212;
--input-border-color: #2e2e2e;
--input-hover-bg-color: #181818;
--input-label-color: #{$oc-gray-2};
--island-bg-color: rgba(30, 30, 30, 0.98);
--keybinding-color: #{$oc-gray-6};
--island-bg-color: #262627;
--keybinding-color: var(--color-gray-60);
--link-color: #{$oc-blue-4};
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
--popup-bg-color: #2c2c2c;
@ -80,12 +134,35 @@
--popup-text-color: #{$oc-gray-4};
--popup-text-inverted-color: #2c2c2c;
--select-highlight-color: #{$oc-blue-4};
--shadow-island: 1px 1px 5px #{transparentize($oc-black, 0.7)};
--text-primary-color: #{$oc-gray-4};
--text-primary-color: var(--color-gray-40);
--button-hover: var(--color-gray-80);
--default-border-color: var(--color-gray-80);
--shadow-island: 0px 13px 33px rgba(0, 0, 0, 0.07),
0px 4.13px 9.94853px rgba(0, 0, 0, 0.0456112),
0px 1.13px 4.13211px rgba(0, 0, 0, 0.035),
0px 0.769896px 1.4945px rgba(0, 0, 0, 0.0243888);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
0px 12.5216px 10.0172px rgba(0, 0, 0, 0.035),
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
--avatar-border-color: var(--color-gray-85);
--sidebar-border-color: var(--color-gray-85);
--sidebar-bg-color: #191919;
--color-primary: #5650f0;
--color-primary-darker: #4b46d8;
--color-primary-darkest: #3e39be;
--color-primary-light: #3f3d64;
--scrollbar-thumb: #{$oc-gray-8};
--scrollbar-thumb-hover: #{$oc-gray-7};
// will be inverted to a lighter color.
--color-selection: #3530c4;
--color-primary: #a8a5ff;
--color-primary-darker: #b2aeff;
--color-primary-darkest: #beb9ff;
--color-primary-light: #4f4d6f;
--color-danger: #ffa8a5;
--color-promo: #d297ff;
}
}

@ -7,18 +7,28 @@
}
@mixin toolbarButtonColorStates {
&.fillable {
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
& + .ToolIcon__icon:active {
background: var(--color-primary-light);
}
&:checked + .ToolIcon__icon {
background: var(--color-primary);
--icon-fill-color: #{$oc-white};
--keybinding-color: #{$oc-white};
--icon-fill-color: var(--color-primary-darker);
svg {
fill: var(--icon-fill-color);
}
}
}
}
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
&:checked + .ToolIcon__icon {
background: var(--color-primary-light);
--keybinding-color: var(--color-gray-60);
svg {
color: var(--color-primary-darker);
}
&:checked + .ToolIcon__icon:active {
background: var(--color-primary-darker);
}
}
@ -26,6 +36,56 @@
bottom: 4px;
right: 4px;
}
.ToolIcon__icon {
&:hover {
background: var(--button-hover);
}
&:active {
background: var(--button-hover);
border: 1px solid var(--color-primary-darkest);
}
}
}
@mixin outlineButtonStyles {
display: flex;
justify-content: center;
align-items: center;
padding: 0.625rem;
width: var(--default-button-size);
height: var(--default-button-size);
box-sizing: border-box;
border-width: 1px;
border-style: solid;
border-color: var(--default-border-color);
border-radius: var(--border-radius-lg);
cursor: pointer;
background-color: transparent;
color: var(--text-primary-color);
&:hover {
background-color: var(--button-hover);
}
&:active {
background-color: var(--button-hover);
border-color: var(--color-primary-darkest);
}
&.active {
background-color: var(--color-primary-light);
border-color: var(--color-primary-light);
&:hover {
background-color: var(--color-primary-light);
}
svg {
color: var(--color-primary-darker);
}
}
}
$theme-filter: "invert(93%) hue-rotate(180deg)";

@ -10,7 +10,7 @@ import { NonDeletedExcalidrawElement } from "./types";
import { register } from "../actions/register";
import { ToolButton } from "../components/ToolButton";
import { editIcon, link, trash } from "../components/icons";
import { FreedrawIcon, LinkIcon, TrashIcon } from "../components/icons";
import { t } from "../i18n";
import {
useCallback,
@ -197,7 +197,7 @@ export const Hyperlink = ({
label={t("buttons.edit")}
onClick={onEdit}
className="excalidraw-hyperlinkContainer--edit"
icon={editIcon}
icon={FreedrawIcon}
/>
)}
@ -209,7 +209,7 @@ export const Hyperlink = ({
label={t("buttons.remove")}
onClick={handleRemove}
className="excalidraw-hyperlinkContainer--remove"
icon={trash}
icon={TrashIcon}
/>
)}
</div>
@ -277,7 +277,7 @@ export const actionLink = register({
return (
<ToolButton
type="button"
icon={link}
icon={LinkIcon}
aria-label={t(getContextMenuLabel(elements, appState))}
title={`${t("labels.link.label")} - ${getShortcutKey("CtrlOrCmd+K")}`}
onClick={() => updateData(null)}

@ -100,7 +100,7 @@ export const getTransformHandlesFromCoords = (
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const dashedLineMargin = margin / zoom.value;
const centeringOffset = (size - 8) / (2 * zoom.value);
const centeringOffset = (size - DEFAULT_SPACING * 2) / (2 * zoom.value);
const transformHandles: TransformHandles = {
nw: omitSides.nw
@ -253,7 +253,7 @@ export const getTransformHandles = (
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
}
const dashedLineMargin = isLinearElement(element)
? DEFAULT_SPACING * 3
? DEFAULT_SPACING + 8
: DEFAULT_SPACING;
return getTransformHandlesFromCoords(
getElementAbsoluteCoords(element),

@ -1,6 +1,10 @@
@import "../../css/variables.module";
.excalidraw {
.RoomDialog__button {
border: 1px solid var(--default-border-color) !important;
}
.RoomDialog-linkContainer {
display: flex;
margin: 1.5em 0;

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