Compare commits

..

30 Commits

Author SHA1 Message Date
Daniel J. Geiger
091123286b feat: Provide an isActionName type guard. 2023-01-24 19:48:37 -06:00
David Luzar
1db078a3dc feat: close MainMenu and Library dropdown on item select (#6152) 2023-01-23 16:54:35 +01:00
David Luzar
d4afd66268 feat: add hand/panning tool (#6141)
* feat: add hand/panning tool

* move hand tool right of tool lock separator

* tweak i18n

* rename `panning` -> `hand`

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

* hide properties sidebar when `hand` active

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

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

* fix: support firefox if clipboard.write supported

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

* widen to TypeError because minification

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

* add var for padding

* use css var

* new line

* stupid mistake

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

* namespace export icons into `icons` dictionary and lowercase

* update readme and changelog

* review fixes

* fix

* fix

* update docs

* remove

* allow button rest props

* update docs

* docs

* add `WelcomeScreen.Center.MenuItemLiveCollaborationTrigger`

* fix lint

* update changelog

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

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

* allow rest props

* fix

* lint

* add ts check

* ts for div

* fix

* fix

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

* refactor related code

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Auto commit: Calculate translation coverage

* New translations en.json (Hindi)

* New translations en.json (Marathi)

* New translations en.json (Hindi)

* Auto commit: Calculate translation coverage

* New translations en.json (Galician)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

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

* New translations en.json (Sinhala)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (Kabyle)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional)

* New translations en.json (Chinese Traditional)

* New translations en.json (Norwegian Bokmal)

* Auto commit: Calculate translation coverage

* New translations en.json (Latvian)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovenian)

* Auto commit: Calculate translation coverage

* New translations en.json (Spanish)

* New translations en.json (Russian)

* Auto commit: Calculate translation coverage

* New translations en.json (German)

* Auto commit: Calculate translation coverage

* New translations en.json (Vietnamese)

* Auto commit: Calculate translation coverage

* New translations en.json (Hindi)

* Auto commit: Calculate translation coverage

* New translations en.json (Dutch)

* Auto commit: Calculate translation coverage

* New translations en.json (Marathi)

* Auto commit: Calculate translation coverage

* New translations en.json (Latvian)

* New translations en.json (French)

* Auto commit: Calculate translation coverage

* New translations en.json (French)

* Auto commit: Calculate translation coverage

* New translations en.json (Portuguese, Brazilian)

* Auto commit: Calculate translation coverage

* New translations en.json (Japanese)

* Auto commit: Calculate translation coverage
2023-01-06 12:23:14 +05:30
148 changed files with 3902 additions and 2364 deletions

View File

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

View File

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

View File

@@ -28,6 +28,7 @@
"@types/pica": "5.1.3",
"@types/react": "18.0.15",
"@types/react-dom": "18.0.6",
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.29.1",
"clsx": "1.1.1",
"cross-env": "7.0.3",
@@ -53,7 +54,7 @@
"react-scripts": "5.0.1",
"roughjs": "4.5.2",
"sass": "1.51.0",
"socket.io-client": "4.5.4",
"socket.io-client": "2.3.1",
"typescript": "4.9.4",
"workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4",

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { LoadIcon, questionCircle, saveAs } from "../components/icons";
import { questionCircle, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton";
import "../components/ToolIcon.scss";
import { Tooltip } from "../components/Tooltip";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data";
@@ -15,12 +14,11 @@ import { getExportSize } from "../scene/export";
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element";
import { ActiveFile } from "../components/ActiveFile";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types";
import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem";
import { getShortcutFromShortcutName } from "./shortcuts";
import "../components/ToolIcon.scss";
export const actionChangeProjectName = register({
name: "changeProjectName",
@@ -133,6 +131,13 @@ export const actionChangeExportEmbedScene = register({
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
trackEvent: { category: "export" },
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.saveToActiveFile &&
!!appState.fileHandle &&
!appState.viewModeEnabled
);
},
perform: async (elements, appState, value, app) => {
const fileHandleExists = !!appState.fileHandle;
@@ -169,12 +174,6 @@ export const actionSaveToActiveFile = register({
},
keyTest: (event) =>
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
PanelComponent: ({ updateData, appState }) => (
<ActiveFile
onSave={() => updateData(null)}
fileName={appState.fileHandle?.name}
/>
),
});
export const actionSaveFileToDisk = register({
@@ -220,6 +219,11 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({
name: "loadScene",
trackEvent: { category: "export" },
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.loadScene && !appState.viewModeEnabled
);
},
perform: async (elements, appState, _, app) => {
try {
const {
@@ -247,19 +251,6 @@ export const actionLoadScene = register({
}
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
PanelComponent: ({ updateData }) => {
return (
<DropdownMenuItem
icon={LoadIcon}
onSelect={updateData}
dataTestId="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
ariaLabel={t("buttons.load")}
>
{t("buttons.load")}
</DropdownMenuItem>
);
},
});
export const actionExportWithDarkMode = register({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,85 +34,92 @@ type ActionFn = (
export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void;
export type ActionName =
| "copy"
| "cut"
| "paste"
| "copyAsPng"
| "copyAsSvg"
| "copyText"
| "sendBackward"
| "bringForward"
| "sendToBack"
| "bringToFront"
| "copyStyles"
| "selectAll"
| "pasteStyles"
| "gridMode"
| "zenMode"
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"
| "changeFillStyle"
| "changeStrokeWidth"
| "changeStrokeShape"
| "changeSloppiness"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeOpacity"
| "changeFontSize"
| "toggleCanvasMenu"
| "toggleEditMenu"
| "undo"
| "redo"
| "finalize"
| "changeProjectName"
| "changeExportBackground"
| "changeExportEmbedScene"
| "changeExportScale"
| "saveToActiveFile"
| "saveFileToDisk"
| "loadScene"
| "duplicateSelection"
| "deleteSelectedElements"
| "changeViewBackgroundColor"
| "clearCanvas"
| "zoomIn"
| "zoomOut"
| "resetZoom"
| "zoomToFit"
| "zoomToSelection"
| "changeFontFamily"
| "changeTextAlign"
| "changeVerticalAlign"
| "toggleFullScreen"
| "toggleShortcuts"
| "group"
| "ungroup"
| "goToCollaborator"
| "addToLibrary"
| "changeRoundness"
| "alignTop"
| "alignBottom"
| "alignLeft"
| "alignRight"
| "alignVerticallyCentered"
| "alignHorizontallyCentered"
| "distributeHorizontally"
| "distributeVertically"
| "flipHorizontal"
| "flipVertical"
| "viewMode"
| "exportWithDarkMode"
| "toggleTheme"
| "increaseFontSize"
| "decreaseFontSize"
| "unbindText"
| "hyperlink"
| "eraser"
| "bindText"
| "toggleLock"
| "toggleLinearEditor";
const actionNames = [
"copy",
"cut",
"paste",
"copyAsPng",
"copyAsSvg",
"copyText",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"copyStyles",
"selectAll",
"pasteStyles",
"gridMode",
"zenMode",
"stats",
"changeStrokeColor",
"changeBackgroundColor",
"changeFillStyle",
"changeStrokeWidth",
"changeStrokeShape",
"changeSloppiness",
"changeStrokeStyle",
"changeArrowhead",
"changeOpacity",
"changeFontSize",
"toggleCanvasMenu",
"toggleEditMenu",
"undo",
"redo",
"finalize",
"changeProjectName",
"changeExportBackground",
"changeExportEmbedScene",
"changeExportScale",
"saveToActiveFile",
"saveFileToDisk",
"loadScene",
"duplicateSelection",
"deleteSelectedElements",
"changeViewBackgroundColor",
"clearCanvas",
"zoomIn",
"zoomOut",
"resetZoom",
"zoomToFit",
"zoomToSelection",
"changeFontFamily",
"changeTextAlign",
"changeVerticalAlign",
"toggleFullScreen",
"toggleShortcuts",
"group",
"ungroup",
"goToCollaborator",
"addToLibrary",
"changeRoundness",
"alignTop",
"alignBottom",
"alignLeft",
"alignRight",
"alignVerticallyCentered",
"alignHorizontallyCentered",
"distributeHorizontally",
"distributeVertically",
"flipHorizontal",
"flipVertical",
"viewMode",
"exportWithDarkMode",
"toggleTheme",
"increaseFontSize",
"decreaseFontSize",
"unbindText",
"hyperlink",
"bindText",
"toggleLock",
"toggleLinearEditor",
"toggleEraserTool",
"toggleHandTool",
] as const;
// So we can have the `isActionName` type guard
export type ActionName = typeof actionNames[number];
export const isActionName = (n: any): n is ActionName =>
actionNames.includes(n);
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@@ -124,9 +131,7 @@ export type PanelComponentProps = {
export interface Action {
name: ActionName;
PanelComponent?: React.FC<
PanelComponentProps & { isInHamburgerMenu: boolean }
>;
PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn;
keyPriority?: number;
keyTest?: (
@@ -140,7 +145,7 @@ export interface Action {
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
) => string);
contextItemPredicate?: (
predicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
appProps: ExcalidrawProps,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,7 +41,11 @@ import { ActionManager } from "../actions/manager";
import { actions } from "../actions/register";
import { ActionResult } from "../actions/types";
import { trackEvent } from "../analytics";
import { getDefaultAppState, isEraserActive } from "../appState";
import {
getDefaultAppState,
isEraserActive,
isHandToolActive,
} from "../appState";
import { parseClipboard } from "../clipboard";
import {
APP_NAME,
@@ -57,6 +61,7 @@ import {
EVENT,
GRID_SIZE,
IMAGE_RENDER_TIMEOUT,
isAndroid,
LINE_CONFIRM_THRESHOLD,
MAX_ALLOWED_FILE_BYTES,
MIME_TYPES,
@@ -166,7 +171,6 @@ import {
shouldRotateWithDiscreteAngle,
isArrowKey,
KEYS,
isAndroid,
} from "../keys";
import { distance2d, getGridPoint, isPathALoop } from "../math";
import { renderScene } from "../renderer/renderScene";
@@ -274,6 +278,7 @@ import {
import { shouldShowBoundingBox } from "../element/transformHandles";
import { Fonts } from "../scene/Fonts";
import { actionPaste } from "../actions/actionClipboard";
import { actionToggleHandTool } from "../actions/actionCanvas";
const deviceContextInitialValue = {
isSmScreen: false,
@@ -283,15 +288,12 @@ const deviceContextInitialValue = {
};
const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
DeviceContext.displayName = "DeviceContext";
export const useDevice = () => useContext<Device>(DeviceContext);
export const ExcalidrawContainerContext = React.createContext<{
container: HTMLDivElement | null;
id: string | null;
}>({ container: null, id: null });
ExcalidrawContainerContext.displayName = "ExcalidrawContainerContext";
export const useExcalidrawContainer = () =>
useContext(ExcalidrawContainerContext);
const ExcalidrawElementsContext = React.createContext<
readonly NonDeletedExcalidrawElement[]
@@ -309,14 +311,19 @@ ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext";
const ExcalidrawSetAppStateContext = React.createContext<
React.Component<any, AppState>["setState"]
>(() => {});
>(() => {
console.warn("unitialized ExcalidrawSetAppStateContext context!");
});
ExcalidrawSetAppStateContext.displayName = "ExcalidrawSetAppStateContext";
const ExcalidrawActionManagerContext = React.createContext<
ActionManager | { renderAction: ActionManager["renderAction"] }
>({ renderAction: () => null });
const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
null!,
);
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
export const useDevice = () => useContext<Device>(DeviceContext);
export const useExcalidrawContainer = () =>
useContext(ExcalidrawContainerContext);
export const useExcalidrawElements = () =>
useContext(ExcalidrawElementsContext);
export const useExcalidrawAppState = () =>
@@ -537,8 +544,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElements(),
this.state,
);
const { onCollabButtonClick, renderTopRightUI, renderCustomStats } =
this.props;
const { renderTopRightUI, renderCustomStats } = this.props;
return (
<div
@@ -572,9 +578,9 @@ class App extends React.Component<AppProps, AppState> {
setAppState={this.setAppState}
actionManager={this.actionManager}
elements={this.scene.getNonDeletedElements()}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={this.toggleLock}
onPenModeToggle={this.togglePenMode}
onHandToolToggle={this.onHandToolToggle}
onInsertElements={(elements) =>
this.addElementsFromPasteOrLibrary({
elements,
@@ -598,6 +604,8 @@ class App extends React.Component<AppProps, AppState> {
id={this.id}
onImageAction={this.onImageAction}
renderWelcomeScreen={
!this.state.isLoading &&
this.props.UIOptions.welcomeScreen &&
this.state.showWelcomeScreen &&
this.state.activeTool.type === "selection" &&
!this.scene.getElementsIncludingDeleted().length
@@ -1810,6 +1818,10 @@ class App extends React.Component<AppProps, AppState> {
});
};
onHandToolToggle = () => {
this.actionManager.executeAction(actionToggleHandTool);
};
scrollToContent = (
target:
| ExcalidrawElement
@@ -2227,11 +2239,13 @@ class App extends React.Component<AppProps, AppState> {
private setActiveTool = (
tool:
| { type: typeof SHAPES[number]["value"] | "eraser" }
| { type: typeof SHAPES[number]["value"] | "eraser" | "hand" }
| { type: "custom"; customType: string },
) => {
const nextActiveTool = updateActiveTool(this.state, tool);
if (!isHoldingSpace) {
if (nextActiveTool.type === "hand") {
setCursor(this.canvas, CURSOR_TYPE.GRAB);
} else if (!isHoldingSpace) {
setCursorForShape(this.canvas, this.state);
}
if (isToolIcon(document.activeElement)) {
@@ -2902,7 +2916,12 @@ class App extends React.Component<AppProps, AppState> {
null;
}
if (isHoldingSpace || isPanning || isDraggingScrollBar) {
if (
isHoldingSpace ||
isPanning ||
isDraggingScrollBar ||
isHandToolActive(this.state)
) {
return;
}
@@ -3494,7 +3513,10 @@ class App extends React.Component<AppProps, AppState> {
);
} else if (this.state.activeTool.type === "custom") {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
} else if (this.state.activeTool.type !== "eraser") {
} else if (
this.state.activeTool.type !== "eraser" &&
this.state.activeTool.type !== "hand"
) {
this.createGenericElementOnPointerDown(
this.state.activeTool.type,
pointerDownState,
@@ -3605,6 +3627,7 @@ class App extends React.Component<AppProps, AppState> {
gesture.pointers.size <= 1 &&
(event.button === POINTER_BUTTON.WHEEL ||
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
isHandToolActive(this.state) ||
this.state.viewModeEnabled)
) ||
isTextElement(this.state.editingElement)

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,10 +14,10 @@ import {
ExcalidrawProps,
BinaryFiles,
UIChildrenComponents,
UIWelcomeScreenComponents,
} from "../types";
import { muteFSAbortError, ReactChildrenToObject } from "../utils";
import { isShallowEqual, muteFSAbortError, getReactChildren } from "../utils";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog";
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
import { FixedSideContainer } from "./FixedSideContainer";
@@ -45,13 +45,14 @@ import { useDevice } from "../components/App";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./footer/Footer";
import { WelcomeScreenMenuArrow, WelcomeScreenTopToolbarArrow } from "./icons";
import WelcomeScreen from "./WelcomeScreen";
import WelcomeScreen from "./welcome-screen/WelcomeScreen";
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai";
import { useAtom } from "jotai";
import WelcomeScreenDecor from "./WelcomeScreenDecor";
import MainMenu from "./mainMenu/MainMenu";
import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState";
interface LayerUIProps {
actionManager: ActionManager;
@@ -60,8 +61,8 @@ interface LayerUIProps {
canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onCollabButtonClick?: () => void;
onLockToggle: () => void;
onHandToolToggle: () => void;
onPenModeToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
showExitZenModeBtn: boolean;
@@ -87,8 +88,8 @@ const LayerUI = ({
setAppState,
elements,
canvas,
onCollabButtonClick,
onLockToggle,
onHandToolToggle,
onPenModeToggle,
onInsertElements,
showExitZenModeBtn,
@@ -107,8 +108,27 @@ const LayerUI = ({
}: LayerUIProps) => {
const device = useDevice();
const childrenComponents =
ReactChildrenToObject<UIChildrenComponents>(children);
const [childrenComponents, restChildren] =
getReactChildren<UIChildrenComponents>(children, {
Menu: true,
FooterCenter: true,
WelcomeScreen: true,
});
const [WelcomeScreenComponents] = getReactChildren<UIWelcomeScreenComponents>(
renderWelcomeScreen
? (
childrenComponents?.WelcomeScreen ?? (
<WelcomeScreen>
<WelcomeScreen.Center />
<WelcomeScreen.Hints.MenuHint />
<WelcomeScreen.Hints.ToolbarHint />
<WelcomeScreen.Hints.HelpHint />
</WelcomeScreen>
)
)?.props?.children
: null,
);
const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) {
@@ -183,16 +203,12 @@ const LayerUI = ({
<MainMenu>
<MainMenu.DefaultItems.LoadScene />
<MainMenu.DefaultItems.SaveToActiveFile />
{/* FIXME we should to test for this inside the item itself */}
{UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
{/* FIXME we should to test for this inside the item itself */}
{UIOptions.canvasActions.saveAsImage && (
<MainMenu.DefaultItems.SaveAsImage />
)}
{onCollabButtonClick && (
<MainMenu.DefaultItems.LiveCollaboration
onSelect={onCollabButtonClick}
isCollaborating={isCollaborating}
/>
)}
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
@@ -208,15 +224,10 @@ const LayerUI = ({
};
const renderCanvasActions = () => (
<div style={{ position: "relative" }}>
<WelcomeScreenDecor
shouldRender={renderWelcomeScreen && !appState.isLoading}
>
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--menu-pointer">
{WelcomeScreenMenuArrow}
<div>{t("welcomeScreen.menuHints")}</div>
</div>
</WelcomeScreenDecor>
{renderMenu()}
{WelcomeScreenComponents.MenuHint}
{/* wrapping to Fragment stops React from occasionally complaining
about identical Keys */}
<>{renderMenu()}</>
</div>
);
@@ -253,9 +264,7 @@ const LayerUI = ({
return (
<FixedSideContainer side="top">
{renderWelcomeScreen && !appState.isLoading && (
<WelcomeScreen appState={appState} actionManager={actionManager} />
)}
{WelcomeScreenComponents.Center}
<div className="App-menu App-menu_top">
<Stack.Col
gap={6}
@@ -270,17 +279,7 @@ const LayerUI = ({
<Section heading="shapes" className="shapes-section">
{(heading: React.ReactNode) => (
<div style={{ position: "relative" }}>
<WelcomeScreenDecor
shouldRender={renderWelcomeScreen && !appState.isLoading}
>
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--top-toolbar-pointer">
<div className="WelcomeScreen-decor--top-toolbar-pointer__label">
{t("welcomeScreen.toolbarHints")}
</div>
{WelcomeScreenTopToolbarArrow}
</div>
</WelcomeScreenDecor>
{WelcomeScreenComponents.ToolbarHint}
<Stack.Col gap={4} align="start">
<Stack.Row
gap={1}
@@ -310,13 +309,20 @@ const LayerUI = ({
penDetected={appState.penDetected}
/>
<LockButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.activeTool.locked}
onChange={() => onLockToggle()}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<div className="App-toolbar__divider"></div>
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
<ShapesSwitcher
appState={appState}
canvas={canvas}
@@ -328,9 +334,6 @@ const LayerUI = ({
});
}}
/>
{/* {actionManager.renderAction("eraser", {
// size: "small",
})} */}
</Stack.Row>
</Island>
</Stack.Row>
@@ -348,14 +351,6 @@ const LayerUI = ({
)}
>
<UserList collaborators={appState.collaborators} />
{onCollabButtonClick && (
<CollabButton
isInHamburgerMenu={false}
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{renderTopRightUI?.(device.isMobile, appState)}
{!appState.viewModeEnabled && (
<LibraryButton appState={appState} setAppState={setAppState} />
@@ -385,6 +380,7 @@ const LayerUI = ({
return (
<>
{restChildren}
{appState.isLoading && <LoadingMessage delay={250} />}
{appState.errorMessage && (
<ErrorDialog
@@ -399,6 +395,7 @@ const LayerUI = ({
}}
/>
)}
<ActiveConfirmDialog />
{renderImageExportDialog()}
{renderJSONExportDialog()}
{appState.pasteDialog.shown && (
@@ -415,24 +412,23 @@ const LayerUI = ({
)}
{device.isMobile && (
<MobileMenu
renderWelcomeScreen={renderWelcomeScreen}
appState={appState}
elements={elements}
actionManager={actionManager}
renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={() => onLockToggle()}
onLockToggle={onLockToggle}
onHandToolToggle={onHandToolToggle}
onPenModeToggle={onPenModeToggle}
canvas={canvas}
isCollaborating={isCollaborating}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderSidebars={renderSidebars}
device={device}
renderMenu={renderMenu}
welcomeScreenCenter={WelcomeScreenComponents.Center}
/>
)}
@@ -457,13 +453,12 @@ const LayerUI = ({
>
{renderFixedSideContainer()}
<Footer
renderWelcomeScreen={renderWelcomeScreen}
appState={appState}
actionManager={actionManager}
showExitZenModeBtn={showExitZenModeBtn}
footerCenter={childrenComponents.FooterCenter}
welcomeScreenHelp={WelcomeScreenComponents.HelpHint}
/>
{appState.showStats && (
<Stats
appState={appState}
@@ -495,28 +490,39 @@ const LayerUI = ({
);
};
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
const {
suggestedBindings,
startBoundElement: boundElement,
...ret
} = appState;
return ret;
};
const prevAppState = getNecessaryObj(prev.appState);
const nextAppState = getNecessaryObj(next.appState);
const stripIrrelevantAppStateProps = (
appState: AppState,
): Partial<AppState> => {
const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
appState;
return ret;
};
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
// short-circuit early
if (prevProps.children !== nextProps.children) {
return false;
}
const {
canvas: _prevCanvas,
// not stable, but shouldn't matter in our case
onInsertElements: _prevOnInsertElements,
appState: prevAppState,
...prev
} = prevProps;
const {
canvas: _nextCanvas,
onInsertElements: _nextOnInsertElements,
appState: nextAppState,
...next
} = nextProps;
return (
prev.renderTopRightUI === next.renderTopRightUI &&
prev.renderCustomStats === next.renderCustomStats &&
prev.renderCustomSidebar === next.renderCustomSidebar &&
prev.langCode === next.langCode &&
prev.elements === next.elements &&
prev.files === next.files &&
keys.every((key) => prevAppState[key] === nextAppState[key])
isShallowEqual(
stripIrrelevantAppStateProps(prevAppState),
stripIrrelevantAppStateProps(nextAppState),
) && isShallowEqual(prev, next)
);
};

View File

@@ -187,13 +187,14 @@ export const LibraryMenuHeader: React.FC<{
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsLibraryMenuOpen(false)}
onSelect={() => setIsLibraryMenuOpen(false)}
className="library-menu"
>
{!itemsSelected && (
<DropdownMenu.Item
onSelect={onLibraryImport}
icon={LoadIcon}
dataTestId="lib-dropdown--load"
data-testid="lib-dropdown--load"
>
{t("buttons.load")}
</DropdownMenu.Item>
@@ -202,7 +203,7 @@ export const LibraryMenuHeader: React.FC<{
<DropdownMenu.Item
onSelect={onLibraryExport}
icon={ExportIcon}
dataTestId="lib-dropdown--export"
data-testid="lib-dropdown--export"
>
{t("buttons.export")}
</DropdownMenu.Item>
@@ -219,7 +220,7 @@ export const LibraryMenuHeader: React.FC<{
<DropdownMenu.Item
icon={publishIcon}
onSelect={() => setShowPublishLibraryDialog(true)}
dataTestId="lib-dropdown--remove"
data-testid="lib-dropdown--remove"
>
{t("buttons.publishLibrary")}
</DropdownMenu.Item>

View File

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

View File

@@ -1,5 +1,10 @@
import React from "react";
import { AppState, Device, ExcalidrawProps } from "../types";
import {
AppState,
Device,
ExcalidrawProps,
UIWelcomeScreenComponents,
} from "../types";
import { ActionManager } from "../actions/manager";
import { t } from "../i18n";
import Stack from "./Stack";
@@ -17,7 +22,8 @@ import { LibraryButton } from "./LibraryButton";
import { PenModeButton } from "./PenModeButton";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
import WelcomeScreen from "./WelcomeScreen";
import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState";
type MobileMenuProps = {
appState: AppState;
@@ -26,11 +32,10 @@ type MobileMenuProps = {
renderImageExportDialog: () => React.ReactNode;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onCollabButtonClick?: () => void;
onLockToggle: () => void;
onHandToolToggle: () => void;
onPenModeToggle: () => void;
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: (
@@ -40,8 +45,8 @@ type MobileMenuProps = {
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen?: boolean;
renderMenu: () => React.ReactNode;
welcomeScreenCenter: UIWelcomeScreenComponents["Center"];
};
export const MobileMenu = ({
@@ -50,23 +55,21 @@ export const MobileMenu = ({
actionManager,
setAppState,
onLockToggle,
onHandToolToggle,
onPenModeToggle,
canvas,
isCollaborating,
onImageAction,
renderTopRightUI,
renderCustomStats,
renderSidebars,
device,
renderWelcomeScreen,
renderMenu,
welcomeScreenCenter,
}: MobileMenuProps) => {
const renderToolbar = () => {
return (
<FixedSideContainer side="top" className="App-top-bar">
{renderWelcomeScreen && !appState.isLoading && (
<WelcomeScreen appState={appState} actionManager={actionManager} />
)}
{welcomeScreenCenter}
<Section heading="shapes">
{(heading: React.ReactNode) => (
<Stack.Col gap={4} align="center">
@@ -74,20 +77,6 @@ export const MobileMenu = ({
<Island padding={1} className="App-toolbar App-toolbar--mobile">
{heading}
<Stack.Row gap={1}>
{/* <PenModeButton
checked={appState.penMode}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
isMobile
/>
<div className="App-toolbar__divider"></div> */}
<ShapesSwitcher
appState={appState}
canvas={canvas}
@@ -103,20 +92,6 @@ export const MobileMenu = ({
</Island>
{renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container">
<PenModeButton
checked={appState.penMode}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
// penDetected={true}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
isMobile
/>
{!appState.viewModeEnabled && (
<LibraryButton
appState={appState}
@@ -124,6 +99,25 @@ export const MobileMenu = ({
isMobile
/>
)}
<PenModeButton
checked={appState.penMode}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
isMobile
/>
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
</div>
</Stack.Row>
</Stack.Col>

View File

@@ -3,7 +3,7 @@
.excalidraw {
&.excalidraw-modal-container {
position: absolute;
z-index: 9999;
z-index: 10;
}
.Modal {

View File

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

View File

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

View File

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

View File

@@ -73,7 +73,7 @@
}
&:hover {
background-color: var(--button-hover);
background-color: var(--button-hover-bg);
text-decoration: none;
}

View File

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

View File

@@ -1,38 +1,33 @@
import React from "react";
import {
getDrodownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
import MenuItemContent from "./DropdownMenuItemContent";
export const getDrodownMenuItemClassName = (className = "") => {
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
};
const DropdownMenuItem = ({
icon,
onSelect,
children,
dataTestId,
shortcut,
className,
style,
ariaLabel,
...rest
}: {
icon?: JSX.Element;
onSelect: () => void;
onSelect: (event: Event) => void;
children: React.ReactNode;
dataTestId?: string;
shortcut?: string;
className?: string;
style?: React.CSSProperties;
ariaLabel?: string;
}) => {
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return (
<button
aria-label={ariaLabel}
onClick={onSelect}
data-testid={dataTestId}
title={ariaLabel}
{...rest}
onClick={handleClick}
type="button"
className={getDrodownMenuItemClassName(className)}
style={style}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}

View File

@@ -1,19 +1,17 @@
import React from "react";
const DropdownMenuItemCustom = ({
children,
className = "",
style,
dataTestId,
...rest
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
dataTestId?: string;
}) => {
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
{...rest}
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
style={style}
data-testid={dataTestId}
>
{children}
</div>

View File

@@ -1,35 +1,37 @@
import MenuItemContent from "./DropdownMenuItemContent";
import React from "react";
import { getDrodownMenuItemClassName } from "./DropdownMenuItem";
import {
getDrodownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
const DropdownMenuItemLink = ({
icon,
dataTestId,
shortcut,
href,
children,
onSelect,
className = "",
style,
ariaLabel,
...rest
}: {
href: string;
icon?: JSX.Element;
children: React.ReactNode;
dataTestId?: string;
shortcut?: string;
className?: string;
href: string;
style?: React.CSSProperties;
ariaLabel?: string;
}) => {
onSelect?: (event: Event) => void;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return (
<a
{...rest}
href={href}
target="_blank"
rel="noreferrer"
className={getDrodownMenuItemClassName(className)}
style={style}
data-testid={dataTestId}
title={ariaLabel}
aria-label={ariaLabel}
title={rest.title ?? rest["aria-label"]}
onClick={handleClick}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}

View File

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

View File

@@ -1,7 +1,11 @@
import clsx from "clsx";
import { actionShortcuts } from "../../actions";
import { ActionManager } from "../../actions/manager";
import { t } from "../../i18n";
import { AppState, UIChildrenComponents } from "../../types";
import {
AppState,
UIChildrenComponents,
UIWelcomeScreenComponents,
} from "../../types";
import {
ExitZenModeAction,
FinalizeAction,
@@ -9,23 +13,22 @@ import {
ZoomActions,
} from "../Actions";
import { useDevice } from "../App";
import { WelcomeScreenHelpArrow } from "../icons";
import { HelpButton } from "../HelpButton";
import { Section } from "../Section";
import Stack from "../Stack";
import WelcomeScreenDecor from "../WelcomeScreenDecor";
const Footer = ({
appState,
actionManager,
showExitZenModeBtn,
renderWelcomeScreen,
footerCenter,
welcomeScreenHelp,
}: {
appState: AppState;
actionManager: ActionManager;
showExitZenModeBtn: boolean;
renderWelcomeScreen: boolean;
footerCenter: UIChildrenComponents["FooterCenter"];
welcomeScreenHelp: UIWelcomeScreenComponents["HelpHint"];
}) => {
const device = useDevice();
const showFinalize =
@@ -77,16 +80,10 @@ const Footer = ({
})}
>
<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")}
{welcomeScreenHelp}
<HelpButton
onClick={() => actionManager.executeAction(actionShortcuts)}
/>
</div>
</div>
<ExitZenModeAction

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import clsx from "clsx";
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t } from "../../i18n";
import {
@@ -6,43 +5,91 @@ import {
useExcalidrawSetAppState,
useExcalidrawActionManager,
} from "../App";
import { ExportIcon, ExportImageIcon, UsersIcon } from "../icons";
import {
ExportIcon,
ExportImageIcon,
HelpIcon,
LoadIcon,
MoonIcon,
save,
SunIcon,
TrashIcon,
usersIcon,
} from "../icons";
import { GithubIcon, DiscordIcon, TwitterIcon } from "../icons";
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import {
actionClearCanvas,
actionLoadScene,
actionSaveToActiveFile,
actionShortcuts,
actionToggleTheme,
} from "../../actions";
import "./DefaultItems.scss";
import clsx from "clsx";
import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
export const LoadScene = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) {
if (!actionManager.isActionEnabled(actionLoadScene)) {
return null;
}
return actionManager.renderAction("loadScene");
return (
<DropdownMenuItem
icon={LoadIcon}
onSelect={() => actionManager.executeAction(actionLoadScene)}
data-testid="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
aria-label={t("buttons.load")}
>
{t("buttons.load")}
</DropdownMenuItem>
);
};
LoadScene.displayName = "LoadScene";
export const SaveToActiveFile = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (!appState.fileHandle) {
if (!actionManager.isActionEnabled(actionSaveToActiveFile)) {
return null;
}
return actionManager.renderAction("saveToActiveFile");
return (
<DropdownMenuItem
shortcut={getShortcutFromShortcutName("saveScene")}
data-testid="save-button"
onSelect={() => actionManager.executeAction(actionSaveToActiveFile)}
icon={save}
aria-label={`${t("buttons.save")}`}
>{`${t("buttons.save")}`}</DropdownMenuItem>
);
};
SaveToActiveFile.displayName = "SaveToActiveFile";
export const SaveAsImage = () => {
const setAppState = useExcalidrawSetAppState();
// Hack until we tie "t" to lang state
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
return (
<DropdownMenuItem
icon={ExportImageIcon}
dataTestId="image-export-button"
data-testid="image-export-button"
onSelect={() => setAppState({ openDialog: "imageExport" })}
shortcut={getShortcutFromShortcutName("imageExport")}
ariaLabel={t("buttons.exportImage")}
aria-label={t("buttons.exportImage")}
>
{t("buttons.exportImage")}
</DropdownMenuItem>
@@ -51,32 +98,79 @@ export const SaveAsImage = () => {
SaveAsImage.displayName = "SaveAsImage";
export const Help = () => {
// Hack until we tie "t" to lang state
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
return actionManager.renderAction("toggleShortcuts", undefined, true);
return (
<DropdownMenuItem
data-testid="help-menu-item"
icon={HelpIcon}
onSelect={() => actionManager.executeAction(actionShortcuts)}
shortcut="?"
aria-label={t("helpDialog.title")}
>
{t("helpDialog.title")}
</DropdownMenuItem>
);
};
Help.displayName = "Help";
export const ClearCanvas = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) {
if (!actionManager.isActionEnabled(actionClearCanvas)) {
return null;
}
return actionManager.renderAction("clearCanvas");
return (
<DropdownMenuItem
icon={TrashIcon}
onSelect={() => setActiveConfirmDialog("clearCanvas")}
data-testid="clear-canvas-button"
aria-label={t("buttons.clearReset")}
>
{t("buttons.clearReset")}
</DropdownMenuItem>
);
};
ClearCanvas.displayName = "ClearCanvas";
export const ToggleTheme = () => {
// Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
return actionManager.renderAction("toggleTheme");
if (!actionManager.isActionEnabled(actionToggleTheme)) {
return null;
}
return (
<DropdownMenuItem
onSelect={(event) => {
// do not close the menu when changing theme
event.preventDefault();
return actionManager.executeAction(actionToggleTheme);
}}
icon={appState.theme === "dark" ? SunIcon : MoonIcon}
data-testid="toggle-dark-mode"
shortcut={getShortcutFromShortcutName("toggleTheme")}
aria-label={
appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")
}
>
{appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")}
</DropdownMenuItem>
);
};
ToggleTheme.displayName = "ToggleTheme";
@@ -101,7 +195,7 @@ export const ChangeCanvasBackground = () => {
ChangeCanvasBackground.displayName = "ChangeCanvasBackground";
export const Export = () => {
// Hack until we tie "t" to lang state
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
@@ -111,8 +205,8 @@ export const Export = () => {
onSelect={() => {
setAppState({ openDialog: "jsonExport" });
}}
dataTestId="json-export-button"
ariaLabel={t("buttons.export")}
data-testid="json-export-button"
aria-label={t("buttons.export")}
>
{t("buttons.export")}
</DropdownMenuItem>
@@ -125,21 +219,21 @@ export const Socials = () => (
<DropdownMenuItemLink
icon={GithubIcon}
href="https://github.com/excalidraw/excalidraw"
ariaLabel="GitHub"
aria-label="GitHub"
>
GitHub
</DropdownMenuItemLink>
<DropdownMenuItemLink
icon={DiscordIcon}
href="https://discord.gg/UexuTaE"
ariaLabel="Discord"
aria-label="Discord"
>
Discord
</DropdownMenuItemLink>
<DropdownMenuItemLink
icon={TwitterIcon}
href="https://twitter.com/excalidraw"
ariaLabel="Twitter"
aria-label="Twitter"
>
Twitter
</DropdownMenuItemLink>
@@ -147,20 +241,20 @@ export const Socials = () => (
);
Socials.displayName = "Socials";
export const LiveCollaboration = ({
export const LiveCollaborationTrigger = ({
onSelect,
isCollaborating,
}: {
onSelect: () => void;
isCollaborating: boolean;
}) => {
// Hack until we tie "t" to lang state
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
return (
<DropdownMenuItem
dataTestId="collab-button"
icon={UsersIcon}
data-testid="collab-button"
icon={usersIcon}
className={clsx({
"active-collab": isCollaborating,
})}
@@ -171,4 +265,4 @@ export const LiveCollaboration = ({
);
};
LiveCollaboration.displayName = "LiveCollaboration";
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";

View File

@@ -11,14 +11,25 @@ import * as DefaultItems from "./DefaultItems";
import { UserList } from "../UserList";
import { t } from "../../i18n";
import { HamburgerMenuIcon } from "../icons";
import { composeEventHandlers } from "../../utils";
const MainMenu = ({ children }: { children?: React.ReactNode }) => {
const MainMenu = ({
children,
onSelect,
}: {
children?: React.ReactNode;
/**
* Called when any menu item is selected (clicked on).
*/
onSelect?: (event: Event) => void;
}) => {
const device = useDevice();
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
const onClickOutside = device.isMobile
? undefined
: () => setAppState({ openMenu: null });
return (
<DropdownMenu open={appState.openMenu === "canvas"}>
<DropdownMenu.Trigger
@@ -30,7 +41,12 @@ const MainMenu = ({ children }: { children?: React.ReactNode }) => {
>
{HamburgerMenuIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content onClickOutside={onClickOutside}>
<DropdownMenu.Content
onClickOutside={onClickOutside}
onSelect={composeEventHandlers(onSelect, () => {
setAppState({ openMenu: null });
})}
>
{children}
{device.isMobile && appState.collaborators.size > 0 && (
<fieldset className="UserList-Wrapper">

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
import { Center } from "./WelcomeScreen.Center";
import { MenuHint, ToolbarHint, HelpHint } from "./WelcomeScreen.Hints";
import "./WelcomeScreen.scss";
const WelcomeScreen = (props: { children: React.ReactNode }) => {
// NOTE this component is used as a dummy wrapper to retrieve child props
// from, and will never be rendered to DOM directly. As such, we can't
// do anything here (use hooks and such)
return null;
};
WelcomeScreen.displayName = "WelcomeScreen";
WelcomeScreen.Center = Center;
WelcomeScreen.Hints = { MenuHint, ToolbarHint, HelpHint };
export default WelcomeScreen;

View File

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

View File

@@ -408,7 +408,7 @@
pointer-events: all;
&:hover {
background-color: var(--button-hover);
background-color: var(--button-hover-bg);
}
&:active {
@@ -540,15 +540,16 @@
}
.mobile-misc-tools-container {
position: fixed;
top: 5rem;
right: 0;
position: absolute;
top: calc(5rem - var(--editor-container-padding));
right: calc(var(--editor-container-padding) * -1);
display: flex;
flex-direction: column;
border: 1px solid var(--sidebar-border-color);
border-top-left-radius: var(--border-radius-lg);
border-bottom-left-radius: var(--border-radius-lg);
border-right: 0;
overflow: hidden;
background-color: var(--island-bg-color);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -263,6 +263,13 @@ class Collab extends PureComponent<Props, CollabState> {
),
);
if (this.portal.socket && this.fallbackInitializationHandler) {
this.portal.socket.off(
"connect_error",
this.fallbackInitializationHandler,
);
}
if (!keepRemoteState) {
LocalData.fileStorage.reset();
this.destroySocketClient();
@@ -358,6 +365,8 @@ class Collab extends PureComponent<Props, CollabState> {
}
};
private fallbackInitializationHandler: null | (() => any) = null;
startCollaboration = async (
existingRoomLinkData: null | { roomId: string; roomKey: string },
): Promise<ImportedDataState | null> => {
@@ -396,6 +405,7 @@ class Collab extends PureComponent<Props, CollabState> {
scenePromise.resolve(scene);
});
};
this.fallbackInitializationHandler = fallbackInitializationHandler;
try {
const socketServerData = await getCollabServer();
@@ -410,27 +420,7 @@ class Collab extends PureComponent<Props, CollabState> {
roomKey,
);
this.portal.socket.on("disconnect", (reason) => {
// reason `io server disconnect` probably means CORS issue
console.warn(
`${
this.portal.socketInitialized ? "initialized" : "uninitialized"
} socket disconnected from server: ${reason}`,
);
this.setState({
errorMessage: this.portal.socketInitialized
? t("errors.socketDisconnected")
: t("errors.socketConnectionError"),
});
fallbackInitializationHandler();
});
this.portal.socket.on("connect_error", () => {
fallbackInitializationHandler();
this.setState({
errorMessage: t("errors.socketConnectionError"),
});
});
this.portal.socket.once("connect_error", fallbackInitializationHandler);
} catch (error: any) {
console.error(error);
this.setState({ errorMessage: error.message });
@@ -457,7 +447,6 @@ class Collab extends PureComponent<Props, CollabState> {
this.saveCollabRoomToFirebase(getSyncableElements(elements));
}
clearTimeout(this.socketInitializationTimer!);
// fallback in case you're not alone in the room but still don't receive
// initial SCENE_INIT message
this.socketInitializationTimer = window.setTimeout(
@@ -568,12 +557,12 @@ class Collab extends PureComponent<Props, CollabState> {
}
| { fetchScene: false; roomLinkData?: null }) => {
clearTimeout(this.socketInitializationTimer!);
if (!this.portal.socket || this.portal.socketInitialized) {
return null;
if (this.portal.socket && this.fallbackInitializationHandler) {
this.portal.socket.off(
"connect_error",
this.fallbackInitializationHandler,
);
}
this.portal.socketInitialized = true;
if (fetchScene && roomLinkData && this.portal.socket) {
this.excalidrawAPI.resetScene();
@@ -596,7 +585,11 @@ class Collab extends PureComponent<Props, CollabState> {
} catch (error: any) {
// log the error and move on. other peers will sync us the scene.
console.error(error);
} finally {
this.portal.socketInitialized = true;
}
} else {
this.portal.socketInitialized = true;
}
return null;
};

View File

@@ -19,11 +19,10 @@ import { newElementWith } from "../../element/mutateElement";
import { BroadcastedExcalidrawElement } from "./reconciliation";
import { encryptData } from "../../data/encryption";
import { PRECEDING_ELEMENT_KEY } from "../../constants";
import { Socket } from "socket.io-client";
class Portal {
collab: TCollabClass;
socket: Socket | null = null;
socket: SocketIOClient.Socket | null = null;
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
roomId: string | null = null;
roomKey: string | null = null;
@@ -33,7 +32,7 @@ class Portal {
this.collab = collab;
}
open(socket: Socket, id: string, key: string) {
open(socket: SocketIOClient.Socket, id: string, key: string) {
this.socket = socket;
this.roomId = id;
this.roomKey = key;
@@ -64,7 +63,6 @@ class Portal {
return;
}
this.queueFileUpload.flush();
this.socket.off();
this.socket.close();
this.socket = null;
this.roomId = null;
@@ -144,12 +142,7 @@ class Portal {
// periodically we'll resync the whole thing to make sure no one diverges
// due to a dropped message (server goes down etc).
const syncableElements = allElements.reduce(
(
acc: BroadcastedExcalidrawElement[],
element: BroadcastedExcalidrawElement,
idx,
elements,
) => {
(acc, element: BroadcastedExcalidrawElement, idx, elements) => {
if (
(syncAll ||
!this.broadcastedElementVersions.has(element.id) ||
@@ -165,7 +158,7 @@ class Portal {
}
return acc;
},
[],
[] as BroadcastedExcalidrawElement[],
);
const data: SocketUpdateDataSource[typeof updateType] = {

View File

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

View File

@@ -14,7 +14,6 @@ import { encryptData, decryptData } from "../../data/encryption";
import { MIME_TYPES } from "../../constants";
import { reconcileElements } from "../collab/reconciliation";
import { getSyncableElements, SyncableExcalidrawElement } from ".";
import { Socket } from "socket.io-client";
// private
// -----------------------------------------------------------------------------
@@ -130,12 +129,12 @@ const decryptElements = async (
};
class FirebaseSceneVersionCache {
private static cache = new WeakMap<Socket, number>();
static get = (socket: Socket) => {
private static cache = new WeakMap<SocketIOClient.Socket, number>();
static get = (socket: SocketIOClient.Socket) => {
return FirebaseSceneVersionCache.cache.get(socket);
};
static set = (
socket: Socket,
socket: SocketIOClient.Socket,
elements: readonly SyncableExcalidrawElement[],
) => {
FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements));
@@ -277,7 +276,7 @@ export const saveToFirebase = async (
export const loadFromFirebase = async (
roomId: string,
roomKey: string,
socket: Socket | null,
socket: SocketIOClient.Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => {
const firebase = await loadFirestore();
const db = firebase.firestore();

View File

@@ -1,6 +1,6 @@
import polyfill from "../polyfill";
import LanguageDetector from "i18next-browser-languagedetector";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { trackEvent } from "../analytics";
import { getDefaultAppState } from "../appState";
import { ErrorDialog } from "../components/ErrorDialog";
@@ -26,6 +26,8 @@ import {
defaultLang,
Footer,
MainMenu,
LiveCollaborationTrigger,
WelcomeScreen,
} from "../packages/excalidraw/index";
import {
AppState,
@@ -45,6 +47,7 @@ import {
} from "../utils";
import {
FIREBASE_STORAGE_PREFIXES,
isExcalidrawPlusSignedUser,
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
@@ -608,7 +611,7 @@ const ExcalidrawWrapper = () => {
<MainMenu.DefaultItems.SaveToActiveFile />
<MainMenu.DefaultItems.Export />
<MainMenu.DefaultItems.SaveAsImage />
<MainMenu.DefaultItems.LiveCollaboration
<MainMenu.DefaultItems.LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => setCollabDialogShown(true)}
/>
@@ -634,6 +637,63 @@ const ExcalidrawWrapper = () => {
);
};
const welcomeScreenJSX = useMemo(() => {
let headingContent;
if (isExcalidrawPlusSignedUser) {
headingContent = t("welcomeScreen.app.center_heading_plus")
.split(/(Excalidraw\+)/)
.map((bit, idx) => {
if (bit === "Excalidraw+") {
return (
<a
style={{ pointerEvents: "all" }}
href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
key={idx}
>
Excalidraw+
</a>
);
}
return bit;
});
} else {
headingContent = t("welcomeScreen.app.center_heading");
}
return (
<WelcomeScreen>
<WelcomeScreen.Hints.MenuHint>
{t("welcomeScreen.app.menuHint")}
</WelcomeScreen.Hints.MenuHint>
<WelcomeScreen.Hints.ToolbarHint />
<WelcomeScreen.Hints.HelpHint />
<WelcomeScreen.Center>
<WelcomeScreen.Center.Logo />
<WelcomeScreen.Center.Heading>
{headingContent}
</WelcomeScreen.Center.Heading>
<WelcomeScreen.Center.Menu>
<WelcomeScreen.Center.MenuItemLoadScene />
<WelcomeScreen.Center.MenuItemHelp />
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
onSelect={() => setCollabDialogShown(true)}
/>
{!isExcalidrawPlusSignedUser && (
<WelcomeScreen.Center.MenuItemLink
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
shortcut={null}
icon={PlusPromoIcon}
>
Try Excalidraw Plus!
</WelcomeScreen.Center.MenuItemLink>
)}
</WelcomeScreen.Center.Menu>
</WelcomeScreen.Center>
</WelcomeScreen>
);
}, [setCollabDialogShown]);
return (
<div
style={{ height: "100%" }}
@@ -645,7 +705,6 @@ const ExcalidrawWrapper = () => {
ref={excalidrawRefCallback}
onChange={onChange}
initialData={initialStatePromiseRef.current.promise}
onCollabButtonClick={() => setCollabDialogShown(true)}
isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate}
UIOptions={{
@@ -679,14 +738,27 @@ const ExcalidrawWrapper = () => {
onLibraryChange={onLibraryChange}
autoFocus={true}
theme={theme}
renderTopRightUI={(isMobile) => {
if (isMobile) {
return null;
}
return (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => setCollabDialogShown(true)}
/>
);
}}
>
{renderMenu()}
<Footer>
<div style={{ display: "flex", gap: ".5rem", alignItems: "center" }}>
<ExcalidrawPlusAppLink />
<EncryptedIcon />
</div>
</Footer>
{welcomeScreenJSX}
</Excalidraw>
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
{errorMessage && (

View File

@@ -1,6 +1,4 @@
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
export const isWindows = /^Win/.test(window.navigator.platform);
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
import { isDarwin } from "./constants";
export const CODES = {
EQUAL: "Equal",

View File

@@ -1,7 +1,7 @@
{
"labels": {
"paste": "لصق",
"pasteAsPlaintext": "",
"pasteAsPlaintext": "اللصق كنص عادي",
"pasteCharts": "لصق الرسوم البيانية",
"selectAll": "تحديد الكل",
"multiSelect": "إضافة عنصر للتحديد",
@@ -66,13 +66,13 @@
"cartoonist": "كرتوني",
"fileTitle": "إسم الملف",
"colorPicker": "منتقي اللون",
"canvasColors": "",
"canvasColors": "تستخدم على القماش",
"canvasBackground": "خلفية اللوحة",
"drawingCanvas": "لوحة الرسم",
"layers": "الطبقات",
"actions": "الإجراءات",
"language": "اللغة",
"liveCollaboration": "",
"liveCollaboration": "التعاون المباشر...",
"duplicateSelection": "تكرار",
"untitled": "غير معنون",
"name": "الاسم",
@@ -108,7 +108,7 @@
"excalidrawLib": "مكتبتنا",
"decreaseFontSize": "تصغير حجم الخط",
"increaseFontSize": "تكبير حجم الخط",
"unbindText": "",
"unbindText": "فك ربط النص",
"bindText": "",
"link": {
"edit": "تعديل الرابط",
@@ -145,7 +145,7 @@
"scale": "مقاس",
"save": "احفظ للملف الحالي",
"saveAs": "حفظ كـ",
"load": "",
"load": "فتح",
"getShareableLink": "احصل على رابط المشاركة",
"close": "غلق",
"selectLanguage": "اختر اللغة",
@@ -201,7 +201,9 @@
"svgImageInsertError": "تعذر إدراج صورة SVG. يبدو أن ترميز SVG غير صحيح.",
"invalidSVGString": "SVG غير صالح.",
"cannotResolveCollabServer": "",
"importLibraryError": ""
"importLibraryError": "",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
},
"toolBar": {
"selection": "تحديد",
@@ -445,10 +447,16 @@
"d9480f": "برتقالي 9"
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
}
}
}

View File

@@ -201,7 +201,9 @@
"svgImageInsertError": "",
"invalidSVGString": "",
"cannotResolveCollabServer": "",
"importLibraryError": ""
"importLibraryError": "",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
},
"toolBar": {
"selection": "Селекция",
@@ -445,10 +447,16 @@
"d9480f": ""
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
}
}
}

View File

@@ -201,7 +201,9 @@
"svgImageInsertError": "এসভীজী ছবি সন্নিবেশ করা যায়নি। এসভীজী মার্কআপটি অবৈধ মনে হচ্ছে৷",
"invalidSVGString": "এসভীজী মার্কআপটি অবৈধ মনে হচ্ছে৷",
"cannotResolveCollabServer": "কোল্যাব সার্ভারের সাথে সংযোগ করা যায়নি। পৃষ্ঠাটি পুনরায় লোড করে আবার চেষ্টা করুন।",
"importLibraryError": "সংগ্রহ লোড করা যায়নি"
"importLibraryError": "সংগ্রহ লোড করা যায়নি",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
},
"toolBar": {
"selection": "বাছাই",
@@ -445,10 +447,16 @@
"d9480f": ""
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
}
}
}

View File

@@ -201,7 +201,9 @@
"svgImageInsertError": "No ha estat possible inserir la imatge SVG. Les marques SVG semblen invàlides.",
"invalidSVGString": "SVG no vàlid.",
"cannotResolveCollabServer": "No ha estat possible connectar amb el servidor collab. Si us plau recarregueu la pàgina i torneu a provar.",
"importLibraryError": "No s'ha pogut carregar la biblioteca"
"importLibraryError": "No s'ha pogut carregar la biblioteca",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
},
"toolBar": {
"selection": "Selecció",
@@ -445,10 +447,16 @@
"d9480f": "Taronja 9"
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
}
}
}

View File

@@ -201,7 +201,9 @@
"svgImageInsertError": "",
"invalidSVGString": "",
"cannotResolveCollabServer": "",
"importLibraryError": ""
"importLibraryError": "",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
},
"toolBar": {
"selection": "Výběr",
@@ -445,10 +447,16 @@
"d9480f": "Oranzova"
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
}
}
}

View File

@@ -201,7 +201,9 @@
"svgImageInsertError": "",
"invalidSVGString": "",
"cannotResolveCollabServer": "",
"importLibraryError": ""
"importLibraryError": "",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
},
"toolBar": {
"selection": "",
@@ -445,10 +447,16 @@
"d9480f": ""
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
}
}
}

View File

@@ -99,14 +99,14 @@
"flipHorizontal": "Horizontal spiegeln",
"flipVertical": "Vertikal spiegeln",
"viewMode": "Ansichtsmodus",
"toggleExportColorScheme": "Exportfarbschema umschalten",
"toggleExportColorScheme": "Farbschema für Export umschalten",
"share": "Teilen",
"showStroke": "Auswahl für Strichfarbe anzeigen",
"showBackground": "Hintergrundfarbe auswählen",
"toggleTheme": "Thema umschalten",
"toggleTheme": "Design umschalten",
"personalLib": "Persönliche Bibliothek",
"excalidrawLib": "Excalidraw-Bibliothek",
"decreaseFontSize": "Schrift verkleinern",
"excalidrawLib": "Excalidraw Bibliothek",
"decreaseFontSize": "Schriftgröße verkleinern",
"increaseFontSize": "Schrift vergrößern",
"unbindText": "Text lösen",
"bindText": "Text an Container binden",
@@ -161,8 +161,8 @@
"resetLibrary": "Bibliothek zurücksetzen",
"createNewRoom": "Neuen Raum erstellen",
"fullScreen": "Vollbildanzeige",
"darkMode": "Dunkler Modus",
"lightMode": "Heller Modus",
"darkMode": "Dunkles Design",
"lightMode": "Helles Design",
"zenMode": "Zen-Modus",
"exitZenMode": "Zen-Modus verlassen",
"cancel": "Abbrechen",
@@ -201,7 +201,9 @@
"svgImageInsertError": "SVG-Bild konnte nicht eingefügt werden. Das SVG-Markup sieht ungültig aus.",
"invalidSVGString": "Ungültige SVG.",
"cannotResolveCollabServer": "Konnte keine Verbindung zum Collab-Server herstellen. Bitte lade die Seite neu und versuche es erneut.",
"importLibraryError": "Bibliothek konnte nicht geladen werden"
"importLibraryError": "Bibliothek konnte nicht geladen werden",
"collabSaveFailed": "Keine Speicherung in der Backend-Datenbank möglich. Wenn die Probleme weiterhin bestehen, solltest Du Deine Datei lokal speichern, um sicherzustellen, dass Du Deine Arbeit nicht verlierst.",
"collabSaveFailed_sizeExceeded": "Keine Speicherung in der Backend-Datenbank möglich, die Zeichenfläche scheint zu groß zu sein. Du solltest Deine Datei lokal speichern, um sicherzustellen, dass Du Deine Arbeit nicht verlierst."
},
"toolBar": {
"selection": "Auswahl",
@@ -312,8 +314,8 @@
"zoomToFit": "Zoomen um alle Elemente einzupassen",
"zoomToSelection": "Auf Auswahl zoomen",
"toggleElementLock": "Auswahl sperren/entsperren",
"movePageUpDown": "",
"movePageLeftRight": ""
"movePageUpDown": "Seite nach oben/unten verschieben",
"movePageLeftRight": "Seite nach links/rechts verschieben"
},
"clearCanvasDialog": {
"title": "Zeichenfläche löschen"
@@ -445,10 +447,16 @@
"d9480f": "Orange 9"
},
"welcomeScreen": {
"data": "Alle Daten werden lokal in Deinem Browser gespeichert.",
"switchToPlusApp": "Möchtest du stattdessen zu Excalidraw+ gehen?",
"menuHints": "Exportieren, Einstellungen, Sprachen, ...",
"toolbarHints": "Wähle ein Werkzeug & beginne zu zeichnen!",
"helpHints": "Kurzbefehle & Hilfe"
"app": {
"center_heading": "Alle Daten werden lokal in Deinem Browser gespeichert.",
"center_heading_plus": "Möchtest du stattdessen zu Excalidraw+ gehen?",
"menuHint": "Exportieren, Einstellungen, Sprachen, ..."
},
"defaults": {
"menuHint": "Exportieren, Einstellungen und mehr...",
"center_heading": "Diagramme. Einfach. Gemacht.",
"toolbarHint": "Wähle ein Werkzeug & beginne zu zeichnen!",
"helpHint": "Kurzbefehle & Hilfe"
}
}
}

View File

@@ -1,7 +1,7 @@
{
"labels": {
"paste": "Επικόλληση",
"pasteAsPlaintext": "",
"pasteAsPlaintext": "Επικόλληση ως απλό κείμενο",
"pasteCharts": "Επικόλληση γραφημάτων",
"selectAll": "Επιλογή όλων",
"multiSelect": "Προσθέστε το στοιχείο στην επιλογή",
@@ -72,7 +72,7 @@
"layers": "Στρώματα",
"actions": "Ενέργειες",
"language": "Γλώσσα",
"liveCollaboration": "",
"liveCollaboration": "Live συνεργασία...",
"duplicateSelection": "Δημιουργία αντιγράφου",
"untitled": "Χωρίς τίτλο",
"name": "Όνομα",
@@ -116,8 +116,8 @@
"label": "Σύνδεσμος"
},
"lineEditor": {
"edit": "",
"exit": ""
"edit": "Επεξεργασία γραμμής",
"exit": "Έξοδος επεξεργαστή κειμένου"
},
"elementLock": {
"lock": "Κλείδωμα",
@@ -136,8 +136,8 @@
"buttons": {
"clearReset": "Επαναφορά του καμβά",
"exportJSON": "Εξαγωγή σε αρχείο",
"exportImage": "",
"export": "",
"exportImage": "Εξαγωγή εικόνας...",
"export": "Αποθήκευση ως...",
"exportToPng": "Εξαγωγή σε PNG",
"exportToSvg": "Εξαγωγή σε SVG",
"copyToClipboard": "Αντιγραφή στο πρόχειρο",
@@ -145,7 +145,7 @@
"scale": "Κλίμακα",
"save": "Αποθήκευση στο τρέχον αρχείο",
"saveAs": "Αποθήκευση ως",
"load": "",
"load": "Άνοιγμα",
"getShareableLink": "Δημόσιος σύνδεσμος",
"close": "Κλείσιμο",
"selectLanguage": "Επιλογή γλώσσας",
@@ -201,7 +201,9 @@
"svgImageInsertError": "Αδυναμία εισαγωγής εικόνας SVG. Η σήμανση της SVG δεν φαίνεται έγκυρη.",
"invalidSVGString": "Μη έγκυρο SVG.",
"cannotResolveCollabServer": "Αδυναμία σύνδεσης με τον διακομιστή συνεργασίας. Παρακαλώ ανανεώστε τη σελίδα και προσπαθήστε ξανά.",
"importLibraryError": "Αδυναμία φόρτωσης βιβλιοθήκης"
"importLibraryError": "Αδυναμία φόρτωσης βιβλιοθήκης",
"collabSaveFailed": "Η αποθήκευση στη βάση δεδομένων δεν ήταν δυνατή. Αν το προβλήματα παραμείνει, θα πρέπει να αποθηκεύσετε το αρχείο σας τοπικά για να βεβαιωθείτε ότι δεν χάνετε την εργασία σας.",
"collabSaveFailed_sizeExceeded": "Η αποθήκευση στη βάση δεδομένων δεν ήταν δυνατή, ο καμβάς φαίνεται να είναι πολύ μεγάλος. Θα πρέπει να αποθηκεύσετε το αρχείο τοπικά για να βεβαιωθείτε ότι δεν θα χάσετε την εργασία σας."
},
"toolBar": {
"selection": "Επιλογή",
@@ -215,7 +217,7 @@
"text": "Κείμενο",
"library": "Βιβλιοθήκη",
"lock": "Κράτησε επιλεγμένο το εργαλείο μετά το σχέδιο",
"penMode": "",
"penMode": "Λειτουργία μολυβιού - αποτροπή αφής",
"link": "Προσθήκη/ Ενημέρωση συνδέσμου για ένα επιλεγμένο σχήμα",
"eraser": "Γόμα"
},
@@ -236,7 +238,7 @@
"resize": "Μπορείς να περιορίσεις τις αναλογίες κρατώντας το SHIFT ενώ αλλάζεις μέγεθος,\nκράτησε πατημένο το ALT για αλλαγή μεγέθους από το κέντρο",
"resizeImage": "Μπορείτε να αλλάξετε το μέγεθος ελεύθερα κρατώντας πατημένο το SHIFT,\nκρατήστε πατημένο το ALT για να αλλάξετε το μέγεθος από το κέντρο",
"rotate": "Μπορείς να περιορίσεις τις γωνίες κρατώντας πατημένο το πλήκτρο SHIFT κατά την περιστροφή",
"lineEditor_info": "",
"lineEditor_info": "Κρατήστε πατημένο Ctrl ή Cmd και πατήστε το πλήκτρο Ctrl ή Cmd + Enter για επεξεργασία σημείων",
"lineEditor_pointSelected": "Πατήστε Διαγραφή για αφαίρεση σημείου(ων),\nCtrlOrCmd+D για αντιγραφή, ή σύρετε για μετακίνηση",
"lineEditor_nothingSelected": "Επιλέξτε ένα σημείο για να επεξεργαστείτε (κρατήστε πατημένο το SHIFT για να επιλέξετε πολλαπλά),\nή κρατήστε πατημένο το Alt και κάντε κλικ για να προσθέσετε νέα σημεία",
"placeImage": "Κάντε κλικ για να τοποθετήσετε την εικόνα ή κάντε κλικ και σύρετε για να ορίσετε το μέγεθός της χειροκίνητα",
@@ -312,8 +314,8 @@
"zoomToFit": "Zoom ώστε να χωρέσουν όλα τα στοιχεία",
"zoomToSelection": "Ζουμ στην επιλογή",
"toggleElementLock": "Κλείδωμα/Ξεκλείδωμα επιλογής",
"movePageUpDown": "",
"movePageLeftRight": ""
"movePageUpDown": "Μετακίνηση σελίδας πάνω/κάτω",
"movePageLeftRight": "Μετακίνηση σελίδας αριστερά/δεξιά"
},
"clearCanvasDialog": {
"title": "Καθαρισμός καμβά"
@@ -395,7 +397,7 @@
"fileSavedToFilename": "Αποθηκεύτηκε στο {filename}",
"canvas": "καμβάς",
"selection": "επιλογή",
"pasteAsSingleElement": ""
"pasteAsSingleElement": "Χρησιμοποίησε το {{shortcut}} για να επικολλήσεις ως ένα μόνο στοιχείο,\nή να επικολλήσεις σε έναν υπάρχοντα επεξεργαστή κειμένου"
},
"colors": {
"ffffff": "Λευκό",
@@ -445,10 +447,16 @@
"d9480f": "Πορτοκαλί 9"
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
"app": {
"center_heading": "Όλα τα δεδομένα σας αποθηκεύονται τοπικά στο πρόγραμμα περιήγησης.",
"center_heading_plus": "Μήπως θέλατε να πάτε στο Excalidraw+;",
"menuHint": "Εξαγωγή, προτιμήσεις, γλώσσες, ..."
},
"defaults": {
"menuHint": "Εξαγωγή, προτιμήσεις και άλλες επιλογές...",
"center_heading": "Διαγράμματα. Εύκολα. Γρήγορα.",
"toolbarHint": "Επιλέξτε ένα εργαλείο και ξεκινήστε να σχεδιάζεται!",
"helpHint": "Συντομεύσεις και βοήθεια"
}
}
}

View File

@@ -203,8 +203,6 @@
"invalidSVGString": "Invalid SVG.",
"cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.",
"importLibraryError": "Couldn't load library",
"socketDisconnected": "You've been disconnected from the server",
"socketConnectionError": "Couldn't connect to the server",
"collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.",
"collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work."
},
@@ -222,7 +220,8 @@
"lock": "Keep selected tool active after drawing",
"penMode": "Pen mode - prevent touch",
"link": "Add/ Update link for a selected shape",
"eraser": "Eraser"
"eraser": "Eraser",
"hand": "Hand (panning tool)"
},
"headings": {
"canvasActions": "Canvas actions",
@@ -230,7 +229,7 @@
"shapes": "Shapes"
},
"hints": {
"canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging",
"canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool",
"linearElement": "Click to start multiple points, drag for single line",
"freeDraw": "Click and drag, release when you're finished",
"text": "Tip: you can also add text by double-clicking anywhere with the selection tool",
@@ -248,7 +247,8 @@
"publishLibrary": "Publish your own library",
"bindTextToElement": "Press enter to add text",
"deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging",
"eraserRevert": "Hold Alt to revert the elements marked for deletion"
"eraserRevert": "Hold Alt to revert the elements marked for deletion",
"firefox_clipboard_write": "This feature can likely be enabled by setting the \"dom.events.asyncClipboard.clipboardItem\" flag to \"true\". To change the browser flags in Firefox, visit the \"about:config\" page."
},
"canvasError": {
"cannotShowPreview": "Cannot show preview",
@@ -450,10 +450,16 @@
"d9480f": "Orange 9"
},
"welcomeScreen": {
"data": "All your data is saved locally in your browser.",
"switchToPlusApp": "Did you want to go to the Excalidraw+ instead?",
"menuHints": "Export, preferences, languages, ...",
"toolbarHints": "Pick a tool & Start drawing!",
"helpHints": "Shortcuts & help"
"app": {
"center_heading": "All your data is saved locally in your browser.",
"center_heading_plus": "Did you want to go to the Excalidraw+ instead?",
"menuHint": "Export, preferences, languages, ..."
},
"defaults": {
"menuHint": "Export, preferences, and more...",
"center_heading": "Diagrams. Made. Simple.",
"toolbarHint": "Pick a tool & Start drawing!",
"helpHint": "Shortcuts & help"
}
}
}

View File

@@ -201,7 +201,9 @@
"svgImageInsertError": "No se pudo insertar la imagen SVG. El código SVG parece inválido.",
"invalidSVGString": "SVG no válido.",
"cannotResolveCollabServer": "No se pudo conectar al servidor colaborador. Por favor, vuelva a cargar la página y vuelva a intentarlo.",
"importLibraryError": "No se pudo cargar la librería"
"importLibraryError": "No se pudo cargar la librería",
"collabSaveFailed": "No se pudo guardar en la base de datos del backend. Si los problemas persisten, debería guardar su archivo localmente para asegurarse de que no pierde su trabajo.",
"collabSaveFailed_sizeExceeded": "No se pudo guardar en la base de datos del backend, el lienzo parece ser demasiado grande. Debería guardar el archivo localmente para asegurarse de que no pierde su trabajo."
},
"toolBar": {
"selection": "Selección",
@@ -445,10 +447,16 @@
"d9480f": "Naranja 9"
},
"welcomeScreen": {
"data": "Toda su información es guardada localmente en su navegador.",
"switchToPlusApp": "¿Quieres ir a Excalidraw+ en su lugar?",
"menuHints": "Exportar, preferencias, idiomas, ...",
"toolbarHints": "¡Escoge una herramienta & Empiece a dibujar!",
"helpHints": "Atajos & ayuda"
"app": {
"center_heading": "Toda su información es guardada localmente en su navegador.",
"center_heading_plus": "¿Quieres ir a Excalidraw+?",
"menuHint": "Exportar, preferencias, idiomas, ..."
},
"defaults": {
"menuHint": "Exportar, preferencias y más...",
"center_heading": "Diagramas. Hecho. Simplemente.",
"toolbarHint": "¡Elige una herramienta y empieza a dibujar!",
"helpHint": "Atajos & ayuda"
}
}
}

View File

@@ -1,7 +1,7 @@
{
"labels": {
"paste": "Itsatsi",
"pasteAsPlaintext": "",
"pasteAsPlaintext": "Itsatsi testu arrunt gisa",
"pasteCharts": "Itsatsi grafikoak",
"selectAll": "Hautatu dena",
"multiSelect": "Gehitu elementua hautapenera",
@@ -201,7 +201,9 @@
"svgImageInsertError": "Ezin izan da SVG irudia txertatu. SVG markak baliogabea dirudi.",
"invalidSVGString": "SVG baliogabea.",
"cannotResolveCollabServer": "Ezin izan da elkarlaneko zerbitzarira konektatu. Mesedez, berriro kargatu orria eta saiatu berriro.",
"importLibraryError": "Ezin izan da liburutegia kargatu"
"importLibraryError": "Ezin izan da liburutegia kargatu",
"collabSaveFailed": "Ezin izan da backend datu-basean gorde. Arazoak jarraitzen badu, zure fitxategia lokalean gorde beharko zenuke zure lana ez duzula galtzen ziurtatzeko.",
"collabSaveFailed_sizeExceeded": "Ezin izan da backend datu-basean gorde, ohiala handiegia dela dirudi. Fitxategia lokalean gorde beharko zenuke zure lana galtzen ez duzula ziurtatzeko."
},
"toolBar": {
"selection": "Hautapena",
@@ -236,7 +238,7 @@
"resize": "Proportzioak mantendu ditzakezu SHIFT sakatuta tamaina aldatzen duzun bitartean.\nsakatu ALT erditik tamaina aldatzeko",
"resizeImage": "Tamaina libreki alda dezakezu SHIFT sakatuta,\nsakatu ALT erditik tamaina aldatzeko",
"rotate": "Angeluak mantendu ditzakezu SHIFT sakatuta biratzen duzun bitartean",
"lineEditor_info": "",
"lineEditor_info": "Eutsi sakatuta Ctrl edo Cmd eta egin klik bikoitza edo sakatu Ctrl edo Cmd + Sartu puntuak editatzeko",
"lineEditor_pointSelected": "Sakatu Ezabatu puntuak kentzeko,\nKtrl+D bikoizteko, edo arrastatu mugitzeko",
"lineEditor_nothingSelected": "Hautatu editatzeko puntu bat (SHIFT sakatuta anitz hautatzeko),\nedo eduki Alt sakatuta eta egin klik puntu berriak gehitzeko",
"placeImage": "Egin klik irudia kokatzeko, edo egin klik eta arrastatu bere tamaina eskuz ezartzeko",
@@ -312,8 +314,8 @@
"zoomToFit": "Egin zoom elementu guztiak ikusteko",
"zoomToSelection": "Zooma hautapenera",
"toggleElementLock": "Blokeatu/desbloketatu hautapena",
"movePageUpDown": "",
"movePageLeftRight": ""
"movePageUpDown": "Mugitu orria gora/behera",
"movePageLeftRight": "Mugitu orria ezker/eskuin"
},
"clearCanvasDialog": {
"title": "Garbitu oihala"
@@ -395,7 +397,7 @@
"fileSavedToFilename": "{filename}-n gorde da",
"canvas": "oihala",
"selection": "hautapena",
"pasteAsSingleElement": ""
"pasteAsSingleElement": "Erabili {{shortcut}} elementu bakar gisa itsasteko,\nedo itsatsi lehendik dagoen testu-editore batean"
},
"colors": {
"ffffff": "Zuria",
@@ -445,10 +447,16 @@
"d9480f": "Laranja 9"
},
"welcomeScreen": {
"data": "Zure datu guztiak modu lokalean gordetzen dira zure nabigatzailean.",
"switchToPlusApp": "Horren ordez Excalidraw+-ra joan nahi al zenuen?",
"menuHints": "Esportatu, hobespenak, hizkuntzak,...",
"toolbarHints": "Aukeratu tresna bat eta hasi marrazten!",
"helpHints": "Lasterbideak eta laguntza"
"app": {
"center_heading": "Zure datu guztiak lokalean gordetzen dira zure nabigatzailean.",
"center_heading_plus": "Horren ordez Excalidraw+-era joan nahi al zenuen?",
"menuHint": "Esportatu, hobespenak, hizkuntzak..."
},
"defaults": {
"menuHint": "Esportatu, hobespenak eta gehiago...",
"center_heading": "Diagramak. Egina. Sinplea.",
"toolbarHint": "Aukeratu tresna bat eta hasi marrazten!",
"helpHint": "Lasterbideak eta laguntza"
}
}
}

View File

@@ -201,7 +201,9 @@
"svgImageInsertError": "تصویر SVG وارد نشد. نشانه گذاری SVG نامعتبر به نظر می رسد.",
"invalidSVGString": "SVG نادرست.",
"cannotResolveCollabServer": "به سرور collab متصل نشد. لطفا صفحه را مجددا بارگذاری کنید و دوباره تلاش کنید.",
"importLibraryError": "داده‌ها بارگذاری نشدند"
"importLibraryError": "داده‌ها بارگذاری نشدند",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
},
"toolBar": {
"selection": "گزینش",
@@ -445,10 +447,16 @@
"d9480f": "نارنجی 9"
},
"welcomeScreen": {
"data": "همه ی داده های شما به صورت محلی در مرورگر ذخیره میشود.",
"switchToPlusApp": "آیا ترجیح میدهید به Excalidraw+ بروید؟",
"menuHints": "خروجی گرفتن، تنظیمات، زبانها، ...",
"toolbarHints": "یک ابزار را انتخاب کنید و ترسیم را شروع کنید!",
"helpHints": "میانبرها و کمک"
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
}
}
}

View File

@@ -201,7 +201,9 @@
"svgImageInsertError": "SVG- kuvaa ei voitu lisätä. Tiedoston SVG-sisältö näyttää virheelliseltä.",
"invalidSVGString": "Virheellinen SVG.",
"cannotResolveCollabServer": "Yhteyden muodostaminen collab-palvelimeen epäonnistui. Virkistä sivu ja yritä uudelleen.",
"importLibraryError": ""
"importLibraryError": "",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
},
"toolBar": {
"selection": "Valinta",
@@ -445,10 +447,16 @@
"d9480f": "Oranssi 9"
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
}
}
}

View File

@@ -1,7 +1,7 @@
{
"labels": {
"paste": "Coller",
"pasteAsPlaintext": "",
"pasteAsPlaintext": "Coller comme texte brut",
"pasteCharts": "Coller les graphiques",
"selectAll": "Tout sélectionner",
"multiSelect": "Ajouter l'élément à la sélection",
@@ -201,7 +201,9 @@
"svgImageInsertError": "Impossible d'insérer l'image SVG. Le balisage SVG semble invalide.",
"invalidSVGString": "SVG invalide.",
"cannotResolveCollabServer": "Impossible de se connecter au serveur collaboratif. Veuillez recharger la page et réessayer.",
"importLibraryError": "Impossible de charger la bibliothèque"
"importLibraryError": "Impossible de charger la bibliothèque",
"collabSaveFailed": "Impossible d'enregistrer dans la base de données en arrière-plan. Si des problèmes persistent, vous devriez enregistrer votre fichier localement pour vous assurer de ne pas perdre votre travail.",
"collabSaveFailed_sizeExceeded": "Impossible d'enregistrer dans la base de données en arrière-plan, le tableau semble trop grand. Vous devriez enregistrer le fichier localement pour vous assurer de ne pas perdre votre travail."
},
"toolBar": {
"selection": "Sélection",
@@ -236,7 +238,7 @@
"resize": "Vous pouvez conserver les proportions en maintenant la touche MAJ pendant le redimensionnement, maintenez la touche ALT pour redimensionner par rapport au centre",
"resizeImage": "Vous pouvez redimensionner librement en maintenant SHIFT,\nmaintenez ALT pour redimensionner depuis le centre",
"rotate": "Vous pouvez restreindre les angles en maintenant MAJ pendant la rotation",
"lineEditor_info": "",
"lineEditor_info": "Maintenez CtrlOrCmd et Double-cliquez ou appuyez sur CtrlOrCmd + Entrée pour modifier les points",
"lineEditor_pointSelected": "Appuyer sur Suppr. pour supprimer des points, Ctrl ou Cmd+D pour dupliquer, ou faire glisser pour déplacer",
"lineEditor_nothingSelected": "Sélectionner un point pour éditer (maintenir la touche MAJ pour en sélectionner plusieurs),\nou maintenir la touche Alt enfoncée et cliquer pour ajouter de nouveaux points",
"placeImage": "Cliquez pour placer l'image, ou cliquez et faites glisser pour définir sa taille manuellement",
@@ -312,8 +314,8 @@
"zoomToFit": "Zoomer pour voir tous les éléments",
"zoomToSelection": "Zoomer sur la sélection",
"toggleElementLock": "Verrouiller/déverrouiller la sélection",
"movePageUpDown": "",
"movePageLeftRight": ""
"movePageUpDown": "Déplacer la page vers le haut/bas",
"movePageLeftRight": "Déplacer la page vers la gauche/droite"
},
"clearCanvasDialog": {
"title": "Effacer la zone de dessin"
@@ -395,7 +397,7 @@
"fileSavedToFilename": "Enregistré sous {filename}",
"canvas": "canevas",
"selection": "sélection",
"pasteAsSingleElement": ""
"pasteAsSingleElement": "Utiliser {{shortcut}} pour coller comme un seul élément,\nou coller dans un éditeur de texte existant"
},
"colors": {
"ffffff": "Blanc",
@@ -445,10 +447,16 @@
"d9480f": "Orange 9"
},
"welcomeScreen": {
"data": "Toutes vos données sont sauvegardées en local dans votre navigateur.",
"switchToPlusApp": "Vous vouliez plutôt aller à Excalidraw+ ?",
"menuHints": "Exportation, préférences, langues, ...",
"toolbarHints": "Choisissez un outil et commencez à dessiner !",
"helpHints": "Raccourcis et aide"
"app": {
"center_heading": "Toutes vos données sont sauvegardées en local dans votre navigateur.",
"center_heading_plus": "Vouliez-vous plutôt aller à Excalidraw+ à la place ?",
"menuHint": "Exportation, préférences, langues, ..."
},
"defaults": {
"menuHint": "Exportation, préférences et plus...",
"center_heading": "Diagrammes. Rendus. Simples.",
"toolbarHint": "Choisissez un outil et commencez à dessiner !",
"helpHint": "Raccourcis et aide"
}
}
}

View File

@@ -1,7 +1,7 @@
{
"labels": {
"paste": "Pegar",
"pasteAsPlaintext": "",
"pasteAsPlaintext": "Pegar coma texto sen formato",
"pasteCharts": "Pegar gráficos",
"selectAll": "Seleccionar todo",
"multiSelect": "Engadir elemento á selección",
@@ -201,7 +201,9 @@
"svgImageInsertError": "Non se puido inserir como imaxe SVG. O marcado SVG semella inválido.",
"invalidSVGString": "SVG inválido.",
"cannotResolveCollabServer": "Non se puido conectar ao servidor de colaboración. Por favor recargue a páxina e probe de novo.",
"importLibraryError": "Non se puido cargar a biblioteca"
"importLibraryError": "Non se puido cargar a biblioteca",
"collabSaveFailed": "Non se puido gardar na base de datos. Se o problema persiste, deberías gardar o teu arquivo de maneira local para asegurarte de non perdelo teu traballo.",
"collabSaveFailed_sizeExceeded": "Non se puido gardar na base de datos, o lenzo semella demasiado grande. Deberías gardar o teu arquivo de maneira local para asegurarte de non perdelo teu traballo."
},
"toolBar": {
"selection": "Selección",
@@ -236,7 +238,7 @@
"resize": "Pode reducir as proporcións mantendo SHIFT mentres axusta o tamaño,\nmanteña ALT para axustalo dende o centro",
"resizeImage": "Pode axustar o tamaño libremente mantendo SHIFT,\nmanteña ALT para axustalo dende o centro",
"rotate": "Podes reducir os ángulos mantendo SHIFT mentres os rotas",
"lineEditor_info": "",
"lineEditor_info": "Manteña pulsado CtrlOrCmd e faga dobre clic ou prema CtrlOrCmd + Enter para editar puntos",
"lineEditor_pointSelected": "Prema Suprimir para eliminar o(s) punto(s)\nCtrlOrCmd+D para duplicalos, ou arrastre para movelos",
"lineEditor_nothingSelected": "Seleccione un punto para editar (manteña pulsado SHIFT para selección múltiple),\nou manteña pulsado Alt e faga clic para engadir novos puntos",
"placeImage": "Faga clic para colocar a imaxe, ou faga clic e arrastre para establecer o seu tamaño manualmente",
@@ -312,8 +314,8 @@
"zoomToFit": "Zoom que se axuste a todos os elementos",
"zoomToSelection": "Zoom á selección",
"toggleElementLock": "Bloquear/desbloquear selección",
"movePageUpDown": "",
"movePageLeftRight": ""
"movePageUpDown": "Mover páxina cara enriba/abaixo",
"movePageLeftRight": "Mover páxina cara a esquerda/dereita"
},
"clearCanvasDialog": {
"title": "Limpar lenzo"
@@ -395,7 +397,7 @@
"fileSavedToFilename": "Gardado en {filename}",
"canvas": "lenzo",
"selection": "selección",
"pasteAsSingleElement": ""
"pasteAsSingleElement": "Usa {{shortcut}} para pegar como un único elemento\nou pega nun editor de texto existente"
},
"colors": {
"ffffff": "Branco",
@@ -445,10 +447,16 @@
"d9480f": "Laranxa 9"
},
"welcomeScreen": {
"data": "Toda a información é gardada de maneira local no seu navegador.",
"switchToPlusApp": "Queres ir a Excalidraw+ no seu lugar?",
"menuHints": "Exportar, preferencias, idiomas, ...",
"toolbarHints": "Escolle unha ferramenta & Comeza a debuxar!",
"helpHints": "Atallos & axuda"
"app": {
"center_heading": "Toda a información é gardada de maneira local no seu navegador.",
"center_heading_plus": "Queres ir a Excalidraw+ no seu lugar?",
"menuHint": "Exportar, preferencias, idiomas, ..."
},
"defaults": {
"menuHint": "Exportar, preferencias, e máis...",
"center_heading": "Diagramas. Feito. Sinxelo.",
"toolbarHint": "Escolle unha ferramenta & Comeza a debuxar!",
"helpHint": "Atallos & axuda"
}
}
}

View File

@@ -201,7 +201,9 @@
"svgImageInsertError": "לא ניתן היה להטמיע את תמונת ה-SVG. קידוד ה-SVG אינו תקני.",
"invalidSVGString": "SVG בלתי תקני.",
"cannotResolveCollabServer": "",
"importLibraryError": "לא ניתן היה לטעון את הספריה"
"importLibraryError": "לא ניתן היה לטעון את הספריה",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
},
"toolBar": {
"selection": "בחירה",
@@ -445,10 +447,16 @@
"d9480f": "כתום 9"
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
}
}
}

View File

@@ -1,7 +1,7 @@
{
"labels": {
"paste": "चिपकाएँ",
"pasteAsPlaintext": "",
"pasteAsPlaintext": "सादे पाठ के रूप में चिपकाएं",
"pasteCharts": "चार्ट चिपकाएँ",
"selectAll": "सभी चुनें",
"multiSelect": "आकार को चयन में जोड़ें",
@@ -201,7 +201,9 @@
"svgImageInsertError": "एसवीजी छवि सम्मिलित नहीं कर सके, एसवीजी रचना अनुचित हैं",
"invalidSVGString": "अनुचित SVG",
"cannotResolveCollabServer": "कॉलेब सर्वर से कनेक्शन नहीं हो पा रहा. कृपया पृष्ठ को पुनः लाने का प्रयास करे.",
"importLibraryError": "संग्रह प्रतिष्ठापित नहीं किया जा सका"
"importLibraryError": "संग्रह प्रतिष्ठापित नहीं किया जा सका",
"collabSaveFailed": "किसी कारण वश अंदरूनी डेटाबेस में सहेजा नहीं जा सका। यदि समस्या बनी रहती है, तो किये काम को खोने न देने के लिये अपनी फ़ाइल को स्थानीय रूप से सहेजे।",
"collabSaveFailed_sizeExceeded": "लगता है कि पृष्ठ तल काफ़ी बड़ा है, इस्कारण अंदरूनी डेटाबेस में सहेजा नहीं जा सका। किये काम को खोने न देने के लिये अपनी फ़ाइल को स्थानीय रूप से सहेजे।"
},
"toolBar": {
"selection": "चयन",
@@ -236,7 +238,7 @@
"resize": "आकार बदलते समय आप SHIFT को पकड़ कर अनुपात में कमी कर सकते हैं,\nकेंद्र से आकार बदलने के लिए ALT दबाए रखें",
"resizeImage": "",
"rotate": "आप घूर्णन करते समय SHIFT पकड़कर कोणों को विवश कर सकते हैं",
"lineEditor_info": "",
"lineEditor_info": "बिंदुओं को सम्पादित करने के लिए CtrlOrCmd को दबायें रखते हुये डबल क्लिक करे, अथवा CtrlOrCmd + Enter साथ दबाये",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": "",
@@ -395,7 +397,7 @@
"fileSavedToFilename": "",
"canvas": "",
"selection": "",
"pasteAsSingleElement": ""
"pasteAsSingleElement": "एक अवयव के रूप में चिपकाने के लिए {{shortcut}} का उपयोग करें,\nया किसी मौजूदा पाठ संपादक में चिपकायें"
},
"colors": {
"ffffff": "सफेद",
@@ -445,10 +447,16 @@
"d9480f": "नारंगी"
},
"welcomeScreen": {
"data": "आपका सर्व डेटा ब्राउज़र के भीतर स्थानिक जगह पे सुरक्षित किया गया.",
"switchToPlusApp": "बजाय आपको Excalidraw+ जगह जाना है?",
"menuHints": "निर्यात, पसंद, भाषायें, ...",
"toolbarHints": "औजार चुने और चित्रकारी प्रारंभ करे!",
"helpHints": "शॉर्ट्कट & सहाय्य"
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
}
}
}

View File

@@ -201,7 +201,9 @@
"svgImageInsertError": "Nem sikerült beszúrni az SVG-képet. Az SVG szintaktika érvénytelennek tűnik.",
"invalidSVGString": "Érvénytelen SVG.",
"cannotResolveCollabServer": "",
"importLibraryError": ""
"importLibraryError": "",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
},
"toolBar": {
"selection": "Kijelölés",
@@ -445,10 +447,16 @@
"d9480f": "Narancs 9"
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
}
}
}

View File

@@ -201,7 +201,9 @@
"svgImageInsertError": "Tidak dapat menyisipkan gambar SVG. Markup SVG sepertinya tidak valid.",
"invalidSVGString": "SVG tidak valid.",
"cannotResolveCollabServer": "Tidak dapat terhubung ke server kolab. Muat ulang laman dan coba lagi.",
"importLibraryError": "Tidak dapat memuat pustaka"
"importLibraryError": "Tidak dapat memuat pustaka",
"collabSaveFailed": "Tidak dapat menyimpan ke dalam basis data server. Jika masih berlanjut, Anda sebaiknya simpan berkas Anda secara lokal untuk memastikan pekerjaan Anda tidak hilang.",
"collabSaveFailed_sizeExceeded": "Tidak dapat menyimpan ke dalam basis data server, tampaknya ukuran kanvas terlalu besar. Anda sebaiknya simpan berkas Anda secara lokal untuk memastikan pekerjaan Anda tidak hilang."
},
"toolBar": {
"selection": "Pilihan",
@@ -445,10 +447,16 @@
"d9480f": "Jingga 9"
},
"welcomeScreen": {
"data": "Semua data Anda tersimpan secara lokal di browser.",
"switchToPlusApp": "Apa Anda ingin berpindah ke Excalidraw+?",
"menuHints": "Ekspor, preferensi, bahasa, ...",
"toolbarHints": "Ambil alat & mulai menggambar!",
"helpHints": "Pintasan & bantuan"
"app": {
"center_heading": "Semua data Anda disimpan secara lokal di peramban Anda.",
"center_heading_plus": "Apa Anda ingin berpindah ke Excalidraw+?",
"menuHint": "Ekspor, preferensi, bahasa, ..."
},
"defaults": {
"menuHint": "Ekspor, preferensi, dan selebihnya...",
"center_heading": "Diagram. Menjadi. Mudah.",
"toolbarHint": "Pilih alat & mulai menggambar!",
"helpHint": "Pintasan & bantuan"
}
}
}

View File

@@ -201,7 +201,9 @@
"svgImageInsertError": "Impossibile inserire l'immagine SVG. Il markup SVG non sembra corretto.",
"invalidSVGString": "SVG non valido.",
"cannotResolveCollabServer": "Impossibile connettersi al server di collab. Ricarica la pagina e riprova.",
"importLibraryError": "Impossibile caricare la libreria"
"importLibraryError": "Impossibile caricare la libreria",
"collabSaveFailed": "Impossibile salvare nel database di backend. Se i problemi persistono, dovresti salvare il tuo file localmente per assicurarti di non perdere il tuo lavoro.",
"collabSaveFailed_sizeExceeded": "Impossibile salvare nel database di backend, la tela sembra essere troppo grande. Dovresti salvare il file localmente per assicurarti di non perdere il tuo lavoro."
},
"toolBar": {
"selection": "Selezione",
@@ -445,10 +447,16 @@
"d9480f": "Arancio 9"
},
"welcomeScreen": {
"data": "Tutti i tuoi dati sono salvati localmente nel browser.",
"switchToPlusApp": "Volevi invece andare su Excalidraw+?",
"menuHints": "Esporta, preferenze, lingue, ...",
"toolbarHints": "Scegli uno strumento & Inizia a disegnare!",
"helpHints": "Scorciatoie & aiuto"
"app": {
"center_heading": "Tutti i tuoi dati sono salvati localmente nel browser.",
"center_heading_plus": "Volevi invece andare su Excalidraw+?",
"menuHint": "Esporta, preferenze, lingue, ..."
},
"defaults": {
"menuHint": "Esporta, preferenze, e altro...",
"center_heading": "Diagrammi. Fatto. Semplice.",
"toolbarHint": "Scegli uno strumento & Inizia a disegnare!",
"helpHint": "Scorciatoie & aiuto"
}
}
}

View File

@@ -1,7 +1,7 @@
{
"labels": {
"paste": "貼り付け",
"pasteAsPlaintext": "",
"pasteAsPlaintext": "書式なしテキストとして貼り付け",
"pasteCharts": "チャートの貼り付け",
"selectAll": "すべて選択",
"multiSelect": "複数選択",
@@ -201,7 +201,9 @@
"svgImageInsertError": "SVGイメージを挿入できませんでした。SVGマークアップは無効に見えます。",
"invalidSVGString": "無効なSVGです。",
"cannotResolveCollabServer": "コラボレーションサーバに接続できませんでした。ページを再読み込みして、もう一度お試しください。",
"importLibraryError": "ライブラリを読み込めませんでした。"
"importLibraryError": "ライブラリを読み込めませんでした。",
"collabSaveFailed": "バックエンドデータベースに保存できませんでした。問題が解決しない場合は、作業を失わないようにローカルにファイルを保存してください。",
"collabSaveFailed_sizeExceeded": "キャンバスが大きすぎるため、バックエンドデータベースに保存できませんでした。問題が解決しない場合は、作業を失わないようにローカルにファイルを保存してください。"
},
"toolBar": {
"selection": "選択",
@@ -236,7 +238,7 @@
"resize": "サイズを変更中にSHIFTを押すと縦横比を固定できます。Altを押すと中央からサイズを変更できます",
"resizeImage": "SHIFTを長押しすると自由にサイズを変更できます。\n中央からサイズを変更するにはALTを長押しします",
"rotate": "回転中にSHIFT キーを押すと角度を制限することができます",
"lineEditor_info": "",
"lineEditor_info": "CtrlOrCmd を押したままダブルクリックするか、CtrlOrCmd + Enter を押して点を編集します",
"lineEditor_pointSelected": "Deleteキーを押すと点を削除、CtrlOrCmd+Dで複製、マウスドラッグで移動",
"lineEditor_nothingSelected": "編集する点を選択SHIFTを押したままで複数選択、\nAltキーを押しながらクリックすると新しい点を追加",
"placeImage": "クリックして画像を配置するか、クリックしてドラッグしてサイズを手動で設定します",
@@ -312,8 +314,8 @@
"zoomToFit": "すべての要素が収まるようにズーム",
"zoomToSelection": "選択要素にズーム",
"toggleElementLock": "選択したアイテムをロック/ロック解除",
"movePageUpDown": "",
"movePageLeftRight": ""
"movePageUpDown": "ページを上下に移動",
"movePageLeftRight": "ページを左右に移動"
},
"clearCanvasDialog": {
"title": "キャンバスを消去"
@@ -395,7 +397,7 @@
"fileSavedToFilename": "{filename} に保存しました",
"canvas": "キャンバス",
"selection": "選択",
"pasteAsSingleElement": ""
"pasteAsSingleElement": "{{shortcut}} を使用して単一の要素として貼り付けるか、\n既存のテキストエディタに貼り付け"
},
"colors": {
"ffffff": "ホワイト",
@@ -445,10 +447,16 @@
"d9480f": "オレンジ 9"
},
"welcomeScreen": {
"data": "すべてのデータはブラウザにローカル保存されます。",
"switchToPlusApp": "代わりにExcalidraw+を開きますか?",
"menuHints": "エクスポート, 設定, 言語, ...",
"toolbarHints": "ツールを選んで描き始めよう!",
"helpHints": "ショートカットとヘルプ"
"app": {
"center_heading": "すべてのデータはブラウザにローカル保存されます。",
"center_heading_plus": "代わりにExcalidraw+を開きますか?",
"menuHint": "エクスポート、設定、言語..."
},
"defaults": {
"menuHint": "エクスポート、設定、その他...",
"center_heading": "ダイアグラムを簡単に。",
"toolbarHint": "ツールを選んで描き始めよう!",
"helpHint": "ショートカットとヘルプ"
}
}
}

View File

@@ -72,7 +72,7 @@
"layers": "Tissiyin",
"actions": "Tigawin",
"language": "Tutlayt",
"liveCollaboration": "",
"liveCollaboration": "Amɛiwen s srid...",
"duplicateSelection": "Sisleg",
"untitled": "War azwel",
"name": "Isem",
@@ -201,7 +201,9 @@
"svgImageInsertError": "D awezɣi tugra n tugna SVG. Acraḍ SVG yettban-d d armeɣtu.",
"invalidSVGString": "SVG armeɣtu.",
"cannotResolveCollabServer": "Ulamek tuqqna s aqeddac n umyalel. Ma ulac uɣilif ales asali n usebter sakin eɛreḍ tikkelt-nniḍen.",
"importLibraryError": "Ur d-ssalay ara tamkarḍit"
"importLibraryError": "Ur d-ssalay ara tamkarḍit",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
},
"toolBar": {
"selection": "Tafrayt",
@@ -312,8 +314,8 @@
"zoomToFit": "Simɣur akken ad twliḍ akk iferdisen",
"zoomToSelection": "Simɣur ɣer tefrayt",
"toggleElementLock": "Sekkeṛ/kkes asekker i tefrayt",
"movePageUpDown": "",
"movePageLeftRight": ""
"movePageUpDown": "Smutti asebter d asawen/akessar",
"movePageLeftRight": "Smutti asebter s azelmaḍ/ayfus"
},
"clearCanvasDialog": {
"title": "Sfeḍ taɣzut n usuneɣ"
@@ -445,10 +447,16 @@
"d9480f": "Aččinawi 9"
},
"welcomeScreen": {
"data": "Akk isefka-inek•inem ttwakelsen s wudem adigan deg yiminig-inek•inem.",
"switchToPlusApp": "Tebɣiḍ ad tedduḍ ɣer Excalidraw+ deg umḍiq?",
"menuHints": "Asifeḍ, ismenyifen, tutlayin, ...",
"toolbarHints": "Fren afecku tebduḍ asuneɣ!",
"helpHints": "Inegzumen akked tallelt"
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
}
}
}

View File

@@ -201,7 +201,9 @@
"svgImageInsertError": "",
"invalidSVGString": "",
"cannotResolveCollabServer": "",
"importLibraryError": ""
"importLibraryError": "",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
},
"toolBar": {
"selection": "",
@@ -445,10 +447,16 @@
"d9480f": ""
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
}
}
}

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