Compare commits

...
29 Commits
Author SHA1 Message Date
Maielo 2351af1854 updated socket io package to latest 2023-05-15 19:39:06 +02:00
Maielo 5e5faf4e67 set hidden connection to 5 mins 2023-05-15 19:38:46 +02:00
Maielo 4c70f471cb increased hidden disconnect timeout 2023-05-14 17:51:47 +02:00
dwelle d61a87f132 Merge branch 'master' into HEAD
# Conflicts:
#	src/excalidraw-app/collab/Collab.tsx
#	src/excalidraw-app/index.tsx
#	src/locales/en.json
2023-05-11 16:35:27 +02:00
ContextualistandGitHub e619e06055 fix: i18n: Apply Trans component to publish library dialogue (#6564) 2023-05-10 10:39:21 +02:00
Excalidraw BotandGitHub d8965ee823 chore: Update translations from Crowdin (#6530)
* New translations en.json (Bengali)

* New translations en.json (Bengali)

* Auto commit: Calculate translation coverage

* New translations en.json (Khmer)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* Auto commit: Calculate translation coverage

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* New translations en.json (Spanish)

* New translations en.json (Italian)

* New translations en.json (Slovak)

* New translations en.json (Bengali)

* New translations en.json (Khmer)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

* New translations en.json (French)

* 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 (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 (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* 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 (Marathi)

* New translations en.json (Thai)

* 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 (Romanian)

* New translations en.json (Korean)

* New translations en.json (Russian)

* New translations en.json (Slovenian)

* New translations en.json (Norwegian Bokmal)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* New translations en.json (German)

* New translations en.json (Chinese Traditional)

* Auto commit: Calculate translation coverage

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Vietnamese)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* New translations en.json (Spanish)

* New translations en.json (Italian)

* New translations en.json (Slovak)

* New translations en.json (Bengali)

* New translations en.json (Khmer)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

* New translations en.json (French)

* 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 (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 (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* 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 (Marathi)

* New translations en.json (Thai)

* 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 (Kurdish)

* New translations en.json (Slovenian)

* New translations en.json (Norwegian Bokmal)

* Auto commit: Calculate translation coverage

* New translations en.json (German)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* Auto commit: Calculate translation coverage

* New translations en.json (Russian)

* Auto commit: Calculate translation coverage

* New translations en.json (Korean)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Swedish)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* New translations en.json (Persian)

* Auto commit: Calculate translation coverage
2023-05-09 18:15:27 +05:30
David LuzarandGitHub 560231d365 perf: use UIAppState where possible to reduce UI rerenders (#6560) 2023-05-08 10:14:02 +02:00
David LuzarandGitHub 026949204d fix: fix brave error i18n string and remove unused (#6561) 2023-05-06 10:36:42 +02:00
1184a8c0e9 feat: Add Trans component for interpolating JSX in translations (#6534)
* feat: add Trans component

* Add comments

* tweak

* Move brave to trans component

* fix test and tweaks

* remove any

* fix

* fix

* comment

* replace render function type

* Use tags for Trans

* Fix a typo

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>

* Cleanup, add comments, add support for kebab case

* tweaks

---------

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-05-05 21:35:18 +05:30
e9cae918a7 feat: sidebar tabs support (#6213)
* feat: Sidebar tabs support [wip]

* tab trigger styling tweaks

* add `:hover` & `:active` states

* replace `@dwelle/tunnel-rat` with `tunnel-rat`

* make stuff more explicit

- remove `Sidebar.Header` fallback (host apps need to render manually), and stop tunneling it (render in place)
- make `docked` state explicit
- stop tunneling `Sidebar.TabTriggers` (render in place)

* redesign sidebar / library as per latest spec

* support no label on `Sidebar.Trigger`

* add Sidebar `props.onStateChange`

* style fixes

* make `appState.isSidebarDocked` into a soft user preference

* px -> rem & refactor

* remove `props.renderSidebar`

* update tests

* remove

* refactor

* rename constants

* tab triggers styling fixes

* factor out library-related logic from generic sidebar trigger

* change `props.onClose` to `onToggle`

* rename `props.value` -> `props.tab`

* add displayNames

* allow HTMLAttributes on applicable compos

* fix example App

* more styling tweaks and fixes

* fix not setting `dockable`

* more style fixes

* fix and align sidebar header button styling

* make DefaultSidebar dockable on if host apps supplies `onDock`

* stop `Sidebar.Trigger` hiding label on mobile

this should be only the default sidebar trigger behavior, and for that we don't need to use `device` hook as we handle in CSS

* fix `dockable` prop of defaultSidebar

* remove extra `typescript` dep

* remove `defaultTab` prop

in favor of explicit `tab` value in `<Sidebar.Trigger/>` and `toggleSidebar()`, to reduce API surface area and solve inconsistency of `appState.openSidebar.tab` not reflecting actual UI value if `defaultTab` was supported (without additional syncing logic which feels like the wrong solution).

* remove `onToggle` in favor of `onStateChange`

reducing API surface area

* fix restore

* comment no longer applies

* reuse `Button` component in sidebar buttons

* fix tests

* split Sidebar sub-components into files

* remove `props.dockable` in favor of `props.onDock` only

* split tests

* fix sidebar showing dock button if no `props.docked` supplied & add more tests

* reorder and group sidebar tests

* clarify

* rename classes & dedupe css

* refactor tests

* update changelog

* update changelog

---------

Co-authored-by: barnabasmolnar <barnabas@excalidraw.com>
2023-05-04 17:33:31 +00:00
Aakansha DoshiandGitHub b1311a407a fix: Revert add version tags to Docker build (#6540)
Revert "build: Add version tags to Docker build (#6508)"

This reverts commit 1815cf3213.
2023-05-02 12:49:11 +05:30
Excalidraw BotandGitHub 2a39d0b9a7 chore: Update translations from Crowdin (#6471) 2023-04-27 19:27:36 +02:00
6b0218b012 feat: testing simple analytics and fathom analytics for better privacy of the users (#6529)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-04-27 19:11:42 +02:00
Aakansha DoshiandGitHub 45a57d70de fix: don't refresh dimensions for text containers on font load (#6523) 2023-04-26 21:35:06 +05:30
Aakansha DoshiandGitHub da8dd389a9 fix: cleanup getMaxContainerHeight and getMaxContainerWidth (#6519)
* fix: cleanup getMaxContainerHeight and getMaxContainerWidth

* rename getMaxContainerWidth -> getBoundTextMaxMaxWidth and getMaxContainerHeight -> getBoundTextMaxHeight

* add specs
2023-04-25 18:06:23 +05:30
Aakansha DoshiandGitHub dae81c0a2c fix: cleanup redrawTextBoundingBox (#6518)
* chore: cleanup redrawTextBoundingBox

* fix
2023-04-25 17:57:53 +05:30
suwalkanishkaandGitHub 1e9943323a style: fix font family inconsistencies (#6501)
style: font fix for four components

The browser default font was showing up in various locations. Fixed them to show the desired ui font.
2023-04-25 17:20:19 +05:30
NainterceptorandGitHub 1815cf3213 build: Add version tags to Docker build (#6508)
ci: Add version tags
2023-04-25 16:51:25 +05:30
David LuzarandGitHub d35386755f feat: retain seed on shift-paste (#6509)
thanks for the review 👍
2023-04-24 10:26:21 +02:00
zsviczianandGitHub 9d5cfbbfb7 fix: text jumps when editing on Android Chrome (#6503)
* debug logging

* debug

* debugging

* Update textWysiwyg.tsx

* Update textWysiwyg.tsx

* extended debug information

* debug

* debug

* trace

* further debug

* don't drag while editing

* removing all console.logs

* revert all changes to textWysiwyt.tsx

* updated comment
2023-04-22 14:17:13 +02:00
David LuzarandGitHub fee760d38c feat: allow avif, jfif, webp, bmp, ico image types (#6500
* feat: allow `avif`, `jfif`, `webp`, `bmp`, `ico` image types

* dedupe for SSOT

* more SSOT
2023-04-21 22:53:49 +02:00
Aakansha DoshiandGitHub 2a4799d8c8 chore: upgrade excalidraw version to 0.15.2 (#6496)
chore: updragde excalidraw version to 0.15.2
2023-04-20 17:40:08 +05:30
Ryan Di 4d3dec612c remove redudant setToast to null 2022-09-01 20:46:04 +08:00
Ryan Di 8b84322627 remove exhaustive-deps that's no longer needed 2022-09-01 19:41:33 +08:00
Ryan Di df5a5f7d86 use excalidrawAPI.setToast for reconnection toast message 2022-09-01 19:33:46 +08:00
Ryan Di a1b1a2f9a3 fix >5s blank scene for first one in the room when reconnecting 2022-08-30 22:36:24 +08:00
Ryan Di c71495b140 add toast message when reconnecting to server 2022-08-29 16:23:52 +08:00
Ryan Di f8c9f63650 fix: disconnect from and reconnect to socket server when visibilitychange 2022-08-26 20:11:16 +08:00
Ryan Di 2816c72c62 fix: fix function boolean typo 2022-08-26 20:00:39 +08:00
161 changed files with 4605 additions and 4064 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
"@docusaurus/core": "2.2.0", "@docusaurus/core": "2.2.0",
"@docusaurus/preset-classic": "2.2.0", "@docusaurus/preset-classic": "2.2.0",
"@docusaurus/theme-live-codeblock": "2.2.0", "@docusaurus/theme-live-codeblock": "2.2.0",
"@excalidraw/excalidraw": "0.15.0", "@excalidraw/excalidraw": "0.15.2",
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3", "docusaurus-plugin-sass": "0.2.3",
+4 -4
View File
@@ -1631,10 +1631,10 @@
url-loader "^4.1.1" url-loader "^4.1.1"
webpack "^5.73.0" webpack "^5.73.0"
"@excalidraw/excalidraw@0.15.0": "@excalidraw/excalidraw@0.15.2":
version "0.15.0" version "0.15.2"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.0.tgz#47170de8d3ff006e9d09dfede2815682b0d4485b" resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.2.tgz#7dba4f6e10c52015a007efb75a9fc1afe598574c"
integrity sha512-PJmh1VcuRHG4l+Zgt9qhezxrJ16tYCZFZ8if5IEfmTL9A/7c5mXxY/qrPTqiGlVC7jYs+ciePXQ0YUDzfOfbzw== integrity sha512-rTI02kgWSTXiUdIkBxt9u/581F3eXcqQgJdIxmz54TFtG3ughoxO5fr4t7Fr2LZIturBPqfocQHGKZ0t2KLKgw==
"@hapi/hoek@^9.0.0": "@hapi/hoek@^9.0.0":
version "9.3.0" version "9.3.0"
+3 -3
View File
@@ -19,7 +19,7 @@
] ]
}, },
"dependencies": { "dependencies": {
"@dwelle/tunnel-rat": "0.1.1", "@radix-ui/react-tabs": "1.0.2",
"@sentry/browser": "6.2.5", "@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5", "@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.16.2", "@testing-library/jest-dom": "5.16.2",
@@ -50,8 +50,8 @@
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"roughjs": "4.5.2", "roughjs": "4.5.2",
"sass": "1.51.0", "sass": "1.51.0",
"socket.io-client": "2.3.1", "socket.io-client": "4.6.1",
"tunnel-rat": "0.1.0", "tunnel-rat": "0.1.2",
"workbox-background-sync": "^6.5.4", "workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4", "workbox-broadcast-update": "^6.5.4",
"workbox-cacheable-response": "^6.5.4", "workbox-cacheable-response": "^6.5.4",
+20 -25
View File
@@ -150,6 +150,14 @@
</script> </script>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %> <% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
<!-- Fathom - privacy-friendly analytics -->
<script
src="https://cdn.usefathom.com/script.js"
data-site="VMSBUEYA"
defer
></script>
<!-- / Fathom -->
<!-- LEGACY GOOGLE ANALYTICS --> <!-- LEGACY GOOGLE ANALYTICS -->
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %> <% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script <script
@@ -166,31 +174,6 @@
</script> </script>
<% } %> <% } %>
<!-- end LEGACY GOOGLE ANALYTICS --> <!-- end LEGACY GOOGLE ANALYTICS -->
<!-- Matomo -->
<% if (process.env.REACT_APP_MATOMO_URL &&
process.env.REACT_APP_MATOMO_SITE_ID &&
process.env.REACT_APP_CDN_MATOMO_TRACKER_URL) { %>
<script>
var _paq = (window._paq = window._paq || []);
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(["trackPageView"]);
_paq.push(["enableLinkTracking"]);
(function () {
var u = "%REACT_APP_MATOMO_URL%";
_paq.push(["setTrackerUrl", u + "matomo.php"]);
_paq.push(["setSiteId", "%REACT_APP_MATOMO_SITE_ID%"]);
var d = document,
g = d.createElement("script"),
s = d.getElementsByTagName("script")[0];
g.async = true;
g.src = "%REACT_APP_CDN_MATOMO_TRACKER_URL%";
s.parentNode.insertBefore(g, s);
})();
</script>
<% } %>
<!-- end Matomo analytics -->
<% } %> <% } %>
<!-- FIXME: remove this when we update CRA (fix SW caching) --> <!-- FIXME: remove this when we update CRA (fix SW caching) -->
@@ -244,5 +227,17 @@
<h1 class="visually-hidden">Excalidraw</h1> <h1 class="visually-hidden">Excalidraw</h1>
</header> </header>
<div id="root"></div> <div id="root"></div>
<!-- 100% privacy friendly analytics -->
<script
async
defer
src="https://scripts.simpleanalyticscdn.com/latest.js"
></script>
<noscript
><img
src="https://queue.simpleanalyticscdn.com/noscript.gif"
alt=""
referrerpolicy="no-referrer-when-downgrade"
/></noscript>
</body> </body>
</html> </html>
+1 -1
View File
@@ -18,7 +18,7 @@ export const actionCopy = register({
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState, true); const selectedElements = getSelectedElements(elements, appState, true);
copyToClipboard(selectedElements, appState, app.files); copyToClipboard(selectedElements, app.files);
return { return {
commitToHistory: false, commitToHistory: false,
+14 -3
View File
@@ -20,9 +20,20 @@ export const trackEvent = (
}); });
} }
// MATOMO event tracking _paq must be same as the one in index.html if (window.sa_event) {
if (window._paq) { window.sa_event(action, {
window._paq.push(["trackEvent", category, action, label, value]); category,
label,
value,
});
}
if (window.fathom) {
window.fathom.trackEvent(action, {
category,
label,
value,
});
} }
} catch (error) { } catch (error) {
console.error("error during analytics", error); console.error("error during analytics", error);
+6 -2
View File
@@ -58,7 +58,7 @@ export const getDefaultAppState = (): Omit<
fileHandle: null, fileHandle: null,
gridSize: null, gridSize: null,
isBindingEnabled: true, isBindingEnabled: true,
isSidebarDocked: false, defaultSidebarDockedPreference: false,
isLoading: false, isLoading: false,
isResizing: false, isResizing: false,
isRotating: false, isRotating: false,
@@ -150,7 +150,11 @@ const APP_STATE_STORAGE_CONF = (<
gridSize: { browser: true, export: true, server: true }, gridSize: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false }, isBindingEnabled: { browser: false, export: false, server: false },
isSidebarDocked: { browser: true, export: false, server: false }, defaultSidebarDockedPreference: {
browser: true,
export: false,
server: false,
},
isLoading: { browser: false, export: false, server: false }, isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false }, isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false }, isRotating: { browser: false, export: false, server: false },
+27 -11
View File
@@ -2,12 +2,12 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import { AppState, BinaryFiles } from "./types"; import { BinaryFiles } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export"; import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks"; import { isInitializedImageElement } from "./element/typeChecks";
import { isPromiseLike } from "./utils"; import { isPromiseLike, isTestEnv } from "./utils";
type ElementsClipboard = { type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -55,24 +55,40 @@ const clipboardContainsElements = (
export const copyToClipboard = async ( export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles | null, files: BinaryFiles | null,
) => { ) => {
let foundFile = false;
const _files = elements.reduce((acc, element) => {
if (isInitializedImageElement(element)) {
foundFile = true;
if (files && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
}
return acc;
}, {} as BinaryFiles);
if (foundFile && !files) {
console.warn(
"copyToClipboard: attempting to file element(s) without providing associated `files` object.",
);
}
// select binded text elements when copying // select binded text elements when copying
const contents: ElementsClipboard = { const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard, type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements, elements,
files: files files: files ? _files : undefined,
? elements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles)
: undefined,
}; };
const json = JSON.stringify(contents); const json = JSON.stringify(contents);
if (isTestEnv()) {
return json;
}
CLIPBOARD = json; CLIPBOARD = json;
try { try {
PREFER_APP_CLIPBOARD = false; PREFER_APP_CLIPBOARD = false;
await copyTextToSystemClipboard(json); await copyTextToSystemClipboard(json);
+7 -6
View File
@@ -14,7 +14,7 @@ import {
hasText, hasText,
} from "../scene"; } from "../scene";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
import { AppState, Zoom } from "../types"; import { UIAppState, Zoom } from "../types";
import { import {
capitalizeString, capitalizeString,
isTransparent, isTransparent,
@@ -28,19 +28,20 @@ import { trackEvent } from "../analytics";
import { hasBoundTextElement } from "../element/typeChecks"; import { hasBoundTextElement } from "../element/typeChecks";
import clsx from "clsx"; import clsx from "clsx";
import { actionToggleZenMode } from "../actions"; import { actionToggleZenMode } from "../actions";
import "./Actions.scss";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { import {
shouldAllowVerticalAlign, shouldAllowVerticalAlign,
suppportsHorizontalAlign, suppportsHorizontalAlign,
} from "../element/textElement"; } from "../element/textElement";
import "./Actions.scss";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
elements, elements,
renderAction, renderAction,
}: { }: {
appState: AppState; appState: UIAppState;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
}) => { }) => {
@@ -215,10 +216,10 @@ export const ShapesSwitcher = ({
appState, appState,
}: { }: {
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
activeTool: AppState["activeTool"]; activeTool: UIAppState["activeTool"];
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void; onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: AppState; appState: UIAppState;
}) => ( }) => (
<> <>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
+53 -41
View File
@@ -60,6 +60,7 @@ import {
ENV, ENV,
EVENT, EVENT,
GRID_SIZE, GRID_SIZE,
IMAGE_MIME_TYPES,
IMAGE_RENDER_TIMEOUT, IMAGE_RENDER_TIMEOUT,
isAndroid, isAndroid,
isBrave, isBrave,
@@ -209,6 +210,8 @@ import {
PointerDownState, PointerDownState,
SceneData, SceneData,
Device, Device,
SidebarName,
SidebarTabName,
} from "../types"; } from "../types";
import { import {
debounce, debounce,
@@ -298,6 +301,9 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionWrapTextInContainer } from "../actions/actionBoundText"; import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError"; import BraveMeasureTextError from "./BraveMeasureTextError";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
const deviceContextInitialValue = { const deviceContextInitialValue = {
isSmScreen: false, isSmScreen: false,
isMobile: false, isMobile: false,
@@ -339,6 +345,8 @@ const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
); );
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext"; ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
export const useApp = () => useContext(AppContext);
export const useAppProps = () => useContext(AppPropsContext);
export const useDevice = () => useContext<Device>(DeviceContext); export const useDevice = () => useContext<Device>(DeviceContext);
export const useExcalidrawContainer = () => export const useExcalidrawContainer = () =>
useContext(ExcalidrawContainerContext); useContext(ExcalidrawContainerContext);
@@ -399,7 +407,7 @@ class App extends React.Component<AppProps, AppState> {
private nearestScrollableContainer: HTMLElement | Document | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined; public libraryItemsFromStorage: LibraryItems | undefined;
private id: string; public id: string;
private history: History; private history: History;
private excalidrawContainerValue: { private excalidrawContainerValue: {
container: HTMLDivElement | null; container: HTMLDivElement | null;
@@ -437,7 +445,7 @@ class App extends React.Component<AppProps, AppState> {
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight, height: window.innerHeight,
showHyperlinkPopup: false, showHyperlinkPopup: false,
isSidebarDocked: false, defaultSidebarDockedPreference: false,
}; };
this.id = nanoid(); this.id = nanoid();
@@ -468,7 +476,7 @@ class App extends React.Component<AppProps, AppState> {
setActiveTool: this.setActiveTool, setActiveTool: this.setActiveTool,
setCursor: this.setCursor, setCursor: this.setCursor,
resetCursor: this.resetCursor, resetCursor: this.resetCursor,
toggleMenu: this.toggleMenu, toggleSidebar: this.toggleSidebar,
} as const; } as const;
if (typeof excalidrawRef === "function") { if (typeof excalidrawRef === "function") {
excalidrawRef(api); excalidrawRef(api);
@@ -576,6 +584,8 @@ class App extends React.Component<AppProps, AppState> {
this.props.handleKeyboardGlobally ? undefined : this.onKeyDown this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
} }
> >
<AppContext.Provider value={this}>
<AppPropsContext.Provider value={this.props}>
<ExcalidrawContainerContext.Provider <ExcalidrawContainerContext.Provider
value={this.excalidrawContainerValue} value={this.excalidrawContainerValue}
> >
@@ -598,26 +608,14 @@ class App extends React.Component<AppProps, AppState> {
onLockToggle={this.toggleLock} onLockToggle={this.toggleLock}
onPenModeToggle={this.togglePenMode} onPenModeToggle={this.togglePenMode}
onHandToolToggle={this.onHandToolToggle} onHandToolToggle={this.onHandToolToggle}
onInsertElements={(elements) =>
this.addElementsFromPasteOrLibrary({
elements,
position: "center",
files: null,
})
}
langCode={getLanguage().code} langCode={getLanguage().code}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats} renderCustomStats={renderCustomStats}
renderCustomSidebar={this.props.renderSidebar}
showExitZenModeBtn={ showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" && typeof this.props?.zenModeEnabled === "undefined" &&
this.state.zenModeEnabled this.state.zenModeEnabled
} }
libraryReturnUrl={this.props.libraryReturnUrl}
UIOptions={this.props.UIOptions} UIOptions={this.props.UIOptions}
focusContainer={this.focusContainer}
library={this.library}
id={this.id}
onImageAction={this.onImageAction} onImageAction={this.onImageAction}
renderWelcomeScreen={ renderWelcomeScreen={
!this.state.isLoading && !this.state.isLoading &&
@@ -663,14 +661,14 @@ class App extends React.Component<AppProps, AppState> {
</ExcalidrawSetAppStateContext.Provider> </ExcalidrawSetAppStateContext.Provider>
</DeviceContext.Provider> </DeviceContext.Provider>
</ExcalidrawContainerContext.Provider> </ExcalidrawContainerContext.Provider>
</AppPropsContext.Provider>
</AppContext.Provider>
</div> </div>
); );
} }
public focusContainer: AppClassProperties["focusContainer"] = () => { public focusContainer: AppClassProperties["focusContainer"] = () => {
if (this.props.autoFocus) {
this.excalidrawContainerRef.current?.focus(); this.excalidrawContainerRef.current?.focus();
}
}; };
public getSceneElementsIncludingDeleted = () => { public getSceneElementsIncludingDeleted = () => {
@@ -681,6 +679,14 @@ class App extends React.Component<AppProps, AppState> {
return this.scene.getNonDeletedElements(); return this.scene.getNonDeletedElements();
}; };
public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
this.addElementsFromPasteOrLibrary({
elements,
position: "center",
files: null,
});
};
private syncActionResult = withBatchedUpdates( private syncActionResult = withBatchedUpdates(
(actionResult: ActionResult) => { (actionResult: ActionResult) => {
if (this.unmounted || actionResult === false) { if (this.unmounted || actionResult === false) {
@@ -950,7 +956,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.addCallback(this.onSceneUpdated); this.scene.addCallback(this.onSceneUpdated);
this.addEventListeners(); this.addEventListeners();
if (this.excalidrawContainerRef.current) { if (this.props.autoFocus && this.excalidrawContainerRef.current) {
this.focusContainer(); this.focusContainer();
} }
@@ -1589,6 +1595,7 @@ class App extends React.Component<AppProps, AppState> {
elements: data.elements, elements: data.elements,
files: data.files || null, files: data.files || null,
position: "cursor", position: "cursor",
retainSeed: isPlainPaste,
}); });
} else if (data.text) { } else if (data.text) {
this.addTextFromPaste(data.text, isPlainPaste); this.addTextFromPaste(data.text, isPlainPaste);
@@ -1602,6 +1609,7 @@ class App extends React.Component<AppProps, AppState> {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
files: BinaryFiles | null; files: BinaryFiles | null;
position: { clientX: number; clientY: number } | "cursor" | "center"; position: { clientX: number; clientY: number } | "cursor" | "center";
retainSeed?: boolean;
}) => { }) => {
const elements = restoreElements(opts.elements, null); const elements = restoreElements(opts.elements, null);
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
@@ -1639,6 +1647,9 @@ class App extends React.Component<AppProps, AppState> {
y: element.y + gridY - minY, y: element.y + gridY - minY,
}); });
}), }),
{
randomizeSeed: !opts.retainSeed,
},
); );
const nextElements = [ const nextElements = [
@@ -1673,7 +1684,7 @@ class App extends React.Component<AppProps, AppState> {
openSidebar: openSidebar:
this.state.openSidebar && this.state.openSidebar &&
this.device.canDeviceFitSidebar && this.device.canDeviceFitSidebar &&
this.state.isSidebarDocked this.state.defaultSidebarDockedPreference
? this.state.openSidebar ? this.state.openSidebar
: null, : null,
selectedElementIds: newElements.reduce( selectedElementIds: newElements.reduce(
@@ -2011,30 +2022,24 @@ class App extends React.Component<AppProps, AppState> {
/** /**
* @returns whether the menu was toggled on or off * @returns whether the menu was toggled on or off
*/ */
public toggleMenu = ( public toggleSidebar = ({
type: "library" | "customSidebar", name,
force?: boolean, tab,
): boolean => { force,
if (type === "customSidebar" && !this.props.renderSidebar) { }: {
console.warn( name: SidebarName;
`attempting to toggle "customSidebar", but no "props.renderSidebar" is defined`, tab?: SidebarTabName;
); force?: boolean;
return false; }): boolean => {
} let nextName;
if (type === "library" || type === "customSidebar") {
let nextValue;
if (force === undefined) { if (force === undefined) {
nextValue = this.state.openSidebar === type ? null : type; nextName = this.state.openSidebar?.name === name ? null : name;
} else { } else {
nextValue = force ? type : null; nextName = force ? name : null;
} }
this.setState({ openSidebar: nextValue }); this.setState({ openSidebar: nextName ? { name: nextName, tab } : null });
return !!nextValue; return !!nextName;
}
return false;
}; };
private updateCurrentCursorPosition = withBatchedUpdates( private updateCurrentCursorPosition = withBatchedUpdates(
@@ -4720,7 +4725,12 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.drag.hasOccurred = true; pointerDownState.drag.hasOccurred = true;
// prevent dragging even if we're no longer holding cmd/ctrl otherwise // prevent dragging even if we're no longer holding cmd/ctrl otherwise
// it would have weird results (stuff jumping all over the screen) // it would have weird results (stuff jumping all over the screen)
if (selectedElements.length > 0 && !pointerDownState.withCmdOrCtrl) { // Checking for editingElement to avoid jump while editing on mobile #6503
if (
selectedElements.length > 0 &&
!pointerDownState.withCmdOrCtrl &&
!this.state.editingElement
) {
const [dragX, dragY] = getGridPoint( const [dragX, dragY] = getGridPoint(
pointerCoords.x - pointerDownState.drag.offset.x, pointerCoords.x - pointerDownState.drag.offset.x,
pointerCoords.y - pointerDownState.drag.offset.y, pointerCoords.y - pointerDownState.drag.offset.y,
@@ -5743,7 +5753,9 @@ class App extends React.Component<AppProps, AppState> {
const imageFile = await fileOpen({ const imageFile = await fileOpen({
description: "Image", description: "Image",
extensions: ["jpg", "png", "svg", "gif"], extensions: Object.keys(
IMAGE_MIME_TYPES,
) as (keyof typeof IMAGE_MIME_TYPES)[],
}); });
const imageElement = this.createImageElement({ const imageElement = this.createImageElement({
+26 -25
View File
@@ -1,39 +1,40 @@
import { t } from "../i18n"; import Trans from "./Trans";
const BraveMeasureTextError = () => { const BraveMeasureTextError = () => {
return ( return (
<div data-testid="brave-measure-text-error"> <div data-testid="brave-measure-text-error">
<p> <p>
{t("errors.brave_measure_text_error.start")} &nbsp; <Trans
<span style={{ fontWeight: 600 }}> i18nKey="errors.brave_measure_text_error.line1"
{t("errors.brave_measure_text_error.aggressive_block_fingerprint")} bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
</span>{" "} />
{t("errors.brave_measure_text_error.setting_enabled")}.
<br />
<br />
{t("errors.brave_measure_text_error.break")}{" "}
<span style={{ fontWeight: 600 }}>
{t("errors.brave_measure_text_error.text_elements")}
</span>{" "}
{t("errors.brave_measure_text_error.in_your_drawings")}.
</p> </p>
<p> <p>
{t("errors.brave_measure_text_error.strongly_recommend")}{" "} <Trans
i18nKey="errors.brave_measure_text_error.line2"
bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
/>
</p>
<p>
<Trans
i18nKey="errors.brave_measure_text_error.line3"
link={(el) => (
<a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"> <a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
{" "} {el}
{t("errors.brave_measure_text_error.steps")} </a>
</a>{" "} )}
{t("errors.brave_measure_text_error.how")}. />
</p> </p>
<p> <p>
{t("errors.brave_measure_text_error.disable_setting")}{" "} <Trans
i18nKey="errors.brave_measure_text_error.line4"
issueLink={(el) => (
<a href="https://github.com/excalidraw/excalidraw/issues/new"> <a href="https://github.com/excalidraw/excalidraw/issues/new">
{t("errors.brave_measure_text_error.issue")} {el}
</a>{" "}
{t("errors.brave_measure_text_error.write")}{" "}
<a href="https://discord.gg/UexuTaE">
{t("errors.brave_measure_text_error.discord")}
</a> </a>
. )}
discordLink={(el) => <a href="https://discord.gg/UexuTaE">{el}.</a>}
/>
</p> </p>
</div> </div>
); );
+8 -4
View File
@@ -1,8 +1,12 @@
import clsx from "clsx";
import { composeEventHandlers } from "../utils";
import "./Button.scss"; import "./Button.scss";
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> { interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
type?: "button" | "submit" | "reset"; type?: "button" | "submit" | "reset";
onSelect: () => any; onSelect: () => any;
/** whether button is in active state */
selected?: boolean;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
} }
@@ -15,18 +19,18 @@ interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
export const Button = ({ export const Button = ({
type = "button", type = "button",
onSelect, onSelect,
selected,
children, children,
className = "", className = "",
...rest ...rest
}: ButtonProps) => { }: ButtonProps) => {
return ( return (
<button <button
onClick={(event) => { onClick={composeEventHandlers(rest.onClick, (event) => {
onSelect(); onSelect();
rest.onClick?.(event); })}
}}
type={type} type={type}
className={`excalidraw-button ${className}`} className={clsx("excalidraw-button", className, { selected })}
{...rest} {...rest}
> >
{children} {children}
+1
View File
@@ -183,6 +183,7 @@
width: 100%; width: 100%;
margin: 0; margin: 0;
font-size: 0.875rem; font-size: 0.875rem;
font-family: inherit;
background-color: transparent; background-color: transparent;
color: var(--text-primary-color); color: var(--text-primary-color);
border: 0; border: 0;
+5 -2
View File
@@ -4,8 +4,8 @@ import { Dialog, DialogProps } from "./Dialog";
import "./ConfirmDialog.scss"; import "./ConfirmDialog.scss";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { useExcalidrawSetAppState } from "./App"; import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
interface Props extends Omit<DialogProps, "onCloseRequest"> { interface Props extends Omit<DialogProps, "onCloseRequest"> {
@@ -26,6 +26,7 @@ const ConfirmDialog = (props: Props) => {
} = props; } = props;
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
const { container } = useExcalidrawContainer();
return ( return (
<Dialog <Dialog
@@ -42,6 +43,7 @@ const ConfirmDialog = (props: Props) => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
setIsLibraryMenuOpen(false); setIsLibraryMenuOpen(false);
onCancel(); onCancel();
container?.focus();
}} }}
/> />
<DialogActionButton <DialogActionButton
@@ -50,6 +52,7 @@ const ConfirmDialog = (props: Props) => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
setIsLibraryMenuOpen(false); setIsLibraryMenuOpen(false);
onConfirm(); onConfirm();
container?.focus();
}} }}
actionType="danger" actionType="danger"
/> />
+1
View File
@@ -30,6 +30,7 @@
background-color: transparent; background-color: transparent;
border: none; border: none;
white-space: nowrap; white-space: nowrap;
font-family: inherit;
display: grid; display: grid;
grid-template-columns: 1fr 0.2fr; grid-template-columns: 1fr 0.2fr;
+144
View File
@@ -0,0 +1,144 @@
import React from "react";
import { DEFAULT_SIDEBAR } from "../constants";
import { DefaultSidebar } from "../packages/excalidraw/index";
import {
fireEvent,
waitFor,
withExcalidrawDimensions,
} from "../tests/test-utils";
import {
assertExcalidrawWithSidebar,
assertSidebarDockButton,
} from "./Sidebar/Sidebar.test";
const { h } = window;
describe("DefaultSidebar", () => {
it("when `docked={undefined}` & `onDock={undefined}`, should allow docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { dockButton } = await assertSidebarDockButton(true);
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(true);
expect(dockButton).toHaveClass("selected");
});
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
expect(dockButton).not.toHaveClass("selected");
});
},
);
});
it("when `docked={undefined}` & `onDock`, should allow docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar onDock={() => {}} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { dockButton } = await assertSidebarDockButton(true);
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(true);
expect(dockButton).toHaveClass("selected");
});
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
expect(dockButton).not.toHaveClass("selected");
});
},
);
});
it("when `docked={true}` & `onDock`, should allow docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar onDock={() => {}} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { dockButton } = await assertSidebarDockButton(true);
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(true);
expect(dockButton).toHaveClass("selected");
});
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
expect(dockButton).not.toHaveClass("selected");
});
},
);
});
it("when `onDock={false}`, should disable docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar onDock={false} />,
DEFAULT_SIDEBAR.name,
async () => {
await withExcalidrawDimensions(
{ width: 1920, height: 1080 },
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
await assertSidebarDockButton(false);
},
);
},
);
});
it("when `docked={true}` & `onDock={false}`, should force-dock sidebar", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar docked onDock={false} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { sidebar } = await assertSidebarDockButton(false);
expect(sidebar).toHaveClass("sidebar--docked");
},
);
});
it("when `docked={true}` & `onDock={undefined}`, should force-dock sidebar", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar docked />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { sidebar } = await assertSidebarDockButton(false);
expect(sidebar).toHaveClass("sidebar--docked");
},
);
});
it("when `docked={false}` & `onDock={undefined}`, should force-undock sidebar", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar docked={false} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { sidebar } = await assertSidebarDockButton(false);
expect(sidebar).not.toHaveClass("sidebar--docked");
},
);
});
});
+118
View File
@@ -0,0 +1,118 @@
import clsx from "clsx";
import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
import { useTunnels } from "../context/tunnels";
import { useUIAppState } from "../context/ui-appState";
import { t } from "../i18n";
import { MarkOptional, Merge } from "../utility-types";
import { composeEventHandlers } from "../utils";
import { useExcalidrawSetAppState } from "./App";
import { withInternalFallback } from "./hoc/withInternalFallback";
import { LibraryMenu } from "./LibraryMenu";
import { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
import { Sidebar } from "./Sidebar/Sidebar";
const DefaultSidebarTrigger = withInternalFallback(
"DefaultSidebarTrigger",
(
props: Omit<SidebarTriggerProps, "name"> &
React.HTMLAttributes<HTMLDivElement>,
) => {
const { DefaultSidebarTriggerTunnel } = useTunnels();
return (
<DefaultSidebarTriggerTunnel.In>
<Sidebar.Trigger
{...props}
className="default-sidebar-trigger"
name={DEFAULT_SIDEBAR.name}
/>
</DefaultSidebarTriggerTunnel.In>
);
},
);
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
const DefaultTabTriggers = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
return (
<DefaultSidebarTabTriggersTunnel.In>
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
</DefaultSidebarTabTriggersTunnel.In>
);
};
DefaultTabTriggers.displayName = "DefaultTabTriggers";
export const DefaultSidebar = Object.assign(
withInternalFallback(
"DefaultSidebar",
({
children,
className,
onDock,
docked,
...rest
}: Merge<
MarkOptional<Omit<SidebarProps, "name">, "children">,
{
/** pass `false` to disable docking */
onDock?: SidebarProps["onDock"] | false;
}
>) => {
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
return (
<Sidebar
{...rest}
name="default"
key="default"
className={clsx("default-sidebar", className)}
docked={docked ?? appState.defaultSidebarDockedPreference}
onDock={
// `onDock=false` disables docking.
// if `docked` passed, but no onDock passed, disable manual docking.
onDock === false || (!onDock && docked != null)
? undefined
: // compose to allow the host app to listen on default behavior
composeEventHandlers(onDock, (docked) => {
setAppState({ defaultSidebarDockedPreference: docked });
})
}
>
<Sidebar.Tabs>
<Sidebar.Header>
{rest.__fallback && (
<div
style={{
color: "var(--color-primary)",
fontSize: "1.2em",
fontWeight: "bold",
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
paddingRight: "1em",
}}
>
{t("toolBar.library")}
</div>
)}
<DefaultSidebarTabTriggersTunnel.Out />
</Sidebar.Header>
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
<LibraryMenu />
</Sidebar.Tab>
{children}
</Sidebar.Tabs>
</Sidebar>
);
},
),
{
Trigger: DefaultSidebarTrigger,
TabTriggers: DefaultTabTriggers,
},
);
+1 -1
View File
@@ -15,7 +15,7 @@ import { Modal } from "./Modal";
import { AppState } from "../types"; import { AppState } from "../types";
import { queryFocusableElements } from "../utils"; import { queryFocusableElements } from "../utils";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
export interface DialogProps { export interface DialogProps {
+5 -5
View File
@@ -1,9 +1,7 @@
import { t } from "../i18n"; import { t } from "../i18n";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { Device, UIAppState } from "../types";
import "./HintViewer.scss";
import { AppState, Device } from "../types";
import { import {
isImageElement, isImageElement,
isLinearElement, isLinearElement,
@@ -13,8 +11,10 @@ import {
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { isEraserActive } from "../appState"; import { isEraserActive } from "../appState";
import "./HintViewer.scss";
interface HintViewerProps { interface HintViewerProps {
appState: AppState; appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
isMobile: boolean; isMobile: boolean;
device: Device; device: Device;
@@ -29,7 +29,7 @@ const getHints = ({
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null; const multiMode = appState.multiElement !== null;
if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) { if (appState.openSidebar && !device.canDeviceFitSidebar) {
return null; return null;
} }
+6 -5
View File
@@ -4,11 +4,10 @@ import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState, BinaryFiles } from "../types"; import { BinaryFiles, UIAppState } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { clipboard } from "./icons"; import { clipboard } from "./icons";
import Stack from "./Stack"; import Stack from "./Stack";
import "./ExportDialog.scss";
import OpenColor from "open-color"; import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants"; import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants";
@@ -16,6 +15,8 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { exportToCanvas } from "../packages/utils"; import { exportToCanvas } from "../packages/utils";
import "./ExportDialog.scss";
const supportsContextFilters = const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!; "filter" in document.createElement("canvas").getContext("2d")!;
@@ -70,7 +71,7 @@ const ImageExportModal = ({
onExportToSvg, onExportToSvg,
onExportToClipboard, onExportToClipboard,
}: { }: {
appState: AppState; appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number; exportPadding?: number;
@@ -216,8 +217,8 @@ export const ImageExportDialog = ({
onExportToSvg, onExportToSvg,
onExportToClipboard, onExportToClipboard,
}: { }: {
appState: AppState; appState: UIAppState;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number; exportPadding?: number;
+4 -4
View File
@@ -2,7 +2,7 @@ import React from "react";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { AppState, ExportOpts, BinaryFiles } from "../types"; import { ExportOpts, BinaryFiles, UIAppState } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { exportToFileIcon, LinkIcon } from "./icons"; import { exportToFileIcon, LinkIcon } from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@@ -28,7 +28,7 @@ const JSONExportModal = ({
exportOpts, exportOpts,
canvas, canvas,
}: { }: {
appState: AppState; appState: UIAppState;
files: BinaryFiles; files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionManager; actionManager: ActionManager;
@@ -96,12 +96,12 @@ export const JSONExportDialog = ({
setAppState, setAppState,
}: { }: {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
appState: AppState; appState: UIAppState;
files: BinaryFiles; files: BinaryFiles;
actionManager: ActionManager; actionManager: ActionManager;
exportOpts: ExportOpts; exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
}) => { }) => {
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
setAppState({ openDialog: null }); setAppState({ openDialog: null });
+84 -71
View File
@@ -1,15 +1,21 @@
import clsx from "clsx"; import clsx from "clsx";
import React from "react"; import React from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants"; import { CLASSES, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_WIDTH } from "../constants";
import { exportCanvas } from "../data"; import { exportCanvas } from "../data";
import { isTextElement, showSelectedShapeActions } from "../element"; import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n"; import { Language, t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { ExportType } from "../scene/types"; import { ExportType } from "../scene/types";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; import {
import { isShallowEqual, muteFSAbortError } from "../utils"; AppProps,
AppState,
ExcalidrawProps,
BinaryFiles,
UIAppState,
} from "../types";
import { capitalizeString, isShallowEqual, muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { ErrorDialog } from "./ErrorDialog"; import { ErrorDialog } from "./ErrorDialog";
import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
@@ -24,32 +30,32 @@ import { Section } from "./Section";
import { HelpDialog } from "./HelpDialog"; import { HelpDialog } from "./HelpDialog";
import Stack from "./Stack"; import Stack from "./Stack";
import { UserList } from "./UserList"; import { UserList } from "./UserList";
import Library from "../data/library";
import { JSONExportDialog } from "./JSONExportDialog"; import { JSONExportDialog } from "./JSONExportDialog";
import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { LibraryMenu } from "./LibraryMenu";
import "./LayerUI.scss";
import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { useDevice } from "../components/App"; import { useDevice } from "../components/App";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats"; import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./footer/Footer"; import Footer from "./footer/Footer";
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
import { Provider, useAtom } from "jotai"; import { Provider, useAtomValue } from "jotai";
import MainMenu from "./main-menu/MainMenu"; import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { HandButton } from "./HandButton"; import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState"; import { isHandToolActive } from "../appState";
import { TunnelsContext, useInitializeTunnels } from "./context/tunnels"; import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
import { LibraryIcon } from "./icons";
import { UIAppStateContext } from "../context/ui-appState";
import { DefaultSidebar } from "./DefaultSidebar";
import "./LayerUI.scss";
import "./Toolbar.scss";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
appState: AppState; appState: UIAppState;
files: BinaryFiles; files: BinaryFiles;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
@@ -57,17 +63,11 @@ interface LayerUIProps {
onLockToggle: () => void; onLockToggle: () => void;
onHandToolToggle: () => void; onHandToolToggle: () => void;
onPenModeToggle: () => void; onPenModeToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
langCode: Language["code"]; langCode: Language["code"];
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
focusContainer: () => void;
library: Library;
id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
children?: React.ReactNode; children?: React.ReactNode;
@@ -109,16 +109,10 @@ const LayerUI = ({
onLockToggle, onLockToggle,
onHandToolToggle, onHandToolToggle,
onPenModeToggle, onPenModeToggle,
onInsertElements,
showExitZenModeBtn, showExitZenModeBtn,
renderTopRightUI, renderTopRightUI,
renderCustomStats, renderCustomStats,
renderCustomSidebar,
libraryReturnUrl,
UIOptions, UIOptions,
focusContainer,
library,
id,
onImageAction, onImageAction,
renderWelcomeScreen, renderWelcomeScreen,
children, children,
@@ -156,7 +150,8 @@ const LayerUI = ({
const fileHandle = await exportCanvas( const fileHandle = await exportCanvas(
type, type,
exportedElements, exportedElements,
appState, // FIXME once we split UI canvas from element canvas
appState as AppState,
files, files,
{ {
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
@@ -197,8 +192,8 @@ const LayerUI = ({
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{/* wrapping to Fragment stops React from occasionally complaining {/* wrapping to Fragment stops React from occasionally complaining
about identical Keys */} about identical Keys */}
<tunnels.mainMenuTunnel.Out /> <tunnels.MainMenuTunnel.Out />
{renderWelcomeScreen && <tunnels.welcomeScreenMenuHintTunnel.Out />} {renderWelcomeScreen && <tunnels.WelcomeScreenMenuHintTunnel.Out />}
</div> </div>
); );
@@ -250,7 +245,7 @@ const LayerUI = ({
{(heading: React.ReactNode) => ( {(heading: React.ReactNode) => (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{renderWelcomeScreen && ( {renderWelcomeScreen && (
<tunnels.welcomeScreenToolbarHintTunnel.Out /> <tunnels.WelcomeScreenToolbarHintTunnel.Out />
)} )}
<Stack.Col gap={4} align="start"> <Stack.Col gap={4} align="start">
<Stack.Row <Stack.Row
@@ -324,8 +319,11 @@ const LayerUI = ({
> >
<UserList collaborators={appState.collaborators} /> <UserList collaborators={appState.collaborators} />
{renderTopRightUI?.(device.isMobile, appState)} {renderTopRightUI?.(device.isMobile, appState)}
{!appState.viewModeEnabled && ( {!appState.viewModeEnabled &&
<LibraryButton appState={appState} setAppState={setAppState} /> // hide button when sidebar docked
(!isSidebarDocked ||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
<tunnels.DefaultSidebarTriggerTunnel.Out />
)} )}
</div> </div>
</div> </div>
@@ -334,21 +332,21 @@ const LayerUI = ({
}; };
const renderSidebars = () => { const renderSidebars = () => {
return appState.openSidebar === "customSidebar" ? ( return (
renderCustomSidebar?.() || null <DefaultSidebar
) : appState.openSidebar === "library" ? ( __fallback
<LibraryMenu onDock={(docked) => {
appState={appState} trackEvent(
onInsertElements={onInsertElements} "sidebar",
libraryReturnUrl={libraryReturnUrl} `toggleDock (${docked ? "dock" : "undock"})`,
focusContainer={focusContainer} `(${device.isMobile ? "mobile" : "desktop"})`,
library={library} );
id={id} }}
/> />
) : null; );
}; };
const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope); const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope);
const layerUIJSX = ( const layerUIJSX = (
<> <>
@@ -360,6 +358,23 @@ const LayerUI = ({
tunneled away. We only render tunneled components that actually tunneled away. We only render tunneled components that actually
have defaults when host do not render anything. */} have defaults when host do not render anything. */}
<DefaultMainMenu UIOptions={UIOptions} /> <DefaultMainMenu UIOptions={UIOptions} />
<DefaultSidebar.Trigger
__fallback
icon={LibraryIcon}
title={capitalizeString(t("toolBar.library"))}
onToggle={(open) => {
if (open) {
trackEvent(
"sidebar",
`${DEFAULT_SIDEBAR.name} (open)`,
`button (${device.isMobile ? "mobile" : "desktop"})`,
);
}
}}
tab={DEFAULT_SIDEBAR.defaultTab}
>
{t("toolBar.library")}
</DefaultSidebar.Trigger>
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />} {appState.isLoading && <LoadingMessage delay={250} />}
@@ -382,7 +397,6 @@ const LayerUI = ({
<PasteChartDialog <PasteChartDialog
setAppState={setAppState} setAppState={setAppState}
appState={appState} appState={appState}
onInsertChart={onInsertElements}
onClose={() => onClose={() =>
setAppState({ setAppState({
pasteDialog: { shown: false, data: null }, pasteDialog: { shown: false, data: null },
@@ -410,7 +424,6 @@ const LayerUI = ({
renderWelcomeScreen={renderWelcomeScreen} renderWelcomeScreen={renderWelcomeScreen}
/> />
)} )}
{!device.isMobile && ( {!device.isMobile && (
<> <>
<div <div
@@ -422,15 +435,14 @@ const LayerUI = ({
!isTextElement(appState.editingElement)), !isTextElement(appState.editingElement)),
})} })}
style={ style={
((appState.openSidebar === "library" && appState.openSidebar &&
appState.isSidebarDocked) || isSidebarDocked &&
hostSidebarCounters.docked) &&
device.canDeviceFitSidebar device.canDeviceFitSidebar
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` } ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
: {} : {}
} }
> >
{renderWelcomeScreen && <tunnels.welcomeScreenCenterTunnel.Out />} {renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />}
{renderFixedSideContainer()} {renderFixedSideContainer()}
<Footer <Footer
appState={appState} appState={appState}
@@ -453,9 +465,9 @@ const LayerUI = ({
<button <button
className="scroll-back-to-content" className="scroll-back-to-content"
onClick={() => { onClick={() => {
setAppState({ setAppState((appState) => ({
...calculateScrollCenter(elements, appState, canvas), ...calculateScrollCenter(elements, appState, canvas),
}); }));
}} }}
> >
{t("buttons.scrollBackToContent")} {t("buttons.scrollBackToContent")}
@@ -469,19 +481,25 @@ const LayerUI = ({
); );
return ( return (
<UIAppStateContext.Provider value={appState}>
<Provider scope={tunnels.jotaiScope}> <Provider scope={tunnels.jotaiScope}>
<TunnelsContext.Provider value={tunnels}> <TunnelsContext.Provider value={tunnels}>
{layerUIJSX} {layerUIJSX}
</TunnelsContext.Provider> </TunnelsContext.Provider>
</Provider> </Provider>
</UIAppStateContext.Provider>
); );
}; };
const stripIrrelevantAppStateProps = ( const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
appState: AppState, const {
): Partial<AppState> => { suggestedBindings,
const { suggestedBindings, startBoundElement, cursorButton, ...ret } = startBoundElement,
appState; cursorButton,
scrollX,
scrollY,
...ret
} = appState;
return ret; return ret;
}; };
@@ -491,24 +509,19 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
return false; return false;
} }
const { const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps;
canvas: _prevCanvas, const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps;
// 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 ( return (
isShallowEqual( isShallowEqual(
stripIrrelevantAppStateProps(prevAppState), // asserting AppState because we're being passed the whole AppState
stripIrrelevantAppStateProps(nextAppState), // but resolve to only the UI-relevant props
stripIrrelevantAppStateProps(prevAppState as AppState),
stripIrrelevantAppStateProps(nextAppState as AppState),
{
selectedElementIds: isShallowEqual,
selectedGroupIds: isShallowEqual,
},
) && isShallowEqual(prev, next) ) && isShallowEqual(prev, next)
); );
}; };
-32
View File
@@ -1,32 +0,0 @@
@import "../css/variables.module";
.library-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: auto;
height: var(--lg-button-size);
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 0;
font-size: 0.75rem;
letter-spacing: 0.4px;
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
&__label {
display: none;
@media screen and (min-width: 1024px) {
display: block;
}
}
}
-57
View File
@@ -1,57 +0,0 @@
import React from "react";
import { t } from "../i18n";
import { AppState } from "../types";
import { capitalizeString } from "../utils";
import { trackEvent } from "../analytics";
import { useDevice } from "./App";
import "./LibraryButton.scss";
import { LibraryIcon } from "./icons";
export const LibraryButton: React.FC<{
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
isMobile?: boolean;
}> = ({ appState, setAppState, isMobile }) => {
const device = useDevice();
const showLabel = !isMobile;
// TODO barnabasmolnar/redesign
// not great, toolbar jumps in a jarring manner
if (appState.isSidebarDocked && appState.openSidebar === "library") {
return null;
}
return (
<label title={`${capitalizeString(t("toolBar.library"))}`}>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
name="editor-library"
onChange={(event) => {
document
.querySelector(".layer-ui__wrapper")
?.classList.remove("animate");
const isOpen = event.target.checked;
setAppState({ openSidebar: isOpen ? "library" : null });
// track only openings
if (isOpen) {
trackEvent(
"library",
"toggleLibrary (open)",
`toolbar (${device.isMobile ? "mobile" : "desktop"})`,
);
}
}}
checked={appState.openSidebar === "library"}
aria-label={capitalizeString(t("toolBar.library"))}
aria-keyshortcuts="0"
/>
<div className="library-button">
<div>{LibraryIcon}</div>
{showLabel && (
<div className="library-button__label">{t("toolBar.library")}</div>
)}
</div>
</label>
);
};
+22 -48
View File
@@ -1,9 +1,9 @@
@import "open-color/open-color"; @import "open-color/open-color";
.excalidraw { .excalidraw {
.layer-ui__library-sidebar { .library-menu-items-container {
display: flex; height: 100%;
flex-direction: column; width: 100%;
} }
.layer-ui__library { .layer-ui__library {
@@ -11,28 +11,6 @@
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
.layer-ui__library-header {
display: flex;
align-items: center;
width: 100%;
margin: 2px 0 15px 0;
.Spinner {
margin-right: 1rem;
}
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
}
}
}
.layer-ui__sidebar {
.library-menu-items-container {
height: 100%;
width: 100%;
}
} }
.library-actions-counter { .library-actions-counter {
@@ -87,10 +65,17 @@
} }
} }
.library-menu-browse-button { .library-menu-control-buttons {
margin: 1rem auto; display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
}
padding: 0.875rem 1rem; .library-menu-browse-button {
flex: 1;
height: var(--lg-button-size);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -122,30 +107,19 @@
} }
} }
.library-menu-browse-button--mobile { &.excalidraw--mobile .library-menu-browse-button {
min-height: 22px; height: var(--default-button-size);
margin-left: auto;
a {
padding-right: 0;
}
} }
.layer-ui__sidebar__header .dropdown-menu { .layer-ui__library .dropdown-menu {
&.dropdown-menu--mobile { width: auto;
top: 100%; top: initial;
}
.dropdown-menu-container {
--gap: 0;
z-index: 1;
position: absolute;
top: 100%;
left: 0;
:root[dir="rtl"] & {
right: 0; right: 0;
left: auto; left: initial;
} bottom: 100%;
margin-bottom: 0.625rem;
.dropdown-menu-container {
width: 196px; width: 196px;
box-shadow: var(--library-dropdown-shadow); box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
+31 -168
View File
@@ -1,77 +1,39 @@
import { import React, { useState, useCallback } from "react";
useRef,
useState,
useEffect,
useCallback,
RefObject,
forwardRef,
} from "react";
import Library, { import Library, {
distributeLibraryItemsOnSquareGrid, distributeLibraryItemsOnSquareGrid,
libraryItemsAtom, libraryItemsAtom,
} from "../data/library"; } from "../data/library";
import { t } from "../i18n"; import { t } from "../i18n";
import { randomId } from "../random"; import { randomId } from "../random";
import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types"; import {
LibraryItems,
import "./LibraryMenu.scss"; LibraryItem,
ExcalidrawProps,
UIAppState,
} from "../types";
import LibraryMenuItems from "./LibraryMenuItems"; import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT } from "../constants";
import { KEYS } from "../keys";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
import Spinner from "./Spinner"; import Spinner from "./Spinner";
import { import {
useDevice, useApp,
useAppProps,
useExcalidrawElements, useExcalidrawElements,
useExcalidrawSetAppState, useExcalidrawSetAppState,
} from "./App"; } from "./App";
import { Sidebar } from "./Sidebar/Sidebar";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { NonDeletedExcalidrawElement } from "../element/types"; import { useUIAppState } from "../context/ui-appState";
import { LibraryMenuHeader } from "./LibraryMenuHeaderContent";
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
const useOnClickOutside = ( import "./LibraryMenu.scss";
ref: RefObject<HTMLElement>, import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
cb: (event: MouseEvent) => void,
) => {
useEffect(() => {
const listener = (event: MouseEvent) => {
if (!ref.current) {
return;
}
if ( export const isLibraryMenuOpenAtom = atom(false);
event.target instanceof Element &&
(ref.current.contains(event.target) ||
!document.body.contains(event.target))
) {
return;
}
cb(event); const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
}; return <div className="layer-ui__library">{children}</div>;
document.addEventListener("pointerdown", listener, false);
return () => {
document.removeEventListener("pointerdown", listener);
};
}, [ref, cb]);
}; };
const LibraryMenuWrapper = forwardRef<
HTMLDivElement,
{ children: React.ReactNode }
>(({ children }, ref) => {
return (
<div ref={ref} className="layer-ui__library">
{children}
</div>
);
});
export const LibraryMenuContent = ({ export const LibraryMenuContent = ({
onInsertLibraryItems, onInsertLibraryItems,
pendingElements, pendingElements,
@@ -87,11 +49,11 @@ export const LibraryMenuContent = ({
pendingElements: LibraryItem["elements"]; pendingElements: LibraryItem["elements"];
onInsertLibraryItems: (libraryItems: LibraryItems) => void; onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onAddToLibrary: () => void; onAddToLibrary: () => void;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library; library: Library;
id: string; id: string;
appState: AppState; appState: UIAppState;
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void; onSelectItems: (id: LibraryItem["id"][]) => void;
}) => { }) => {
@@ -158,81 +120,31 @@ export const LibraryMenuContent = ({
theme={appState.theme} theme={appState.theme}
/> />
{showBtn && ( {showBtn && (
<LibraryMenuBrowseButton <LibraryMenuControlButtons
style={{ padding: "16px 12px 0 12px" }}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={appState.theme} theme={appState.theme}
selectedItems={selectedItems}
onSelectItems={onSelectItems}
/> />
)} )}
</LibraryMenuWrapper> </LibraryMenuWrapper>
); );
}; };
export const LibraryMenu: React.FC<{ /**
appState: AppState; * This component is meant to be rendered inside <Sidebar.Tab/> inside our
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void; * <DefaultSidebar/> or host apps Sidebar components.
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; */
focusContainer: () => void; export const LibraryMenu = () => {
library: Library; const { library, id, onInsertElements } = useApp();
id: string; const appProps = useAppProps();
}> = ({ const appState = useUIAppState();
appState,
onInsertElements,
libraryReturnUrl,
focusContainer,
library,
id,
}) => {
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements(); const elements = useExcalidrawElements();
const device = useDevice();
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]); const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const ref = useRef<HTMLDivElement | null>(null);
const closeLibrary = useCallback(() => {
const isDialogOpen = !!document.querySelector(".Dialog");
// Prevent closing if any dialog is open
if (isDialogOpen) {
return;
}
setAppState({ openSidebar: null });
}, [setAppState]);
useOnClickOutside(
ref,
useCallback(
(event) => {
// If click on the library icon, do nothing so that LibraryButton
// can toggle library menu
if ((event.target as Element).closest(".ToolIcon__library")) {
return;
}
if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) {
closeLibrary();
}
},
[closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar],
),
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
(!appState.isSidebarDocked || !device.canDeviceFitSidebar)
) {
closeLibrary();
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar]);
const deselectItems = useCallback(() => { const deselectItems = useCallback(() => {
setAppState({ setAppState({
@@ -241,55 +153,7 @@ export const LibraryMenu: React.FC<{
}); });
}, [setAppState]); }, [setAppState]);
const removeFromLibrary = useCallback(
async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id),
);
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setSelectedItems([]);
},
[library, setAppState, selectedItems, setSelectedItems],
);
const resetLibrary = useCallback(() => {
library.resetLibrary();
focusContainer();
}, [library, focusContainer]);
return ( return (
<Sidebar
__isInternal
// necessary to remount when switching between internal
// and custom (host app) sidebar, so that the `props.onClose`
// is colled correctly
key="library"
className="layer-ui__library-sidebar"
initialDockedState={appState.isSidebarDocked}
onDock={(docked) => {
trackEvent(
"library",
`toggleLibraryDock (${docked ? "dock" : "undock"})`,
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
);
}}
ref={ref}
>
<Sidebar.Header className="layer-ui__library-header">
<LibraryMenuHeader
appState={appState}
setAppState={setAppState}
selectedItems={selectedItems}
onSelectItems={setSelectedItems}
library={library}
onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems)
}
resetLibrary={resetLibrary}
/>
</Sidebar.Header>
<LibraryMenuContent <LibraryMenuContent
pendingElements={getSelectedElements(elements, appState, true)} pendingElements={getSelectedElements(elements, appState, true)}
onInsertLibraryItems={(libraryItems) => { onInsertLibraryItems={(libraryItems) => {
@@ -297,13 +161,12 @@ export const LibraryMenu: React.FC<{
}} }}
onAddToLibrary={deselectItems} onAddToLibrary={deselectItems}
setAppState={setAppState} setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={appProps.libraryReturnUrl}
library={library} library={library}
id={id} id={id}
appState={appState} appState={appState}
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={setSelectedItems} onSelectItems={setSelectedItems}
/> />
</Sidebar>
); );
}; };
+2 -2
View File
@@ -1,6 +1,6 @@
import { VERSIONS } from "../constants"; import { VERSIONS } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { AppState, ExcalidrawProps } from "../types"; import { ExcalidrawProps, UIAppState } from "../types";
const LibraryMenuBrowseButton = ({ const LibraryMenuBrowseButton = ({
theme, theme,
@@ -8,7 +8,7 @@ const LibraryMenuBrowseButton = ({
libraryReturnUrl, libraryReturnUrl,
}: { }: {
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: AppState["theme"]; theme: UIAppState["theme"];
id: string; id: string;
}) => { }) => {
const referrer = const referrer =
@@ -0,0 +1,33 @@
import { LibraryItem, ExcalidrawProps, UIAppState } from "../types";
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
export const LibraryMenuControlButtons = ({
selectedItems,
onSelectItems,
libraryReturnUrl,
theme,
id,
style,
}: {
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: UIAppState["theme"];
id: string;
style: React.CSSProperties;
}) => {
return (
<div className="library-menu-control-buttons" style={style}>
<LibraryMenuBrowseButton
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
/>
<LibraryDropdownMenu
selectedItems={selectedItems}
onSelectItems={onSelectItems}
/>
</div>
);
};
+66 -16
View File
@@ -1,8 +1,11 @@
import React, { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { t } from "../i18n";
import Trans from "./Trans";
import { jotaiScope } from "../jotai";
import { LibraryItem, LibraryItems, UIAppState } from "../types";
import { useApp, useExcalidrawSetAppState } from "./App";
import { saveLibraryAsJSON } from "../data/json"; import { saveLibraryAsJSON } from "../data/json";
import Library, { libraryItemsAtom } from "../data/library"; import Library, { libraryItemsAtom } from "../data/library";
import { t } from "../i18n";
import { AppState, LibraryItem, LibraryItems } from "../types";
import { import {
DotsIcon, DotsIcon,
ExportIcon, ExportIcon,
@@ -13,29 +16,27 @@ import {
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { fileOpen } from "../data/filesystem"; import { fileOpen } from "../data/filesystem";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { atom, useAtom } from "jotai"; import { useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import PublishLibrary from "./PublishLibrary"; import PublishLibrary from "./PublishLibrary";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import DropdownMenu from "./dropdownMenu/DropdownMenu"; import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
export const isLibraryMenuOpenAtom = atom(false); import { useUIAppState } from "../context/ui-appState";
const getSelectedItems = ( const getSelectedItems = (
libraryItems: LibraryItems, libraryItems: LibraryItems,
selectedItems: LibraryItem["id"][], selectedItems: LibraryItem["id"][],
) => libraryItems.filter((item) => selectedItems.includes(item.id)); ) => libraryItems.filter((item) => selectedItems.includes(item.id));
export const LibraryMenuHeader: React.FC<{ export const LibraryDropdownMenuButton: React.FC<{
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
library: Library; library: Library;
onRemoveFromLibrary: () => void; onRemoveFromLibrary: () => void;
resetLibrary: () => void; resetLibrary: () => void;
onSelectItems: (items: LibraryItem["id"][]) => void; onSelectItems: (items: LibraryItem["id"][]) => void;
appState: AppState; appState: UIAppState;
}> = ({ }> = ({
setAppState, setAppState,
selectedItems, selectedItems,
@@ -50,6 +51,7 @@ export const LibraryMenuHeader: React.FC<{
isLibraryMenuOpenAtom, isLibraryMenuOpenAtom,
jotaiScope, jotaiScope,
); );
const renderRemoveLibAlert = useCallback(() => { const renderRemoveLibAlert = useCallback(() => {
const content = selectedItems.length const content = selectedItems.length
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length }) ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
@@ -104,16 +106,19 @@ export const LibraryMenuHeader: React.FC<{
small={true} small={true}
> >
<p> <p>
{t("publishSuccessDialog.content", { <Trans
authorName: publishLibSuccess!.authorName, i18nKey="publishSuccessDialog.content"
})}{" "} authorName={publishLibSuccess!.authorName}
link={(el) => (
<a <a
href={publishLibSuccess?.url} href={publishLibSuccess?.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{t("publishSuccessDialog.link")} {el}
</a> </a>
)}
/>
</p> </p>
<ToolButton <ToolButton
type="button" type="button"
@@ -181,7 +186,6 @@ export const LibraryMenuHeader: React.FC<{
return ( return (
<DropdownMenu open={isLibraryMenuOpen}> <DropdownMenu open={isLibraryMenuOpen}>
<DropdownMenu.Trigger <DropdownMenu.Trigger
className="Sidebar__dropdown-btn"
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)} onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
> >
{DotsIcon} {DotsIcon}
@@ -230,6 +234,7 @@ export const LibraryMenuHeader: React.FC<{
</DropdownMenu> </DropdownMenu>
); );
}; };
return ( return (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{renderLibraryMenu()} {renderLibraryMenu()}
@@ -261,3 +266,48 @@ export const LibraryMenuHeader: React.FC<{
</div> </div>
); );
}; };
export const LibraryDropdownMenu = ({
selectedItems,
onSelectItems,
}: {
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) => {
const { library } = useApp();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const removeFromLibrary = useCallback(
async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id),
);
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
onSelectItems([]);
},
[library, setAppState, selectedItems, onSelectItems],
);
const resetLibrary = useCallback(() => {
library.resetLibrary();
}, [library]);
return (
<LibraryDropdownMenuButton
appState={appState}
setAppState={setAppState}
selectedItems={selectedItems}
onSelectItems={onSelectItems}
library={library}
onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems)
}
resetLibrary={resetLibrary}
/>
);
};
+2 -2
View File
@@ -47,7 +47,7 @@
&__items { &__items {
row-gap: 0.5rem; row-gap: 0.5rem;
padding: var(--container-padding-y) var(--container-padding-x); padding: var(--container-padding-y) 0;
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
@@ -61,7 +61,7 @@
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
&--excal { &--excal {
margin-top: 2.5rem; margin-top: 2rem;
} }
} }
+18 -19
View File
@@ -2,17 +2,21 @@ import React, { useState } from "react";
import { serializeLibraryAsJSON } from "../data/json"; import { serializeLibraryAsJSON } from "../data/json";
import { ExcalidrawElement, NonDeleted } from "../element/types"; import { ExcalidrawElement, NonDeleted } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { AppState, ExcalidrawProps, LibraryItem, LibraryItems } from "../types"; import {
ExcalidrawProps,
LibraryItem,
LibraryItems,
UIAppState,
} from "../types";
import { arrayToMap, chunk } from "../utils"; import { arrayToMap, chunk } from "../utils";
import { LibraryUnit } from "./LibraryUnit"; import { LibraryUnit } from "./LibraryUnit";
import Stack from "./Stack"; import Stack from "./Stack";
import "./LibraryMenuItems.scss";
import { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import Spinner from "./Spinner"; import Spinner from "./Spinner";
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
import clsx from "clsx";
import { duplicateElements } from "../element/newElement"; import { duplicateElements } from "../element/newElement";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import "./LibraryMenuItems.scss";
const CELLS_PER_ROW = 4; const CELLS_PER_ROW = 4;
@@ -36,7 +40,7 @@ const LibraryMenuItems = ({
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void; onSelectItems: (id: LibraryItem["id"][]) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: AppState["theme"]; theme: UIAppState["theme"];
id: string; id: string;
}) => { }) => {
const [lastSelectedItem, setLastSelectedItem] = useState< const [lastSelectedItem, setLastSelectedItem] = useState<
@@ -102,7 +106,7 @@ const LibraryMenuItems = ({
...item, ...item,
// duplicate each library item before inserting on canvas to confine // duplicate each library item before inserting on canvas to confine
// ids and bindings to each library item. See #6465 // ids and bindings to each library item. See #6465
elements: duplicateElements(item.elements), elements: duplicateElements(item.elements, { randomizeSeed: true }),
}; };
}); });
}; };
@@ -201,11 +205,7 @@ const LibraryMenuItems = ({
(item) => item.status === "published", (item) => item.status === "published",
); );
const showBtn = const showBtn = !libraryItems.length && !pendingElements.length;
!libraryItems.length &&
!unpublishedItems.length &&
!publishedItems.length &&
!pendingElements.length;
return ( return (
<div <div
@@ -215,7 +215,7 @@ const LibraryMenuItems = ({
unpublishedItems.length || unpublishedItems.length ||
publishedItems.length publishedItems.length
? { justifyContent: "flex-start" } ? { justifyContent: "flex-start" }
: {} : { borderBottom: 0 }
} }
> >
<Stack.Col <Stack.Col
@@ -251,11 +251,7 @@ const LibraryMenuItems = ({
</div> </div>
{!pendingElements.length && !unpublishedItems.length ? ( {!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items"> <div className="library-menu-items__no-items">
<div <div className="library-menu-items__no-items__label">
className={clsx({
"library-menu-items__no-items__label": showBtn,
})}
>
{t("library.noItems")} {t("library.noItems")}
</div> </div>
<div className="library-menu-items__no-items__hint"> <div className="library-menu-items__no-items__hint">
@@ -303,10 +299,13 @@ const LibraryMenuItems = ({
</> </>
{showBtn && ( {showBtn && (
<LibraryMenuBrowseButton <LibraryMenuControlButtons
style={{ padding: "16px 0", width: "100%" }}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={theme} theme={theme}
selectedItems={selectedItems}
onSelectItems={onSelectItems}
/> />
)} )}
</Stack.Col> </Stack.Col>
+16 -17
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { AppState, Device, ExcalidrawProps } from "../types"; import { AppState, Device, ExcalidrawProps, UIAppState } from "../types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { t } from "../i18n"; import { t } from "../i18n";
import Stack from "./Stack"; import Stack from "./Stack";
@@ -13,16 +13,15 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section"; import { Section } from "./Section";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars"; import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockButton } from "./LockButton"; import { LockButton } from "./LockButton";
import { LibraryButton } from "./LibraryButton";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions"; import { actionToggleStats } from "../actions";
import { HandButton } from "./HandButton"; import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState"; import { isHandToolActive } from "../appState";
import { useTunnels } from "./context/tunnels"; import { useTunnels } from "../context/tunnels";
type MobileMenuProps = { type MobileMenuProps = {
appState: AppState; appState: UIAppState;
actionManager: ActionManager; actionManager: ActionManager;
renderJSONExportDialog: () => React.ReactNode; renderJSONExportDialog: () => React.ReactNode;
renderImageExportDialog: () => React.ReactNode; renderImageExportDialog: () => React.ReactNode;
@@ -36,7 +35,7 @@ type MobileMenuProps = {
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: ( renderTopRightUI?: (
isMobile: boolean, isMobile: boolean,
appState: AppState, appState: UIAppState,
) => JSX.Element | null; ) => JSX.Element | null;
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderSidebars: () => JSX.Element | null; renderSidebars: () => JSX.Element | null;
@@ -60,11 +59,15 @@ export const MobileMenu = ({
device, device,
renderWelcomeScreen, renderWelcomeScreen,
}: MobileMenuProps) => { }: MobileMenuProps) => {
const { welcomeScreenCenterTunnel, mainMenuTunnel } = useTunnels(); const {
WelcomeScreenCenterTunnel,
MainMenuTunnel,
DefaultSidebarTriggerTunnel,
} = useTunnels();
const renderToolbar = () => { const renderToolbar = () => {
return ( return (
<FixedSideContainer side="top" className="App-top-bar"> <FixedSideContainer side="top" className="App-top-bar">
{renderWelcomeScreen && <welcomeScreenCenterTunnel.Out />} {renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
<Section heading="shapes"> <Section heading="shapes">
{(heading: React.ReactNode) => ( {(heading: React.ReactNode) => (
<Stack.Col gap={4} align="center"> <Stack.Col gap={4} align="center">
@@ -88,11 +91,7 @@ export const MobileMenu = ({
{renderTopRightUI && renderTopRightUI(true, appState)} {renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container"> <div className="mobile-misc-tools-container">
{!appState.viewModeEnabled && ( {!appState.viewModeEnabled && (
<LibraryButton <DefaultSidebarTriggerTunnel.Out />
appState={appState}
setAppState={setAppState}
isMobile
/>
)} )}
<PenModeButton <PenModeButton
checked={appState.penMode} checked={appState.penMode}
@@ -132,14 +131,14 @@ export const MobileMenu = ({
if (appState.viewModeEnabled) { if (appState.viewModeEnabled) {
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
<mainMenuTunnel.Out /> <MainMenuTunnel.Out />
</div> </div>
); );
} }
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
<mainMenuTunnel.Out /> <MainMenuTunnel.Out />
{actionManager.renderAction("toggleEditMenu")} {actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")} {actionManager.renderAction("undo")}
{actionManager.renderAction("redo")} {actionManager.renderAction("redo")}
@@ -190,13 +189,13 @@ export const MobileMenu = ({
{renderAppToolbar()} {renderAppToolbar()}
{appState.scrolledOutside && {appState.scrolledOutside &&
!appState.openMenu && !appState.openMenu &&
appState.openSidebar !== "library" && ( !appState.openSidebar && (
<button <button
className="scroll-back-to-content" className="scroll-back-to-content"
onClick={() => { onClick={() => {
setAppState({ setAppState((appState) => ({
...calculateScrollCenter(elements, appState, canvas), ...calculateScrollCenter(elements, appState, canvas),
}); }));
}} }}
> >
{t("buttons.scrollBackToContent")} {t("buttons.scrollBackToContent")}
+7 -6
View File
@@ -5,8 +5,10 @@ import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
import { ChartType } from "../element/types"; import { ChartType } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import { AppState, LibraryItem } from "../types"; import { UIAppState } from "../types";
import { useApp } from "./App";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import "./PasteChartDialog.scss"; import "./PasteChartDialog.scss";
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void; type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
@@ -78,13 +80,12 @@ export const PasteChartDialog = ({
setAppState, setAppState,
appState, appState,
onClose, onClose,
onInsertChart,
}: { }: {
appState: AppState; appState: UIAppState;
onClose: () => void; onClose: () => void;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
onInsertChart: (elements: LibraryItem["elements"]) => void;
}) => { }) => {
const { onInsertElements } = useApp();
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
if (onClose) { if (onClose) {
onClose(); onClose();
@@ -92,7 +93,7 @@ export const PasteChartDialog = ({
}, [onClose]); }, [onClose]);
const handleChartClick = (chartType: ChartType, elements: ChartElements) => { const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
onInsertChart(elements); onInsertElements(elements);
trackEvent("magic", "chart", chartType); trackEvent("magic", "chart", chartType);
setAppState({ setAppState({
currentChartType: chartType, currentChartType: chartType,
+23 -13
View File
@@ -3,8 +3,9 @@ import OpenColor from "open-color";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { t } from "../i18n"; import { t } from "../i18n";
import Trans from "./Trans";
import { AppState, LibraryItems, LibraryItem } from "../types"; import { LibraryItems, LibraryItem, UIAppState } from "../types";
import { exportToCanvas, exportToSvg } from "../packages/utils"; import { exportToCanvas, exportToSvg } from "../packages/utils";
import { import {
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
@@ -135,7 +136,7 @@ const SingleLibraryItem = ({
onRemove, onRemove,
}: { }: {
libItem: LibraryItem; libItem: LibraryItem;
appState: AppState; appState: UIAppState;
index: number; index: number;
onChange: (val: string, index: number) => void; onChange: (val: string, index: number) => void;
onRemove: (id: string) => void; onRemove: (id: string) => void;
@@ -231,7 +232,7 @@ const PublishLibrary = ({
}: { }: {
onClose: () => void; onClose: () => void;
libraryItems: LibraryItems; libraryItems: LibraryItems;
appState: AppState; appState: UIAppState;
onSuccess: (data: { onSuccess: (data: {
url: string; url: string;
authorName: string; authorName: string;
@@ -402,26 +403,32 @@ const PublishLibrary = ({
{shouldRenderForm ? ( {shouldRenderForm ? (
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<div className="publish-library-note"> <div className="publish-library-note">
{t("publishDialog.noteDescription.pre")} <Trans
i18nKey="publishDialog.noteDescription"
link={(el) => (
<a <a
href="https://libraries.excalidraw.com" href="https://libraries.excalidraw.com"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{t("publishDialog.noteDescription.link")} {el}
</a>{" "} </a>
{t("publishDialog.noteDescription.post")} )}
/>
</div> </div>
<span className="publish-library-note"> <span className="publish-library-note">
{t("publishDialog.noteGuidelines.pre")} <Trans
i18nKey="publishDialog.noteGuidelines"
link={(el) => (
<a <a
href="https://github.com/excalidraw/excalidraw-libraries#guidelines" href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{t("publishDialog.noteGuidelines.link")} {el}
</a> </a>
{t("publishDialog.noteGuidelines.post")} )}
/>
</span> </span>
<div className="publish-library-note"> <div className="publish-library-note">
@@ -515,15 +522,18 @@ const PublishLibrary = ({
/> />
</label> </label>
<span className="publish-library-note"> <span className="publish-library-note">
{t("publishDialog.noteLicense.pre")} <Trans
i18nKey="publishDialog.noteLicense"
link={(el) => (
<a <a
href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE" href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{t("publishDialog.noteLicense.link")} {el}
</a> </a>
{t("publishDialog.noteLicense.post")} )}
/>
</span> </span>
</div> </div>
<div className="publish-library__buttons"> <div className="publish-library__buttons">
+125 -84
View File
@@ -2,67 +2,26 @@
@import "../../css/variables.module"; @import "../../css/variables.module";
.excalidraw { .excalidraw {
.Sidebar { .sidebar {
&__close-btn, display: flex;
&__pin-btn, flex-direction: column;
&__dropdown-btn {
@include outlineButtonStyles;
width: var(--lg-button-size);
height: var(--lg-button-size);
padding: 0;
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
&__pin-btn {
&--pinned {
background-color: var(--color-primary);
border-color: var(--color-primary);
svg {
color: #fff;
}
&:hover,
&:active {
background-color: var(--color-primary-darker);
}
}
}
}
&.theme--dark {
.Sidebar {
&__pin-btn {
&--pinned {
svg {
color: var(--color-gray-90);
}
}
}
}
}
.layer-ui__sidebar {
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
z-index: 5; z-index: 5;
margin: 0; margin: 0;
padding: 0;
box-sizing: border-box;
background-color: var(--sidebar-bg-color);
box-shadow: var(--sidebar-shadow);
:root[dir="rtl"] & { :root[dir="rtl"] & {
left: 0; left: 0;
right: auto; right: auto;
} }
background-color: var(--sidebar-bg-color);
box-shadow: var(--sidebar-shadow);
&--docked { &--docked {
box-shadow: none; box-shadow: none;
} }
@@ -77,52 +36,134 @@
border-right: 1px solid var(--sidebar-border-color); border-right: 1px solid var(--sidebar-border-color);
border-left: 0; border-left: 0;
} }
padding: 0;
box-sizing: border-box;
.Island {
box-shadow: none;
} }
.ToolIcon__icon { // ---------------------------- sidebar header ------------------------------
border-radius: var(--border-radius-md);
}
.ToolIcon__icon__close { .sidebar__header {
.Modal__close {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text);
}
}
.Island {
--padding: 0;
background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg);
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;
}
}
.layer-ui__sidebar__header {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 100%; width: 100%;
padding: 1rem; padding-top: 1rem;
border-bottom: 1px solid var(--sidebar-border-color); padding-bottom: 1rem;
} }
.layer-ui__sidebar__header__buttons { .sidebar__header__buttons {
gap: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.625rem; margin-left: auto;
button {
@include outlineButtonStyles;
--button-bg: transparent;
border: 0 !important;
width: var(--lg-button-size);
height: var(--lg-button-size);
padding: 0;
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
&:hover {
background: var(--button-hover-bg, var(--island-bg-color));
}
}
.sidebar__dock.selected {
svg {
stroke: var(--color-primary);
fill: var(--color-primary);
}
}
}
// ---------------------------- sidebar tabs ------------------------------
.sidebar-tabs-root {
display: flex;
flex-direction: column;
flex: 1 1 auto;
padding: 1rem 0.75rem;
[role="tabpanel"] {
flex: 1;
outline: none;
flex: 1 1 auto;
display: flex;
flex-direction: column;
outline: none;
}
[role="tabpanel"][data-state="inactive"] {
display: none !important;
}
[role="tablist"] {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
}
}
.sidebar-tabs-root > .sidebar__header {
padding-top: 0;
padding-bottom: 1rem;
}
.sidebar-tab-trigger {
--button-width: auto;
--button-bg: transparent;
--button-hover-bg: transparent;
--button-active-bg: var(--color-primary);
--button-hover-color: var(--color-primary);
--button-hover-border: var(--color-primary);
&[data-state="active"] {
--button-bg: var(--color-primary);
--button-hover-bg: var(--color-primary-darker);
--button-hover-color: var(--color-icon-white);
--button-border: var(--color-primary);
color: var(--color-icon-white);
}
}
// ---------------------------- default sidebar ------------------------------
.default-sidebar {
display: flex;
flex-direction: column;
.sidebar-triggers {
$padding: 2px;
$border: 1px;
display: flex;
gap: 0;
padding: $padding;
// offset by padding + border to vertically center the list with sibling
// buttons (both from top and bototm, due to flex layout)
margin-top: -#{$padding + $border};
margin-bottom: -#{$padding + $border};
border: $border solid var(--sidebar-border-color);
background: var(--default-bg-color);
border-radius: 0.625rem;
.sidebar-tab-trigger {
height: var(--lg-button-size);
width: var(--lg-button-size);
border: none;
}
}
.sidebar__header {
border-bottom: 1px solid var(--sidebar-border-color);
}
} }
} }
+234 -258
View File
@@ -1,8 +1,9 @@
import React from "react"; import React from "react";
import { DEFAULT_SIDEBAR } from "../../constants";
import { Excalidraw, Sidebar } from "../../packages/excalidraw/index"; import { Excalidraw, Sidebar } from "../../packages/excalidraw/index";
import { import {
act,
fireEvent, fireEvent,
GlobalTestState,
queryAllByTestId, queryAllByTestId,
queryByTestId, queryByTestId,
render, render,
@@ -10,54 +11,66 @@ import {
withExcalidrawDimensions, withExcalidrawDimensions,
} from "../../tests/test-utils"; } from "../../tests/test-utils";
export const assertSidebarDockButton = async <T extends boolean>(
hasDockButton: T,
): Promise<
T extends false
? { dockButton: null; sidebar: HTMLElement }
: { dockButton: HTMLElement; sidebar: HTMLElement }
> => {
const sidebar =
GlobalTestState.renderResult.container.querySelector<HTMLElement>(
".sidebar",
);
expect(sidebar).not.toBe(null);
const dockButton = queryByTestId(sidebar!, "sidebar-dock");
if (hasDockButton) {
expect(dockButton).not.toBe(null);
return { dockButton: dockButton!, sidebar: sidebar! } as any;
}
expect(dockButton).toBe(null);
return { dockButton: null, sidebar: sidebar! } as any;
};
export const assertExcalidrawWithSidebar = async (
sidebar: React.ReactNode,
name: string,
test: () => void,
) => {
await render(
<Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
{sidebar}
</Excalidraw>,
);
await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
};
describe("Sidebar", () => { describe("Sidebar", () => {
describe("General behavior", () => {
it("should render custom sidebar", async () => { it("should render custom sidebar", async () => {
const { container } = await render( const { container } = await render(
<Excalidraw <Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }} initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
renderSidebar={() => ( >
<Sidebar> <Sidebar name="customSidebar">
<div id="test-sidebar-content">42</div> <div id="test-sidebar-content">42</div>
</Sidebar> </Sidebar>
)} </Excalidraw>,
/>,
); );
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null); expect(node).not.toBe(null);
}); });
it("should render custom sidebar header", async () => {
const { container } = await render(
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar>
<Sidebar.Header>
<div id="test-sidebar-header-content">42</div>
</Sidebar.Header>
</Sidebar>
)}
/>,
);
const node = container.querySelector("#test-sidebar-header-content");
expect(node).not.toBe(null);
// make sure we don't render the default fallback header,
// just the custom one
expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
});
it("should render only one sidebar and prefer the custom one", async () => { it("should render only one sidebar and prefer the custom one", async () => {
const { container } = await render( const { container } = await render(
<Excalidraw <Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }} initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
renderSidebar={() => ( >
<Sidebar> <Sidebar name="customSidebar">
<div id="test-sidebar-content">42</div> <div id="test-sidebar-content">42</div>
</Sidebar> </Sidebar>
)} </Excalidraw>,
/>,
); );
await waitFor(() => { await waitFor(() => {
@@ -66,233 +79,18 @@ describe("Sidebar", () => {
expect(node).not.toBe(null); expect(node).not.toBe(null);
// make sure only one sidebar is rendered // make sure only one sidebar is rendered
const sidebars = container.querySelectorAll(".layer-ui__sidebar"); const sidebars = container.querySelectorAll(".sidebar");
expect(sidebars.length).toBe(1); expect(sidebars.length).toBe(1);
}); });
}); });
it("should always render custom sidebar with close button & close on click", async () => {
const onClose = jest.fn();
const CustomExcalidraw = () => {
return (
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar className="test-sidebar" onClose={onClose}>
hello
</Sidebar>
)}
/>
);
};
const { container } = await render(<CustomExcalidraw />);
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
expect(closeButton).not.toBe(null);
fireEvent.click(closeButton);
await waitFor(() => {
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
expect(onClose).toHaveBeenCalled();
});
});
it("should render custom sidebar with dock (irrespective of onDock prop)", async () => {
const CustomExcalidraw = () => {
return (
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar className="test-sidebar">hello</Sidebar>
)}
/>
);
};
const { container } = await render(<CustomExcalidraw />);
// should show dock button when the sidebar fits to be docked
// -------------------------------------------------------------------------
await withExcalidrawDimensions({ width: 1920, height: 1080 }, () => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
expect(closeButton).not.toBe(null);
});
// should not show dock button when the sidebar does not fit to be docked
// -------------------------------------------------------------------------
await withExcalidrawDimensions({ width: 400, height: 1080 }, () => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
expect(closeButton).toBe(null);
});
});
it("should support controlled docking", async () => {
let _setDockable: (dockable: boolean) => void = null!;
const CustomExcalidraw = () => {
const [dockable, setDockable] = React.useState(false);
_setDockable = setDockable;
return (
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar
className="test-sidebar"
docked={false}
dockable={dockable}
>
hello
</Sidebar>
)}
/>
);
};
const { container } = await render(<CustomExcalidraw />);
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
// should not show dock button when `dockable` is `false`
// -------------------------------------------------------------------------
act(() => {
_setDockable(false);
});
await waitFor(() => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
expect(closeButton).toBe(null);
});
// should show dock button when `dockable` is `true`, even if `docked`
// prop is set
// -------------------------------------------------------------------------
act(() => {
_setDockable(true);
});
await waitFor(() => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
expect(closeButton).not.toBe(null);
});
});
});
it("should support controlled docking", async () => {
let _setDocked: (docked?: boolean) => void = null!;
const CustomExcalidraw = () => {
const [docked, setDocked] = React.useState<boolean | undefined>();
_setDocked = setDocked;
return (
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar className="test-sidebar" docked={docked}>
hello
</Sidebar>
)}
/>
);
};
const { container } = await render(<CustomExcalidraw />);
const { h } = window;
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
const dockButton = await waitFor(() => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const dockBotton = queryByTestId(sidebar!, "sidebar-dock");
expect(dockBotton).not.toBe(null);
return dockBotton!;
});
const dockButtonInput = dockButton.querySelector("input")!;
// should not show dock button when `dockable` is `false`
// -------------------------------------------------------------------------
expect(h.state.isSidebarDocked).toBe(false);
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(true);
expect(dockButtonInput).toBeChecked();
});
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(false);
expect(dockButtonInput).not.toBeChecked();
});
// shouldn't update `appState.isSidebarDocked` when the sidebar
// is controlled (`docked` prop is set), as host apps should handle
// the state themselves
// -------------------------------------------------------------------------
act(() => {
_setDocked(true);
});
await waitFor(() => {
expect(dockButtonInput).toBeChecked();
expect(h.state.isSidebarDocked).toBe(false);
expect(dockButtonInput).toBeChecked();
});
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(false);
expect(dockButtonInput).toBeChecked();
});
// the `appState.isSidebarDocked` should remain untouched when
// `props.docked` is set to `false`, and user toggles
// -------------------------------------------------------------------------
act(() => {
_setDocked(false);
h.setState({ isSidebarDocked: true });
});
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(true);
expect(dockButtonInput).not.toBeChecked();
});
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(dockButtonInput).not.toBeChecked();
expect(h.state.isSidebarDocked).toBe(true);
});
});
});
it("should toggle sidebar using props.toggleMenu()", async () => { it("should toggle sidebar using props.toggleMenu()", async () => {
const { container } = await render( const { container } = await render(
<Excalidraw <Excalidraw>
renderSidebar={() => ( <Sidebar name="customSidebar">
<Sidebar>
<div id="test-sidebar-content">42</div> <div id="test-sidebar-content">42</div>
</Sidebar> </Sidebar>
)} </Excalidraw>,
/>,
); );
// sidebar isn't rendered initially // sidebar isn't rendered initially
@@ -304,7 +102,7 @@ describe("Sidebar", () => {
// toggle sidebar on // toggle sidebar on
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar")).toBe(true); expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
@@ -313,7 +111,7 @@ describe("Sidebar", () => {
// toggle sidebar off // toggle sidebar off
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar")).toBe(false); expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(false);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
@@ -322,7 +120,9 @@ describe("Sidebar", () => {
// force-toggle sidebar off (=> still hidden) // force-toggle sidebar off (=> still hidden)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar", false)).toBe(false); expect(
window.h.app.toggleSidebar({ name: "customSidebar", force: false }),
).toBe(false);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
@@ -331,8 +131,12 @@ describe("Sidebar", () => {
// force-toggle sidebar on // force-toggle sidebar on
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true); expect(
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true); window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
).toBe(true);
expect(
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
).toBe(true);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
@@ -341,15 +145,187 @@ describe("Sidebar", () => {
// toggle library (= hide custom sidebar) // toggle library (= hide custom sidebar)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("library")).toBe(true); expect(window.h.app.toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(
true,
);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null); expect(node).toBe(null);
// make sure only one sidebar is rendered // make sure only one sidebar is rendered
const sidebars = container.querySelectorAll(".layer-ui__sidebar"); const sidebars = container.querySelectorAll(".sidebar");
expect(sidebars.length).toBe(1); expect(sidebars.length).toBe(1);
}); });
}); });
});
describe("<Sidebar.Header/>", () => {
it("should render custom sidebar header", async () => {
const { container } = await render(
<Excalidraw
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
>
<Sidebar name="customSidebar">
<Sidebar.Header>
<div id="test-sidebar-header-content">42</div>
</Sidebar.Header>
</Sidebar>
</Excalidraw>,
);
const node = container.querySelector("#test-sidebar-header-content");
expect(node).not.toBe(null);
// make sure we don't render the default fallback header,
// just the custom one
expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
});
it("should not render <Sidebar.Header> for custom sidebars by default", async () => {
const CustomExcalidraw = () => {
return (
<Excalidraw
initialData={{
appState: { openSidebar: { name: "customSidebar" } },
}}
>
<Sidebar name="customSidebar" className="test-sidebar">
hello
</Sidebar>
</Excalidraw>
);
};
const { container } = await render(<CustomExcalidraw />);
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-close");
expect(closeButton).toBe(null);
});
it("<Sidebar.Header> should render close button", async () => {
const onStateChange = jest.fn();
const CustomExcalidraw = () => {
return (
<Excalidraw
initialData={{
appState: { openSidebar: { name: "customSidebar" } },
}}
>
<Sidebar
name="customSidebar"
className="test-sidebar"
onStateChange={onStateChange}
>
<Sidebar.Header />
</Sidebar>
</Excalidraw>
);
};
const { container } = await render(<CustomExcalidraw />);
// initial open
expect(onStateChange).toHaveBeenCalledWith({ name: "customSidebar" });
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
expect(closeButton).not.toBe(null);
fireEvent.click(closeButton);
await waitFor(() => {
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(
null,
);
expect(onStateChange).toHaveBeenCalledWith(null);
});
});
});
describe("Docking behavior", () => {
it("shouldn't be user-dockable if `onDock` not supplied", async () => {
await assertExcalidrawWithSidebar(
<Sidebar name="customSidebar">
<Sidebar.Header />
</Sidebar>,
"customSidebar",
async () => {
await assertSidebarDockButton(false);
},
);
});
it("shouldn't be user-dockable if `onDock` not supplied & `docked={true}`", async () => {
await assertExcalidrawWithSidebar(
<Sidebar name="customSidebar" docked={true}>
<Sidebar.Header />
</Sidebar>,
"customSidebar",
async () => {
await assertSidebarDockButton(false);
},
);
});
it("shouldn't be user-dockable if `onDock` not supplied & docked={false}`", async () => {
await assertExcalidrawWithSidebar(
<Sidebar name="customSidebar" docked={false}>
<Sidebar.Header />
</Sidebar>,
"customSidebar",
async () => {
await assertSidebarDockButton(false);
},
);
});
it("should be user-dockable when both `onDock` and `docked` supplied", async () => {
await render(
<Excalidraw
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
>
<Sidebar
name="customSidebar"
className="test-sidebar"
onDock={() => {}}
docked
>
<Sidebar.Header />
</Sidebar>
</Excalidraw>,
);
await withExcalidrawDimensions(
{ width: 1920, height: 1080 },
async () => {
await assertSidebarDockButton(true);
},
);
});
it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
await render(
<Excalidraw
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
>
<Sidebar
name="customSidebar"
className="test-sidebar"
onDock={() => {}}
>
<Sidebar.Header />
</Sidebar>
</Excalidraw>,
);
await withExcalidrawDimensions(
{ width: 1920, height: 1080 },
async () => {
await assertSidebarDockButton(false);
},
);
});
});
}); });
+195 -100
View File
@@ -1,151 +1,246 @@
import { import React, {
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, useState,
forwardRef, forwardRef,
useImperativeHandle,
useCallback,
RefObject,
} from "react"; } from "react";
import { Island } from ".././Island"; import { Island } from ".././Island";
import { atom, useAtom } from "jotai"; import { atom, useSetAtom } from "jotai";
import { jotaiScope } from "../../jotai"; import { jotaiScope } from "../../jotai";
import { import {
SidebarPropsContext, SidebarPropsContext,
SidebarProps, SidebarProps,
SidebarPropsContextValue, SidebarPropsContextValue,
} from "./common"; } from "./common";
import { SidebarHeader } from "./SidebarHeader";
import { SidebarHeaderComponents } from "./SidebarHeader"; import clsx from "clsx";
import { useDevice, useExcalidrawSetAppState } from "../App";
import { updateObject } from "../../utils";
import { KEYS } from "../../keys";
import { EVENT } from "../../constants";
import { SidebarTrigger } from "./SidebarTrigger";
import { SidebarTabTriggers } from "./SidebarTabTriggers";
import { SidebarTabTrigger } from "./SidebarTabTrigger";
import { SidebarTabs } from "./SidebarTabs";
import { SidebarTab } from "./SidebarTab";
import "./Sidebar.scss"; import "./Sidebar.scss";
import clsx from "clsx"; import { useUIAppState } from "../../context/ui-appState";
import { useExcalidrawSetAppState } from "../App";
import { updateObject } from "../../utils";
/** using a counter instead of boolean to handle race conditions where // FIXME replace this with the implem from ColorPicker once it's merged
* the host app may render (mount/unmount) multiple different sidebar */ const useOnClickOutside = (
export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 }); ref: RefObject<HTMLElement>,
cb: (event: MouseEvent) => void,
) => {
useEffect(() => {
const listener = (event: MouseEvent) => {
if (!ref.current) {
return;
}
export const Sidebar = Object.assign( if (
forwardRef( event.target instanceof Element &&
(ref.current.contains(event.target) ||
!document.body.contains(event.target))
) {
return;
}
cb(event);
};
document.addEventListener("pointerdown", listener, false);
return () => {
document.removeEventListener("pointerdown", listener);
};
}, [ref, cb]);
};
/**
* Flags whether the currently rendered Sidebar is docked or not, for use
* in upstream components that need to act on this (e.g. LayerUI to shift the
* UI). We use an atom because of potential host app sidebars (for the default
* sidebar we could just read from appState.defaultSidebarDockedPreference).
*
* Since we can only render one Sidebar at a time, we can use a simple flag.
*/
export const isSidebarDockedAtom = atom(false);
export const SidebarInner = forwardRef(
( (
{ {
name,
children, children,
onClose,
onDock, onDock,
docked, docked,
/** Undocumented, may be removed later. Generally should either be
* `props.docked` or `appState.isSidebarDocked`. Currently serves to
* prevent unwanted animation of the shadow if initially docked. */
//
// NOTE we'll want to remove this after we sort out how to subscribe to
// individual appState properties
initialDockedState = docked,
dockable = true,
className, className,
__isInternal, ...rest
}: SidebarProps<{ }: SidebarProps & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">,
// NOTE sidebars we use internally inside the editor must have this flag set.
// It indicates that this sidebar should have lower precedence over host
// sidebars, if both are open.
/** @private internal */
__isInternal?: boolean;
}>,
ref: React.ForwardedRef<HTMLDivElement>, ref: React.ForwardedRef<HTMLDivElement>,
) => { ) => {
const [hostSidebarCounters, setHostSidebarCounters] = useAtom( if (process.env.NODE_ENV === "development" && onDock && docked == null) {
hostSidebarCountersAtom, console.warn(
jotaiScope, "Sidebar: `docked` must be set when `onDock` is supplied for the sidebar to be user-dockable. To hide this message, either pass `docked` or remove `onDock`",
); );
}
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const [isDockedFallback, setIsDockedFallback] = useState( const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
docked ?? initialDockedState ?? false,
useLayoutEffect(() => {
setIsSidebarDockedAtom(!!docked);
return () => {
setIsSidebarDockedAtom(false);
};
}, [setIsSidebarDockedAtom, docked]);
const headerPropsRef = useRef<SidebarPropsContextValue>(
{} as SidebarPropsContextValue,
); );
headerPropsRef.current.onCloseRequest = () => {
useLayoutEffect(() => {
if (docked === undefined) {
// ugly hack to get initial state out of AppState without subscribing
// to it as a whole (once we have granular subscriptions, we'll move
// to that)
//
// NOTE this means that is updated `state.isSidebarDocked` changes outside
// of this compoent, it won't be reflected here. Currently doesn't happen.
setAppState((state) => {
setIsDockedFallback(state.isSidebarDocked);
// bail from update
return null;
});
}
}, [setAppState, docked]);
useLayoutEffect(() => {
if (!__isInternal) {
setHostSidebarCounters((s) => ({
rendered: s.rendered + 1,
docked: isDockedFallback ? s.docked + 1 : s.docked,
}));
return () => {
setHostSidebarCounters((s) => ({
rendered: s.rendered - 1,
docked: isDockedFallback ? s.docked - 1 : s.docked,
}));
};
}
}, [__isInternal, setHostSidebarCounters, isDockedFallback]);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
return () => {
onCloseRef.current?.();
};
}, []);
const headerPropsRef = useRef<SidebarPropsContextValue>({});
headerPropsRef.current.onClose = () => {
setAppState({ openSidebar: null }); setAppState({ openSidebar: null });
}; };
headerPropsRef.current.onDock = (isDocked) => { headerPropsRef.current.onDock = (isDocked) => onDock?.(isDocked);
if (docked === undefined) {
setAppState({ isSidebarDocked: isDocked });
setIsDockedFallback(isDocked);
}
onDock?.(isDocked);
};
// renew the ref object if the following props change since we want to // renew the ref object if the following props change since we want to
// rerender. We can't pass down as component props manually because // rerender. We can't pass down as component props manually because
// the <Sidebar.Header/> can be rendered upsream. // the <Sidebar.Header/> can be rendered upstream.
headerPropsRef.current = updateObject(headerPropsRef.current, { headerPropsRef.current = updateObject(headerPropsRef.current, {
docked: docked ?? isDockedFallback, docked,
dockable, // explicit prop to rerender on update
shouldRenderDockButton: !!onDock && docked != null,
}); });
if (hostSidebarCounters.rendered > 0 && __isInternal) { const islandRef = useRef<HTMLDivElement>(null);
return null;
useImperativeHandle(ref, () => {
return islandRef.current!;
});
const device = useDevice();
const closeLibrary = useCallback(() => {
const isDialogOpen = !!document.querySelector(".Dialog");
// Prevent closing if any dialog is open
if (isDialogOpen) {
return;
} }
setAppState({ openSidebar: null });
}, [setAppState]);
useOnClickOutside(
islandRef,
useCallback(
(event) => {
// If click on the library icon, do nothing so that LibraryButton
// can toggle library menu
if ((event.target as Element).closest(".sidebar-trigger")) {
return;
}
if (!docked || !device.canDeviceFitSidebar) {
closeLibrary();
}
},
[closeLibrary, docked, device.canDeviceFitSidebar],
),
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
(!docked || !device.canDeviceFitSidebar)
) {
closeLibrary();
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [closeLibrary, docked, device.canDeviceFitSidebar]);
return ( return (
<Island <Island
className={clsx( {...rest}
"layer-ui__sidebar", className={clsx("sidebar", { "sidebar--docked": docked }, className)}
{ "layer-ui__sidebar--docked": isDockedFallback }, ref={islandRef}
className,
)}
ref={ref}
> >
<SidebarPropsContext.Provider value={headerPropsRef.current}> <SidebarPropsContext.Provider value={headerPropsRef.current}>
<SidebarHeaderComponents.Context>
<SidebarHeaderComponents.Component __isFallback />
{children} {children}
</SidebarHeaderComponents.Context>
</SidebarPropsContext.Provider> </SidebarPropsContext.Provider>
</Island> </Island>
); );
}, },
), );
SidebarInner.displayName = "SidebarInner";
export const Sidebar = Object.assign(
forwardRef((props: SidebarProps, ref: React.ForwardedRef<HTMLDivElement>) => {
const appState = useUIAppState();
const { onStateChange } = props;
const refPrevOpenSidebar = useRef(appState.openSidebar);
useEffect(() => {
if (
// closing sidebar
((!appState.openSidebar &&
refPrevOpenSidebar?.current?.name === props.name) ||
// opening current sidebar
(appState.openSidebar?.name === props.name &&
refPrevOpenSidebar?.current?.name !== props.name) ||
// switching tabs or switching to a different sidebar
refPrevOpenSidebar.current?.name === props.name) &&
appState.openSidebar !== refPrevOpenSidebar.current
) {
onStateChange?.(
appState.openSidebar?.name !== props.name
? null
: appState.openSidebar,
);
}
refPrevOpenSidebar.current = appState.openSidebar;
}, [appState.openSidebar, onStateChange, props.name]);
const [mounted, setMounted] = useState(false);
useLayoutEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
// We want to render in the next tick (hence `mounted` flag) so that it's
// guaranteed to happen after unmount of the previous sidebar (in case the
// previous sidebar is mounted after the next one). This is necessary to
// prevent flicker of subcomponents that support fallbacks
// (e.g. SidebarHeader). This is because we're using flags to determine
// whether prefer the fallback component or not (otherwise both will render
// initially), and the flag won't be reset in time if the unmount order
// it not correct.
//
// Alternative, and more general solution would be to namespace the fallback
// HoC so that state is not shared between subcomponents when the wrapping
// component is of the same type (e.g. Sidebar -> SidebarHeader).
const shouldRender = mounted && appState.openSidebar?.name === props.name;
if (!shouldRender) {
return null;
}
return <SidebarInner {...props} ref={ref} key={props.name} />;
}),
{ {
Header: SidebarHeaderComponents.Component, Header: SidebarHeader,
TabTriggers: SidebarTabTriggers,
TabTrigger: SidebarTabTrigger,
Tabs: SidebarTabs,
Tab: SidebarTab,
Trigger: SidebarTrigger,
}, },
); );
Sidebar.displayName = "Sidebar";
+27 -59
View File
@@ -4,86 +4,54 @@ import { t } from "../../i18n";
import { useDevice } from "../App"; import { useDevice } from "../App";
import { SidebarPropsContext } from "./common"; import { SidebarPropsContext } from "./common";
import { CloseIcon, PinIcon } from "../icons"; import { CloseIcon, PinIcon } from "../icons";
import { withUpstreamOverride } from "../hoc/withUpstreamOverride";
import { Tooltip } from "../Tooltip"; import { Tooltip } from "../Tooltip";
import { Button } from "../Button";
export const SidebarDockButton = (props: { export const SidebarHeader = ({
checked: boolean; children,
onChange?(): void; className,
}) => { }: {
return (
<div className="layer-ui__sidebar-dock-button" data-testid="sidebar-dock">
<Tooltip label={t("labels.sidebarLock")}>
<label
className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
`ToolIcon_size_medium`,
)}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
onChange={props.onChange}
checked={props.checked}
aria-label={t("labels.sidebarLock")}
/>{" "}
<div
className={clsx("Sidebar__pin-btn", {
"Sidebar__pin-btn--pinned": props.checked,
})}
tabIndex={0}
>
{PinIcon}
</div>{" "}
</label>{" "}
</Tooltip>
</div>
);
};
const _SidebarHeader: React.FC<{
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;
}> = ({ children, className }) => { }) => {
const device = useDevice(); const device = useDevice();
const props = useContext(SidebarPropsContext); const props = useContext(SidebarPropsContext);
const renderDockButton = !!(device.canDeviceFitSidebar && props.dockable); const renderDockButton = !!(
const renderCloseButton = !!props.onClose; device.canDeviceFitSidebar && props.shouldRenderDockButton
);
return ( return (
<div <div
className={clsx("layer-ui__sidebar__header", className)} className={clsx("sidebar__header", className)}
data-testid="sidebar-header" data-testid="sidebar-header"
> >
{children} {children}
{(renderDockButton || renderCloseButton) && ( <div className="sidebar__header__buttons">
<div className="layer-ui__sidebar__header__buttons">
{renderDockButton && ( {renderDockButton && (
<SidebarDockButton <Tooltip label={t("labels.sidebarLock")}>
checked={!!props.docked} <Button
onChange={() => { onSelect={() => props.onDock?.(!props.docked)}
props.onDock?.(!props.docked); selected={!!props.docked}
}} className="sidebar__dock"
/> data-testid="sidebar-dock"
aria-label={t("labels.sidebarLock")}
>
{PinIcon}
</Button>
</Tooltip>
)} )}
{renderCloseButton && ( <Button
<button
data-testid="sidebar-close" data-testid="sidebar-close"
className="Sidebar__close-btn" className="sidebar__close"
onClick={props.onClose} onSelect={props.onCloseRequest}
aria-label={t("buttons.close")} aria-label={t("buttons.close")}
> >
{CloseIcon} {CloseIcon}
</button> </Button>
)}
</div> </div>
)}
</div> </div>
); );
}; };
const [Context, Component] = withUpstreamOverride(_SidebarHeader); SidebarHeader.displayName = "SidebarHeader";
/** @private */
export const SidebarHeaderComponents = { Context, Component };
+18
View File
@@ -0,0 +1,18 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { SidebarTabName } from "../../types";
export const SidebarTab = ({
tab,
children,
...rest
}: {
tab: SidebarTabName;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<RadixTabs.Content {...rest} value={tab}>
{children}
</RadixTabs.Content>
);
};
SidebarTab.displayName = "SidebarTab";
@@ -0,0 +1,26 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { SidebarTabName } from "../../types";
export const SidebarTabTrigger = ({
children,
tab,
onSelect,
...rest
}: {
children: React.ReactNode;
tab: SidebarTabName;
onSelect?: React.ReactEventHandler<HTMLButtonElement> | undefined;
} & Omit<React.HTMLAttributes<HTMLButtonElement>, "onSelect">) => {
return (
<RadixTabs.Trigger value={tab} asChild onSelect={onSelect}>
<button
type={"button"}
className={`excalidraw-button sidebar-tab-trigger`}
{...rest}
>
{children}
</button>
</RadixTabs.Trigger>
);
};
SidebarTabTrigger.displayName = "SidebarTabTrigger";
@@ -0,0 +1,16 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const SidebarTabTriggers = ({
children,
...rest
}: { children: React.ReactNode } & Omit<
React.RefAttributes<HTMLDivElement>,
"onSelect"
>) => {
return (
<RadixTabs.List className="sidebar-triggers" {...rest}>
{children}
</RadixTabs.List>
);
};
SidebarTabTriggers.displayName = "SidebarTabTriggers";
+36
View File
@@ -0,0 +1,36 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { useUIAppState } from "../../context/ui-appState";
import { useExcalidrawSetAppState } from "../App";
export const SidebarTabs = ({
children,
...rest
}: {
children: React.ReactNode;
} & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">) => {
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
if (!appState.openSidebar) {
return null;
}
const { name } = appState.openSidebar;
return (
<RadixTabs.Root
className="sidebar-tabs-root"
value={appState.openSidebar.tab}
onValueChange={(tab) =>
setAppState((state) => ({
...state,
openSidebar: { ...state.openSidebar, name, tab },
}))
}
{...rest}
>
{children}
</RadixTabs.Root>
);
};
SidebarTabs.displayName = "SidebarTabs";
@@ -0,0 +1,34 @@
@import "../../css/variables.module";
.excalidraw {
.sidebar-trigger {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: auto;
height: var(--lg-button-size);
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 0;
font-size: 0.75rem;
letter-spacing: 0.4px;
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
.default-sidebar-trigger .sidebar-trigger__label {
display: none;
@media screen and (min-width: 1024px) {
display: block;
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import { useExcalidrawSetAppState } from "../App";
import { SidebarTriggerProps } from "./common";
import { useUIAppState } from "../../context/ui-appState";
import clsx from "clsx";
import "./SidebarTrigger.scss";
export const SidebarTrigger = ({
name,
tab,
icon,
title,
children,
onToggle,
className,
style,
}: SidebarTriggerProps) => {
const setAppState = useExcalidrawSetAppState();
const appState = useUIAppState();
return (
<label title={title}>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
onChange={(event) => {
document
.querySelector(".layer-ui__wrapper")
?.classList.remove("animate");
const isOpen = event.target.checked;
setAppState({ openSidebar: isOpen ? { name, tab } : null });
onToggle?.(isOpen);
}}
checked={appState.openSidebar?.name === name}
aria-label={title}
aria-keyshortcuts="0"
/>
<div className={clsx("sidebar-trigger", className)} style={style}>
{icon && <div>{icon}</div>}
{children && <div className="sidebar-trigger__label">{children}</div>}
</div>
</label>
);
};
SidebarTrigger.displayName = "SidebarTrigger";
+26 -8
View File
@@ -1,23 +1,41 @@
import React from "react"; import React from "react";
import { AppState, SidebarName, SidebarTabName } from "../../types";
export type SidebarTriggerProps = {
name: SidebarName;
tab?: SidebarTabName;
icon?: JSX.Element;
children?: React.ReactNode;
title?: string;
className?: string;
onToggle?: (open: boolean) => void;
style?: React.CSSProperties;
};
export type SidebarProps<P = {}> = { export type SidebarProps<P = {}> = {
name: SidebarName;
children: React.ReactNode; children: React.ReactNode;
/** /**
* Called on sidebar close (either by user action or by the editor). * Called on sidebar open/close or tab change.
*/
onStateChange?: (state: AppState["openSidebar"]) => void;
/**
* supply alongside `docked` prop in order to make the Sidebar user-dockable
*/ */
onClose?: () => void | boolean;
/** if not supplied, sidebar won't be dockable */
onDock?: (docked: boolean) => void; onDock?: (docked: boolean) => void;
docked?: boolean; docked?: boolean;
initialDockedState?: boolean;
dockable?: boolean;
className?: string; className?: string;
// NOTE sidebars we use internally inside the editor must have this flag set.
// It indicates that this sidebar should have lower precedence over host
// sidebars, if both are open.
/** @private internal */
__fallback?: boolean;
} & P; } & P;
export type SidebarPropsContextValue = Pick< export type SidebarPropsContextValue = Pick<
SidebarProps, SidebarProps,
"onClose" | "onDock" | "docked" | "dockable" "onDock" | "docked"
>; > & { onCloseRequest: () => void; shouldRenderDockButton: boolean };
export const SidebarPropsContext = export const SidebarPropsContext =
React.createContext<SidebarPropsContextValue>({}); React.createContext<SidebarPropsContextValue>({} as SidebarPropsContextValue);
+3 -3
View File
@@ -3,14 +3,14 @@ import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { getTargetElements } from "../scene"; import { getTargetElements } from "../scene";
import { AppState, ExcalidrawProps } from "../types"; import { ExcalidrawProps, UIAppState } from "../types";
import { CloseIcon } from "./icons"; import { CloseIcon } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
import "./Stats.scss"; import "./Stats.scss";
export const Stats = (props: { export const Stats = (props: {
appState: AppState; appState: UIAppState;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"]; renderCustomStats: ExcalidrawProps["renderCustomStats"];
+3
View File
@@ -2,6 +2,9 @@
// container in body where the actual tooltip is appended to // container in body where the actual tooltip is appended to
.excalidraw-tooltip { .excalidraw-tooltip {
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
+22 -14
View File
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import * as Sentry from "@sentry/browser"; import * as Sentry from "@sentry/browser";
import { t } from "../i18n"; import { t } from "../i18n";
import Trans from "./Trans";
interface TopErrorBoundaryState { interface TopErrorBoundaryState {
hasError: boolean; hasError: boolean;
@@ -74,13 +75,17 @@ export class TopErrorBoundary extends React.Component<
<div className="ErrorSplash excalidraw"> <div className="ErrorSplash excalidraw">
<div className="ErrorSplash-messageContainer"> <div className="ErrorSplash-messageContainer">
<div className="ErrorSplash-paragraph bigger align-center"> <div className="ErrorSplash-paragraph bigger align-center">
{t("errorSplash.headingMain_pre")} <Trans
<button onClick={() => window.location.reload()}> i18nKey="errorSplash.headingMain"
{t("errorSplash.headingMain_button")} button={(el) => (
</button> <button onClick={() => window.location.reload()}>{el}</button>
)}
/>
</div> </div>
<div className="ErrorSplash-paragraph align-center"> <div className="ErrorSplash-paragraph align-center">
{t("errorSplash.clearCanvasMessage")} <Trans
i18nKey="errorSplash.clearCanvasMessage"
button={(el) => (
<button <button
onClick={() => { onClick={() => {
try { try {
@@ -91,8 +96,10 @@ export class TopErrorBoundary extends React.Component<
} }
}} }}
> >
{t("errorSplash.clearCanvasMessage_button")} {el}
</button> </button>
)}
/>
<br /> <br />
<div className="smaller"> <div className="smaller">
<span role="img" aria-label="warning"> <span role="img" aria-label="warning">
@@ -106,16 +113,17 @@ export class TopErrorBoundary extends React.Component<
</div> </div>
<div> <div>
<div className="ErrorSplash-paragraph"> <div className="ErrorSplash-paragraph">
{t("errorSplash.trackedToSentry_pre")} {t("errorSplash.trackedToSentry", {
{this.state.sentryEventId} eventId: this.state.sentryEventId,
{t("errorSplash.trackedToSentry_post")} })}
</div> </div>
<div className="ErrorSplash-paragraph"> <div className="ErrorSplash-paragraph">
{t("errorSplash.openIssueMessage_pre")} <Trans
<button onClick={() => this.createGithubIssue()}> i18nKey="errorSplash.openIssueMessage"
{t("errorSplash.openIssueMessage_button")} button={(el) => (
</button> <button onClick={() => this.createGithubIssue()}>{el}</button>
{t("errorSplash.openIssueMessage_post")} )}
/>
</div> </div>
<div className="ErrorSplash-paragraph"> <div className="ErrorSplash-paragraph">
<div className="ErrorSplash-details"> <div className="ErrorSplash-details">
+67
View File
@@ -0,0 +1,67 @@
import { render } from "@testing-library/react";
import fallbackLangData from "../locales/en.json";
import Trans from "./Trans";
describe("Test <Trans/>", () => {
it("should translate the the strings correctly", () => {
//@ts-ignore
fallbackLangData.transTest = {
key1: "Hello {{audience}}",
key2: "Please <link>click the button</link> to continue.",
key3: "Please <link>click {{location}}</link> to continue.",
key4: "Please <link>click <bold>{{location}}</bold></link> to continue.",
key5: "Please <connect-link>click the button</connect-link> to continue.",
};
const { getByTestId } = render(
<>
<div data-testid="test1">
<Trans i18nKey="transTest.key1" audience="world" />
</div>
<div data-testid="test2">
<Trans
i18nKey="transTest.key2"
link={(el) => <a href="https://example.com">{el}</a>}
/>
</div>
<div data-testid="test3">
<Trans
i18nKey="transTest.key3"
link={(el) => <a href="https://example.com">{el}</a>}
location="the button"
/>
</div>
<div data-testid="test4">
<Trans
i18nKey="transTest.key4"
link={(el) => <a href="https://example.com">{el}</a>}
location="the button"
bold={(el) => <strong>{el}</strong>}
/>
</div>
<div data-testid="test5">
<Trans
i18nKey="transTest.key5"
connect-link={(el) => <a href="https://example.com">{el}</a>}
/>
</div>
</>,
);
expect(getByTestId("test1").innerHTML).toEqual("Hello world");
expect(getByTestId("test2").innerHTML).toEqual(
`Please <a href="https://example.com">click the button</a> to continue.`,
);
expect(getByTestId("test3").innerHTML).toEqual(
`Please <a href="https://example.com">click the button</a> to continue.`,
);
expect(getByTestId("test4").innerHTML).toEqual(
`Please <a href="https://example.com">click <strong>the button</strong></a> to continue.`,
);
expect(getByTestId("test5").innerHTML).toEqual(
`Please <a href="https://example.com">click the button</a> to continue.`,
);
});
});
+169
View File
@@ -0,0 +1,169 @@
import React from "react";
import { useI18n } from "../i18n";
// Used for splitting i18nKey into tokens in Trans component
// Example:
// "Please <link>click {{location}}</link> to continue.".split(SPLIT_REGEX).filter(Boolean)
// produces
// ["Please ", "<link>", "click ", "{{location}}", "</link>", " to continue."]
const SPLIT_REGEX = /({{[\w-]+}})|(<[\w-]+>)|(<\/[\w-]+>)/g;
// Used for extracting "location" from "{{location}}"
const KEY_REGEXP = /{{([\w-]+)}}/;
// Used for extracting "link" from "<link>"
const TAG_START_REGEXP = /<([\w-]+)>/;
// Used for extracting "link" from "</link>"
const TAG_END_REGEXP = /<\/([\w-]+)>/;
const getTransChildren = (
format: string,
props: {
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
},
): React.ReactNode[] => {
const stack: { name: string; children: React.ReactNode[] }[] = [
{
name: "",
children: [],
},
];
format
.split(SPLIT_REGEX)
.filter(Boolean)
.forEach((match) => {
const tagStartMatch = match.match(TAG_START_REGEXP);
const tagEndMatch = match.match(TAG_END_REGEXP);
const keyMatch = match.match(KEY_REGEXP);
if (tagStartMatch !== null) {
// The match is <tag>. Set the tag name as the name if it's one of the
// props, e.g. for "Please <link>click the button</link> to continue"
// tagStartMatch[1] = "link" and props contain "link" then it will be
// pushed to stack.
const name = tagStartMatch[1];
if (props.hasOwnProperty(name)) {
stack.push({
name,
children: [],
});
} else {
console.warn(
`Trans: missed to pass in prop ${name} for interpolating ${format}`,
);
}
} else if (tagEndMatch !== null) {
// If tag end match is found, this means we need to replace the content with
// its actual value in prop e.g. format = "Please <link>click the
// button</link> to continue", tagEndMatch is for "</link>", stack last item name =
// "link" and props.link = (el) => <a
// href="https://example.com">{el}</a> then its prop value will be
// pushed to "link"'s children so on DOM when rendering it's rendered as
// <a href="https://example.com">click the button</a>
const name = tagEndMatch[1];
if (name === stack[stack.length - 1].name) {
const item = stack.pop()!;
const itemChildren = React.createElement(
React.Fragment,
{},
...item.children,
);
const fn = props[item.name];
if (typeof fn === "function") {
stack[stack.length - 1].children.push(fn(itemChildren));
}
} else {
console.warn(
`Trans: unexpected end tag ${match} for interpolating ${format}`,
);
}
} else if (keyMatch !== null) {
// The match is for {{key}}. Check if the key is present in props and set
// the prop value as children of last stack item e.g. format = "Hello
// {{name}}", key = "name" and props.name = "Excalidraw" then its prop
// value will be pushed to "name"'s children so it's rendered on DOM as
// "Hello Excalidraw"
const name = keyMatch[1];
if (props.hasOwnProperty(name)) {
stack[stack.length - 1].children.push(props[name] as React.ReactNode);
} else {
console.warn(
`Trans: key ${name} not in props for interpolating ${format}`,
);
}
} else {
// If none of cases match means we just need to push the string
// to stack eg - "Hello {{name}} Whats up?" "Hello", "Whats up" will be pushed
stack[stack.length - 1].children.push(match);
}
});
if (stack.length !== 1) {
console.warn(`Trans: stack not empty for interpolating ${format}`);
}
return stack[0].children;
};
/*
Trans component is used for translating JSX.
```json
{
"example1": "Hello {{audience}}",
"example2": "Please <link>click the button</link> to continue.",
"example3": "Please <link>click {{location}}</link> to continue.",
"example4": "Please <link>click <bold>{{location}}</bold></link> to continue.",
}
```
```jsx
<Trans i18nKey="example1" audience="world" />
<Trans
i18nKey="example2"
connectLink={(el) => <a href="https://example.com">{el}</a>}
/>
<Trans
i18nKey="example3"
connectLink={(el) => <a href="https://example.com">{el}</a>}
location="the button"
/>
<Trans
i18nKey="example4"
connectLink={(el) => <a href="https://example.com">{el}</a>}
location="the button"
bold={(el) => <strong>{el}</strong>}
/>
```
Output:
```html
Hello world
Please <a href="https://example.com">click the button</a> to continue.
Please <a href="https://example.com">click the button</a> to continue.
Please <a href="https://example.com">click <strong>the button</strong></a> to continue.
```
*/
const Trans = ({
i18nKey,
children,
...props
}: {
i18nKey: string;
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
}) => {
const { t } = useI18n();
// This is needed to avoid unique key error in list which gets rendered from getTransChildren
return React.createElement(
React.Fragment,
{},
...getTransChildren(t(i18nKey), props),
);
};
export default Trans;
+6 -19
View File
@@ -6,58 +6,45 @@ exports[`Test <App/> should show error modal when using brave and measureText AP
> >
<p> <p>
Looks like you are using Brave browser with the Looks like you are using Brave browser with the
 
<span <span
style="font-weight: 600;" style="font-weight: 600;"
> >
Aggressively Block Fingerprinting Aggressively Block Fingerprinting
</span> </span>
setting enabled.
setting enabled </p>
. <p>
<br />
<br />
This could result in breaking the This could result in breaking the
<span <span
style="font-weight: 600;" style="font-weight: 600;"
> >
Text Elements Text Elements
</span> </span>
in your drawings.
in your drawings
.
</p> </p>
<p> <p>
We strongly recommend disabling this setting. You can follow We strongly recommend disabling this setting. You can follow
<a <a
href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser" href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"
> >
these steps these steps
</a> </a>
on how to do so.
on how to do so
.
</p> </p>
<p> <p>
If disabling this setting doesn't fix the display of text elements, please open an If disabling this setting doesn't fix the display of text elements, please open an
<a <a
href="https://github.com/excalidraw/excalidraw/issues/new" href="https://github.com/excalidraw/excalidraw/issues/new"
> >
issue issue
</a> </a>
on our GitHub, or write us on on our GitHub, or write us on
<a <a
href="https://discord.gg/UexuTaE" href="https://discord.gg/UexuTaE"
> >
Discord Discord
</a>
. .
</a>
</p> </p>
</div> </div>
`; `;
-32
View File
@@ -1,32 +0,0 @@
import React from "react";
import tunnel from "@dwelle/tunnel-rat";
type Tunnel = ReturnType<typeof tunnel>;
type TunnelsContextValue = {
mainMenuTunnel: Tunnel;
welcomeScreenMenuHintTunnel: Tunnel;
welcomeScreenToolbarHintTunnel: Tunnel;
welcomeScreenHelpHintTunnel: Tunnel;
welcomeScreenCenterTunnel: Tunnel;
footerCenterTunnel: Tunnel;
jotaiScope: symbol;
};
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
export const useTunnels = () => React.useContext(TunnelsContext);
export const useInitializeTunnels = () => {
return React.useMemo((): TunnelsContextValue => {
return {
mainMenuTunnel: tunnel(),
welcomeScreenMenuHintTunnel: tunnel(),
welcomeScreenToolbarHintTunnel: tunnel(),
welcomeScreenHelpHintTunnel: tunnel(),
welcomeScreenCenterTunnel: tunnel(),
footerCenterTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);
};
@@ -1,4 +1,4 @@
import { useOutsideClickHook } from "../../hooks/useOutsideClick"; import { useOutsideClick } from "../../hooks/useOutsideClick";
import { Island } from "../Island"; import { Island } from "../Island";
import { useDevice } from "../App"; import { useDevice } from "../App";
@@ -24,7 +24,7 @@ const MenuContent = ({
style?: React.CSSProperties; style?: React.CSSProperties;
}) => { }) => {
const device = useDevice(); const device = useDevice();
const menuRef = useOutsideClickHook(() => { const menuRef = useOutsideClick(() => {
onClickOutside?.(); onClickOutside?.();
}); });
@@ -1,5 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import { useDevice, useExcalidrawAppState } from "../App"; import { useUIAppState } from "../../context/ui-appState";
import { useDevice } from "../App";
const MenuTrigger = ({ const MenuTrigger = ({
className = "", className = "",
@@ -10,7 +11,7 @@ const MenuTrigger = ({
children: React.ReactNode; children: React.ReactNode;
onToggle: () => void; onToggle: () => void;
}) => { }) => {
const appState = useExcalidrawAppState(); const appState = useUIAppState();
const device = useDevice(); const device = useDevice();
const classNames = clsx( const classNames = clsx(
`dropdown-menu-button ${className}`, `dropdown-menu-button ${className}`,
+6 -6
View File
@@ -1,7 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import { actionShortcuts } from "../../actions"; import { actionShortcuts } from "../../actions";
import { ActionManager } from "../../actions/manager"; import { ActionManager } from "../../actions/manager";
import { AppState } from "../../types";
import { import {
ExitZenModeAction, ExitZenModeAction,
FinalizeAction, FinalizeAction,
@@ -9,10 +8,11 @@ import {
ZoomActions, ZoomActions,
} from "../Actions"; } from "../Actions";
import { useDevice } from "../App"; import { useDevice } from "../App";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { HelpButton } from "../HelpButton"; import { HelpButton } from "../HelpButton";
import { Section } from "../Section"; import { Section } from "../Section";
import Stack from "../Stack"; import Stack from "../Stack";
import { UIAppState } from "../../types";
const Footer = ({ const Footer = ({
appState, appState,
@@ -20,12 +20,12 @@ const Footer = ({
showExitZenModeBtn, showExitZenModeBtn,
renderWelcomeScreen, renderWelcomeScreen,
}: { }: {
appState: AppState; appState: UIAppState;
actionManager: ActionManager; actionManager: ActionManager;
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
}) => { }) => {
const { footerCenterTunnel, welcomeScreenHelpHintTunnel } = useTunnels(); const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
const device = useDevice(); const device = useDevice();
const showFinalize = const showFinalize =
@@ -70,14 +70,14 @@ const Footer = ({
</Section> </Section>
</Stack.Col> </Stack.Col>
</div> </div>
<footerCenterTunnel.Out /> <FooterCenterTunnel.Out />
<div <div
className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", { className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
"transition-right disable-pointerEvents": appState.zenModeEnabled, "transition-right disable-pointerEvents": appState.zenModeEnabled,
})} })}
> >
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{renderWelcomeScreen && <welcomeScreenHelpHintTunnel.Out />} {renderWelcomeScreen && <WelcomeScreenHelpHintTunnel.Out />}
<HelpButton <HelpButton
onClick={() => actionManager.executeAction(actionShortcuts)} onClick={() => actionManager.executeAction(actionShortcuts)}
/> />
+6 -6
View File
@@ -1,13 +1,13 @@
import clsx from "clsx"; import clsx from "clsx";
import { useExcalidrawAppState } from "../App"; import { useTunnels } from "../../context/tunnels";
import { useTunnels } from "../context/tunnels";
import "./FooterCenter.scss"; import "./FooterCenter.scss";
import { useUIAppState } from "../../context/ui-appState";
const FooterCenter = ({ children }: { children?: React.ReactNode }) => { const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
const { footerCenterTunnel } = useTunnels(); const { FooterCenterTunnel } = useTunnels();
const appState = useExcalidrawAppState(); const appState = useUIAppState();
return ( return (
<footerCenterTunnel.In> <FooterCenterTunnel.In>
<div <div
className={clsx("footer-center zen-mode-transition", { className={clsx("footer-center zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom": "layer-ui__wrapper__footer-left--transition-bottom":
@@ -16,7 +16,7 @@ const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
> >
{children} {children}
</div> </div>
</footerCenterTunnel.In> </FooterCenterTunnel.In>
); );
}; };
+20 -6
View File
@@ -1,32 +1,46 @@
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import React, { useLayoutEffect } from "react"; import React, { useLayoutEffect } from "react";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../../context/tunnels";
export const withInternalFallback = <P,>( export const withInternalFallback = <P,>(
componentName: string, componentName: string,
Component: React.FC<P>, Component: React.FC<P>,
) => { ) => {
const counterAtom = atom(0); const renderAtom = atom(0);
// flag set on initial render to tell the fallback component to skip the // flag set on initial render to tell the fallback component to skip the
// render until mount counter are initialized. This is because the counter // render until mount counter are initialized. This is because the counter
// is initialized in an effect, and thus we could end rendering both // is initialized in an effect, and thus we could end rendering both
// components at the same time until counter is initialized. // components at the same time until counter is initialized.
let preferHost = false; let preferHost = false;
let counter = 0;
const WrapperComponent: React.FC< const WrapperComponent: React.FC<
P & { P & {
__fallback?: boolean; __fallback?: boolean;
} }
> = (props) => { > = (props) => {
const { jotaiScope } = useTunnels(); const { jotaiScope } = useTunnels();
const [counter, setCounter] = useAtom(counterAtom, jotaiScope); const [, setRender] = useAtom(renderAtom, jotaiScope);
useLayoutEffect(() => { useLayoutEffect(() => {
setCounter((counter) => counter + 1); setRender((c) => {
const next = c + 1;
counter = next;
return next;
});
return () => { return () => {
setCounter((counter) => counter - 1); setRender((c) => {
const next = c - 1;
counter = next;
if (!next) {
preferHost = false;
}
return next;
});
}; };
}, [setCounter]); }, [setRender]);
if (!props.__fallback) { if (!props.__fallback) {
preferHost = true; preferHost = true;
@@ -1,63 +0,0 @@
import React, {
useMemo,
useContext,
useLayoutEffect,
useState,
createContext,
} from "react";
export const withUpstreamOverride = <P,>(Component: React.ComponentType<P>) => {
type ContextValue = [boolean, React.Dispatch<React.SetStateAction<boolean>>];
const DefaultComponentContext = createContext<ContextValue>([
false,
() => {},
]);
const ComponentContext: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [isRenderedUpstream, setIsRenderedUpstream] = useState(false);
const contextValue: ContextValue = useMemo(
() => [isRenderedUpstream, setIsRenderedUpstream],
[isRenderedUpstream],
);
return (
<DefaultComponentContext.Provider value={contextValue}>
{children}
</DefaultComponentContext.Provider>
);
};
const DefaultComponent = (
props: P & {
// indicates whether component should render when not rendered upstream
/** @private internal */
__isFallback?: boolean;
},
) => {
const [isRenderedUpstream, setIsRenderedUpstream] = useContext(
DefaultComponentContext,
);
useLayoutEffect(() => {
if (!props.__isFallback) {
setIsRenderedUpstream(true);
return () => setIsRenderedUpstream(false);
}
}, [props.__isFallback, setIsRenderedUpstream]);
if (props.__isFallback && isRenderedUpstream) {
return null;
}
return <Component {...props} />;
};
if (Component.name) {
DefaultComponent.displayName = `${Component.name}_upstreamOverrideWrapper`;
ComponentContext.displayName = `${Component.name}_upstreamOverrideContextWrapper`;
}
return [ComponentContext, DefaultComponent] as const;
};
@@ -3,9 +3,9 @@ import { usersIcon } from "../icons";
import { Button } from "../Button"; import { Button } from "../Button";
import clsx from "clsx"; import clsx from "clsx";
import { useExcalidrawAppState } from "../App";
import "./LiveCollaborationTrigger.scss"; import "./LiveCollaborationTrigger.scss";
import { useUIAppState } from "../../context/ui-appState";
const LiveCollaborationTrigger = ({ const LiveCollaborationTrigger = ({
isCollaborating, isCollaborating,
@@ -15,7 +15,7 @@ const LiveCollaborationTrigger = ({
isCollaborating: boolean; isCollaborating: boolean;
onSelect: () => void; onSelect: () => void;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => { } & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const appState = useExcalidrawAppState(); const appState = useUIAppState();
return ( return (
<Button <Button
+4 -7
View File
@@ -1,10 +1,6 @@
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { import { useExcalidrawSetAppState, useExcalidrawActionManager } from "../App";
useExcalidrawAppState,
useExcalidrawSetAppState,
useExcalidrawActionManager,
} from "../App";
import { import {
ExportIcon, ExportIcon,
ExportImageIcon, ExportImageIcon,
@@ -32,6 +28,7 @@ import clsx from "clsx";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai"; import { jotaiScope } from "../../jotai";
import { useUIAppState } from "../../context/ui-appState";
export const LoadScene = () => { export const LoadScene = () => {
const { t } = useI18n(); const { t } = useI18n();
@@ -139,7 +136,7 @@ ClearCanvas.displayName = "ClearCanvas";
export const ToggleTheme = () => { export const ToggleTheme = () => {
const { t } = useI18n(); const { t } = useI18n();
const appState = useExcalidrawAppState(); const appState = useUIAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionToggleTheme)) { if (!actionManager.isActionEnabled(actionToggleTheme)) {
@@ -172,7 +169,7 @@ ToggleTheme.displayName = "ToggleTheme";
export const ChangeCanvasBackground = () => { export const ChangeCanvasBackground = () => {
const { t } = useI18n(); const { t } = useI18n();
const appState = useExcalidrawAppState(); const appState = useUIAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) { if (appState.viewModeEnabled) {
+7 -10
View File
@@ -1,9 +1,5 @@
import React from "react"; import React from "react";
import { import { useDevice, useExcalidrawSetAppState } from "../App";
useDevice,
useExcalidrawAppState,
useExcalidrawSetAppState,
} from "../App";
import DropdownMenu from "../dropdownMenu/DropdownMenu"; import DropdownMenu from "../dropdownMenu/DropdownMenu";
import * as DefaultItems from "./DefaultItems"; import * as DefaultItems from "./DefaultItems";
@@ -13,7 +9,8 @@ import { t } from "../../i18n";
import { HamburgerMenuIcon } from "../icons"; import { HamburgerMenuIcon } from "../icons";
import { withInternalFallback } from "../hoc/withInternalFallback"; import { withInternalFallback } from "../hoc/withInternalFallback";
import { composeEventHandlers } from "../../utils"; import { composeEventHandlers } from "../../utils";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { useUIAppState } from "../../context/ui-appState";
const MainMenu = Object.assign( const MainMenu = Object.assign(
withInternalFallback( withInternalFallback(
@@ -28,16 +25,16 @@ const MainMenu = Object.assign(
*/ */
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
}) => { }) => {
const { mainMenuTunnel } = useTunnels(); const { MainMenuTunnel } = useTunnels();
const device = useDevice(); const device = useDevice();
const appState = useExcalidrawAppState(); const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const onClickOutside = device.isMobile const onClickOutside = device.isMobile
? undefined ? undefined
: () => setAppState({ openMenu: null }); : () => setAppState({ openMenu: null });
return ( return (
<mainMenuTunnel.In> <MainMenuTunnel.In>
<DropdownMenu open={appState.openMenu === "canvas"}> <DropdownMenu open={appState.openMenu === "canvas"}>
<DropdownMenu.Trigger <DropdownMenu.Trigger
onToggle={() => { onToggle={() => {
@@ -66,7 +63,7 @@ const MainMenu = Object.assign(
)} )}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu> </DropdownMenu>
</mainMenuTunnel.In> </MainMenuTunnel.In>
); );
}, },
), ),
@@ -1,13 +1,10 @@
import { actionLoadScene, actionShortcuts } from "../../actions"; import { actionLoadScene, actionShortcuts } from "../../actions";
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t, useI18n } from "../../i18n"; import { t, useI18n } from "../../i18n";
import { import { useDevice, useExcalidrawActionManager } from "../App";
useDevice, import { useTunnels } from "../../context/tunnels";
useExcalidrawActionManager,
useExcalidrawAppState,
} from "../App";
import { useTunnels } from "../context/tunnels";
import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons"; import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons";
import { useUIAppState } from "../../context/ui-appState";
const WelcomeScreenMenuItemContent = ({ const WelcomeScreenMenuItemContent = ({
icon, icon,
@@ -89,9 +86,9 @@ const WelcomeScreenMenuItemLink = ({
WelcomeScreenMenuItemLink.displayName = "WelcomeScreenMenuItemLink"; WelcomeScreenMenuItemLink.displayName = "WelcomeScreenMenuItemLink";
const Center = ({ children }: { children?: React.ReactNode }) => { const Center = ({ children }: { children?: React.ReactNode }) => {
const { welcomeScreenCenterTunnel } = useTunnels(); const { WelcomeScreenCenterTunnel } = useTunnels();
return ( return (
<welcomeScreenCenterTunnel.In> <WelcomeScreenCenterTunnel.In>
<div className="welcome-screen-center"> <div className="welcome-screen-center">
{children || ( {children || (
<> <>
@@ -104,7 +101,7 @@ const Center = ({ children }: { children?: React.ReactNode }) => {
</> </>
)} )}
</div> </div>
</welcomeScreenCenterTunnel.In> </WelcomeScreenCenterTunnel.In>
); );
}; };
Center.displayName = "Center"; Center.displayName = "Center";
@@ -148,7 +145,7 @@ const MenuItemHelp = () => {
MenuItemHelp.displayName = "MenuItemHelp"; MenuItemHelp.displayName = "MenuItemHelp";
const MenuItemLoadScene = () => { const MenuItemLoadScene = () => {
const appState = useExcalidrawAppState(); const appState = useUIAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) { if (appState.viewModeEnabled) {
@@ -1,5 +1,5 @@
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { import {
WelcomeScreenHelpArrow, WelcomeScreenHelpArrow,
WelcomeScreenMenuArrow, WelcomeScreenMenuArrow,
@@ -7,44 +7,44 @@ import {
} from "../icons"; } from "../icons";
const MenuHint = ({ children }: { children?: React.ReactNode }) => { const MenuHint = ({ children }: { children?: React.ReactNode }) => {
const { welcomeScreenMenuHintTunnel } = useTunnels(); const { WelcomeScreenMenuHintTunnel } = useTunnels();
return ( return (
<welcomeScreenMenuHintTunnel.In> <WelcomeScreenMenuHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu"> <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
{WelcomeScreenMenuArrow} {WelcomeScreenMenuArrow}
<div className="welcome-screen-decor-hint__label"> <div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.menuHint")} {children || t("welcomeScreen.defaults.menuHint")}
</div> </div>
</div> </div>
</welcomeScreenMenuHintTunnel.In> </WelcomeScreenMenuHintTunnel.In>
); );
}; };
MenuHint.displayName = "MenuHint"; MenuHint.displayName = "MenuHint";
const ToolbarHint = ({ children }: { children?: React.ReactNode }) => { const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
const { welcomeScreenToolbarHintTunnel } = useTunnels(); const { WelcomeScreenToolbarHintTunnel } = useTunnels();
return ( return (
<welcomeScreenToolbarHintTunnel.In> <WelcomeScreenToolbarHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar"> <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
<div className="welcome-screen-decor-hint__label"> <div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.toolbarHint")} {children || t("welcomeScreen.defaults.toolbarHint")}
</div> </div>
{WelcomeScreenTopToolbarArrow} {WelcomeScreenTopToolbarArrow}
</div> </div>
</welcomeScreenToolbarHintTunnel.In> </WelcomeScreenToolbarHintTunnel.In>
); );
}; };
ToolbarHint.displayName = "ToolbarHint"; ToolbarHint.displayName = "ToolbarHint";
const HelpHint = ({ children }: { children?: React.ReactNode }) => { const HelpHint = ({ children }: { children?: React.ReactNode }) => {
const { welcomeScreenHelpHintTunnel } = useTunnels(); const { WelcomeScreenHelpHintTunnel } = useTunnels();
return ( return (
<welcomeScreenHelpHintTunnel.In> <WelcomeScreenHelpHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help"> <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
<div>{children || t("welcomeScreen.defaults.helpHint")}</div> <div>{children || t("welcomeScreen.defaults.helpHint")}</div>
{WelcomeScreenHelpArrow} {WelcomeScreenHelpArrow}
</div> </div>
</welcomeScreenHelpHintTunnel.In> </WelcomeScreenHelpHintTunnel.In>
); );
}; };
HelpHint.displayName = "HelpHint"; HelpHint.displayName = "HelpHint";
+23 -16
View File
@@ -105,20 +105,30 @@ export const CANVAS_ONLY_ACTIONS = ["selectAll"];
export const GRID_SIZE = 20; // TODO make it configurable? export const GRID_SIZE = 20; // TODO make it configurable?
export const MIME_TYPES = { export const IMAGE_MIME_TYPES = {
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
json: "application/json",
svg: "image/svg+xml", svg: "image/svg+xml",
"excalidraw.svg": "image/svg+xml",
png: "image/png", png: "image/png",
"excalidraw.png": "image/png",
jpg: "image/jpeg", jpg: "image/jpeg",
gif: "image/gif", gif: "image/gif",
webp: "image/webp", webp: "image/webp",
bmp: "image/bmp", bmp: "image/bmp",
ico: "image/x-icon", ico: "image/x-icon",
avif: "image/avif",
jfif: "image/jfif",
} as const;
export const MIME_TYPES = {
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
// image-encoded excalidraw data
"excalidraw.svg": "image/svg+xml",
"excalidraw.png": "image/png",
// binary
binary: "application/octet-stream", binary: "application/octet-stream",
// image
...IMAGE_MIME_TYPES,
} as const; } as const;
export const EXPORT_DATA_TYPES = { export const EXPORT_DATA_TYPES = {
@@ -189,16 +199,6 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const ALLOWED_IMAGE_MIME_TYPES = [
MIME_TYPES.png,
MIME_TYPES.jpg,
MIME_TYPES.svg,
MIME_TYPES.gif,
MIME_TYPES.webp,
MIME_TYPES.bmp,
MIME_TYPES.ico,
] as const;
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024; export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg"; export const SVG_NS = "http://www.w3.org/2000/svg";
@@ -275,3 +275,10 @@ export const DEFAULT_ELEMENT_PROPS: {
opacity: 100, opacity: 100,
locked: false, locked: false,
}; };
export const LIBRARY_SIDEBAR_TAB = "library";
export const DEFAULT_SIDEBAR = {
name: "default",
defaultTab: LIBRARY_SIDEBAR_TAB,
} as const;
+36
View File
@@ -0,0 +1,36 @@
import React from "react";
import tunnel from "tunnel-rat";
export type Tunnel = ReturnType<typeof tunnel>;
type TunnelsContextValue = {
MainMenuTunnel: Tunnel;
WelcomeScreenMenuHintTunnel: Tunnel;
WelcomeScreenToolbarHintTunnel: Tunnel;
WelcomeScreenHelpHintTunnel: Tunnel;
WelcomeScreenCenterTunnel: Tunnel;
FooterCenterTunnel: Tunnel;
DefaultSidebarTriggerTunnel: Tunnel;
DefaultSidebarTabTriggersTunnel: Tunnel;
jotaiScope: symbol;
};
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
export const useTunnels = () => React.useContext(TunnelsContext);
export const useInitializeTunnels = () => {
return React.useMemo((): TunnelsContextValue => {
return {
MainMenuTunnel: tunnel(),
WelcomeScreenMenuHintTunnel: tunnel(),
WelcomeScreenToolbarHintTunnel: tunnel(),
WelcomeScreenHelpHintTunnel: tunnel(),
WelcomeScreenCenterTunnel: tunnel(),
FooterCenterTunnel: tunnel(),
DefaultSidebarTriggerTunnel: tunnel(),
DefaultSidebarTabTriggersTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);
};
+5
View File
@@ -0,0 +1,5 @@
import React from "react";
import { UIAppState } from "../types";
export const UIAppStateContext = React.createContext<UIAppState>(null!);
export const useUIAppState = () => React.useContext(UIAppStateContext);
+3 -1
View File
@@ -354,6 +354,7 @@
border-radius: var(--space-factor); border-radius: var(--space-factor);
border: 1px solid var(--button-gray-2); border: 1px solid var(--button-gray-2);
font-size: 0.8rem; font-size: 0.8rem;
font-family: inherit;
outline: none; outline: none;
appearance: none; appearance: none;
background-image: var(--dropdown-icon); background-image: var(--dropdown-icon);
@@ -413,6 +414,7 @@
bottom: 30px; bottom: 30px;
transform: translateX(-50%); transform: translateX(-50%);
pointer-events: all; pointer-events: all;
font-family: inherit;
&:hover { &:hover {
background-color: var(--button-hover-bg); background-color: var(--button-hover-bg);
@@ -565,7 +567,7 @@
border-radius: 0; border-radius: 0;
} }
.library-button { .default-sidebar-trigger {
border: 0; border: 0;
} }
} }
+6
View File
@@ -78,10 +78,13 @@
--color-selection: #6965db; --color-selection: #6965db;
--color-icon-white: #{$oc-white};
--color-primary: #6965db; --color-primary: #6965db;
--color-primary-darker: #5b57d1; --color-primary-darker: #5b57d1;
--color-primary-darkest: #4a47b1; --color-primary-darkest: #4a47b1;
--color-primary-light: #e3e2fe; --color-primary-light: #e3e2fe;
--color-primary-light-darker: #d7d5ff;
--color-gray-10: #f5f5f5; --color-gray-10: #f5f5f5;
--color-gray-20: #ebebeb; --color-gray-20: #ebebeb;
@@ -161,10 +164,13 @@
// will be inverted to a lighter color. // will be inverted to a lighter color.
--color-selection: #3530c4; --color-selection: #3530c4;
--color-icon-white: var(--color-gray-90);
--color-primary: #a8a5ff; --color-primary: #a8a5ff;
--color-primary-darker: #b2aeff; --color-primary-darker: #b2aeff;
--color-primary-darkest: #beb9ff; --color-primary-darkest: #beb9ff;
--color-primary-light: #4f4d6f; --color-primary-light: #4f4d6f;
--color-primary-light-darker: #43415e;
--color-text-warning: var(--color-gray-80); --color-text-warning: var(--color-gray-80);
+14 -4
View File
@@ -72,7 +72,14 @@
&:hover { &:hover {
background-color: var(--button-hover-bg, var(--island-bg-color)); background-color: var(--button-hover-bg, var(--island-bg-color));
border-color: var(--button-hover-border, var(--default-border-color)); border-color: var(
--button-hover-border,
var(--button-border, var(--default-border-color))
);
color: var(
--button-hover-color,
var(--button-color, var(--text-primary-color, inherit))
);
} }
&:active { &:active {
@@ -81,11 +88,14 @@
} }
&.active { &.active {
background-color: var(--color-primary-light); background-color: var(--button-selected-bg, var(--color-primary-light));
border-color: var(--color-primary-light); border-color: var(--button-selected-border, var(--color-primary-light));
&:hover { &:hover {
background-color: var(--color-primary-light); background-color: var(
--button-selected-hover-bg,
var(--color-primary-light)
);
} }
svg { svg {
+4 -6
View File
@@ -1,6 +1,6 @@
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { cleanAppStateForExport } from "../appState"; import { cleanAppStateForExport } from "../appState";
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants"; import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element"; import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types"; import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
@@ -117,11 +117,9 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
export const isSupportedImageFile = ( export const isSupportedImageFile = (
blob: Blob | null | undefined, blob: Blob | null | undefined,
): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => { ): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
const { type } = blob || {}; const { type } = blob || {};
return ( return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
!!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
);
}; };
export const loadSceneOrLibraryFromBlob = async ( export const loadSceneOrLibraryFromBlob = async (
@@ -157,7 +155,7 @@ export const loadSceneOrLibraryFromBlob = async (
}, },
localAppState, localAppState,
localElements, localElements,
{ repairBindings: true, refreshDimensions: true }, { repairBindings: true, refreshDimensions: false },
), ),
}; };
} else if (isValidLibrary(data)) { } else if (isValidLibrary(data)) {
+1 -10
View File
@@ -8,16 +8,7 @@ import { EVENT, MIME_TYPES } from "../constants";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
import { debounce } from "../utils"; import { debounce } from "../utils";
type FILE_EXTENSION = type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
| "gif"
| "jpg"
| "png"
| "excalidraw.png"
| "svg"
| "excalidraw.svg"
| "json"
| "excalidraw"
| "excalidrawlib";
const INPUT_CHANGE_INTERVAL_MS = 500; const INPUT_CHANGE_INTERVAL_MS = 500;
+18 -4
View File
@@ -14,7 +14,14 @@ import { getCommonBoundingBox } from "../element/bounds";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { URL_HASH_KEYS, URL_QUERY_KEYS, APP_NAME, EVENT } from "../constants"; import {
URL_HASH_KEYS,
URL_QUERY_KEYS,
APP_NAME,
EVENT,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_TAB,
} from "../constants";
export const libraryItemsAtom = atom<{ export const libraryItemsAtom = atom<{
status: "loading" | "loaded"; status: "loading" | "loaded";
@@ -148,7 +155,9 @@ class Library {
defaultStatus?: "unpublished" | "published"; defaultStatus?: "unpublished" | "published";
}): Promise<LibraryItems> => { }): Promise<LibraryItems> => {
if (openLibraryMenu) { if (openLibraryMenu) {
this.app.setState({ openSidebar: "library" }); this.app.setState({
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: LIBRARY_SIDEBAR_TAB },
});
} }
return this.setLibrary(() => { return this.setLibrary(() => {
@@ -174,6 +183,13 @@ class Library {
}), }),
) )
) { ) {
if (prompt) {
// focus container if we've prompted. We focus conditionally
// lest `props.autoFocus` is disabled (in which case we should
// focus only on user action such as prompt confirm)
this.app.focusContainer();
}
if (merge) { if (merge) {
resolve(mergeLibraryItems(this.lastLibraryItems, nextItems)); resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
} else { } else {
@@ -186,8 +202,6 @@ class Library {
reject(error); reject(error);
} }
}); });
}).finally(() => {
this.app.focusContainer();
}); });
}; };
+12 -20
View File
@@ -27,6 +27,7 @@ import {
PRECEDING_ELEMENT_KEY, PRECEDING_ELEMENT_KEY,
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS, ROUNDNESS,
DEFAULT_SIDEBAR,
} from "../constants"; } from "../constants";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
@@ -431,21 +432,15 @@ const LegacyAppStateMigrations: {
defaultAppState: ReturnType<typeof getDefaultAppState>, defaultAppState: ReturnType<typeof getDefaultAppState>,
) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]]; ) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
} = { } = {
isLibraryOpen: (appState, defaultAppState) => { isSidebarDocked: (appState, defaultAppState) => {
return [ return [
"openSidebar", "defaultSidebarDockedPreference",
"isLibraryOpen" in appState appState.isSidebarDocked ??
? appState.isLibraryOpen coalesceAppStateValue(
? "library" "defaultSidebarDockedPreference",
: null appState,
: coalesceAppStateValue("openSidebar", appState, defaultAppState), defaultAppState,
]; ),
},
isLibraryMenuDocked: (appState, defaultAppState) => {
return [
"isSidebarDocked",
appState.isLibraryMenuDocked ??
coalesceAppStateValue("isSidebarDocked", appState, defaultAppState),
]; ];
}, },
}; };
@@ -517,13 +512,10 @@ export const restoreAppState = (
: appState.zoom?.value : appState.zoom?.value
? appState.zoom ? appState.zoom
: defaultAppState.zoom, : defaultAppState.zoom,
// when sidebar docked and user left it open in last session,
// keep it open. If not docked, keep it closed irrespective of last state.
openSidebar: openSidebar:
nextAppState.openSidebar === "library" // string (legacy)
? nextAppState.isSidebarDocked typeof (appState.openSidebar as any as string) === "string"
? "library" ? { name: DEFAULT_SIDEBAR.name }
: null
: nextAppState.openSidebar, : nextAppState.openSidebar,
}; };
}; };
+2 -4
View File
@@ -25,10 +25,8 @@ export interface ExportedDataState {
* Don't consume on its own. * Don't consume on its own.
*/ */
export type LegacyAppState = { export type LegacyAppState = {
/** @deprecated #5663 TODO remove 22-12-15 */ /** @deprecated #6213 TODO remove 23-06-01 */
isLibraryOpen: [boolean, "openSidebar"]; isSidebarDocked: [boolean, "defaultSidebarDockedPreference"];
/** @deprecated #5663 TODO remove 22-12-15 */
isLibraryMenuDocked: [boolean, "isSidebarDocked"];
}; };
export interface ImportedDataState { export interface ImportedDataState {
+3 -2
View File
@@ -1,4 +1,4 @@
import { AppState, ExcalidrawProps, Point } from "../types"; import { AppState, ExcalidrawProps, Point, UIAppState } from "../types";
import { import {
getShortcutKey, getShortcutKey,
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
@@ -297,10 +297,11 @@ export const getContextMenuLabel = (
: "labels.link.create"; : "labels.link.create";
return label; return label;
}; };
export const getLinkHandleFromCoords = ( export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds, [x1, y1, x2, y2]: Bounds,
angle: number, angle: number,
appState: AppState, appState: UIAppState,
): [x: number, y: number, width: number, height: number] => { ): [x: number, y: number, width: number, height: number] => {
const size = DEFAULT_LINK_SIZE; const size = DEFAULT_LINK_SIZE;
const linkWidth = size / appState.zoom.value; const linkWidth = size / appState.zoom.value;
+17 -4
View File
@@ -20,7 +20,7 @@ import {
isTestEnv, isTestEnv,
} from "../utils"; } from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
import { mutateElement, newElementWith } from "./mutateElement"; import { bumpVersion, mutateElement, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups"; import { getNewGroupIdsForDuplication } from "../groups";
import { AppState } from "../types"; import { AppState } from "../types";
import { getElementAbsoluteCoords } from "."; import { getElementAbsoluteCoords } from ".";
@@ -33,7 +33,7 @@ import {
measureText, measureText,
normalizeText, normalizeText,
wrapText, wrapText,
getMaxContainerWidth, getBoundTextMaxWidth,
getDefaultLineHeight, getDefaultLineHeight,
} from "./textElement"; } from "./textElement";
import { import {
@@ -310,7 +310,7 @@ export const refreshTextDimensions = (
text = wrapText( text = wrapText(
text, text,
getFontString(textElement), getFontString(textElement),
getMaxContainerWidth(container), getBoundTextMaxWidth(container),
); );
} }
const dimensions = getAdjustedDimensions(textElement, text); const dimensions = getAdjustedDimensions(textElement, text);
@@ -539,8 +539,16 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
* it's advised to supply the whole elements array, or sets of elements that * it's advised to supply the whole elements array, or sets of elements that
* are encapsulated (such as library items), if the purpose is to retain * are encapsulated (such as library items), if the purpose is to retain
* bindings to the cloned elements intact. * bindings to the cloned elements intact.
*
* NOTE by default does not randomize or regenerate anything except the id.
*/ */
export const duplicateElements = (elements: readonly ExcalidrawElement[]) => { export const duplicateElements = (
elements: readonly ExcalidrawElement[],
opts?: {
/** NOTE also updates version flags and `updated` */
randomizeSeed: boolean;
},
) => {
const clonedElements: ExcalidrawElement[] = []; const clonedElements: ExcalidrawElement[] = [];
const origElementsMap = arrayToMap(elements); const origElementsMap = arrayToMap(elements);
@@ -574,6 +582,11 @@ export const duplicateElements = (elements: readonly ExcalidrawElement[]) => {
clonedElement.id = maybeGetNewId(element.id)!; clonedElement.id = maybeGetNewId(element.id)!;
if (opts?.randomizeSeed) {
clonedElement.seed = randomInteger();
bumpVersion(clonedElement);
}
if (clonedElement.groupIds) { if (clonedElement.groupIds) {
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => { clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
if (!groupNewIdsMap.has(groupId)) { if (!groupNewIdsMap.has(groupId)) {
+7 -7
View File
@@ -44,10 +44,10 @@ import {
getBoundTextElementId, getBoundTextElementId,
getContainerElement, getContainerElement,
handleBindTextResize, handleBindTextResize,
getMaxContainerWidth, getBoundTextMaxWidth,
getApproxMinLineHeight, getApproxMinLineHeight,
measureText, measureText,
getMaxContainerHeight, getBoundTextMaxHeight,
} from "./textElement"; } from "./textElement";
export const normalizeAngle = (angle: number): number => { export const normalizeAngle = (angle: number): number => {
@@ -204,7 +204,7 @@ const measureFontSizeFromWidth = (
if (hasContainer) { if (hasContainer) {
const container = getContainerElement(element); const container = getContainerElement(element);
if (container) { if (container) {
width = getMaxContainerWidth(container); width = getBoundTextMaxWidth(container);
} }
} }
const nextFontSize = element.fontSize * (nextWidth / width); const nextFontSize = element.fontSize * (nextWidth / width);
@@ -435,8 +435,8 @@ export const resizeSingleElement = (
const nextFont = measureFontSizeFromWidth( const nextFont = measureFontSizeFromWidth(
boundTextElement, boundTextElement,
getMaxContainerWidth(updatedElement), getBoundTextMaxWidth(updatedElement),
getMaxContainerHeight(updatedElement), getBoundTextMaxHeight(updatedElement, boundTextElement),
); );
if (nextFont === null) { if (nextFont === null) {
return; return;
@@ -718,10 +718,10 @@ const resizeMultipleElements = (
const metrics = measureFontSizeFromWidth( const metrics = measureFontSizeFromWidth(
boundTextElement ?? (element.orig as ExcalidrawTextElement), boundTextElement ?? (element.orig as ExcalidrawTextElement),
boundTextElement boundTextElement
? getMaxContainerWidth(updatedElement) ? getBoundTextMaxWidth(updatedElement)
: updatedElement.width, : updatedElement.width,
boundTextElement boundTextElement
? getMaxContainerHeight(updatedElement) ? getBoundTextMaxHeight(updatedElement, boundTextElement)
: updatedElement.height, : updatedElement.height,
); );
+2 -2
View File
@@ -1,9 +1,9 @@
import { AppState } from "../types";
import { NonDeletedExcalidrawElement } from "./types"; import { NonDeletedExcalidrawElement } from "./types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { UIAppState } from "../types";
export const showSelectedShapeActions = ( export const showSelectedShapeActions = (
appState: AppState, appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
) => ) =>
Boolean( Boolean(
+48 -11
View File
@@ -3,15 +3,15 @@ import { API } from "../tests/helpers/api";
import { import {
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getContainerCoords, getContainerCoords,
getMaxContainerWidth, getBoundTextMaxWidth,
getMaxContainerHeight, getBoundTextMaxHeight,
wrapText, wrapText,
detectLineHeight, detectLineHeight,
getLineHeightInPx, getLineHeightInPx,
getDefaultLineHeight, getDefaultLineHeight,
parseTokens, parseTokens,
} from "./textElement"; } from "./textElement";
import { FontString } from "./types"; import { ExcalidrawTextElementWithContainer, FontString } from "./types";
describe("Test wrapText", () => { describe("Test wrapText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
@@ -311,7 +311,7 @@ describe("Test measureText", () => {
}); });
}); });
describe("Test getMaxContainerWidth", () => { describe("Test getBoundTextMaxWidth", () => {
const params = { const params = {
width: 178, width: 178,
height: 194, height: 194,
@@ -319,39 +319,76 @@ describe("Test measureText", () => {
it("should return max width when container is rectangle", () => { it("should return max width when container is rectangle", () => {
const container = API.createElement({ type: "rectangle", ...params }); const container = API.createElement({ type: "rectangle", ...params });
expect(getMaxContainerWidth(container)).toBe(168); expect(getBoundTextMaxWidth(container)).toBe(168);
}); });
it("should return max width when container is ellipse", () => { it("should return max width when container is ellipse", () => {
const container = API.createElement({ type: "ellipse", ...params }); const container = API.createElement({ type: "ellipse", ...params });
expect(getMaxContainerWidth(container)).toBe(116); expect(getBoundTextMaxWidth(container)).toBe(116);
}); });
it("should return max width when container is diamond", () => { it("should return max width when container is diamond", () => {
const container = API.createElement({ type: "diamond", ...params }); const container = API.createElement({ type: "diamond", ...params });
expect(getMaxContainerWidth(container)).toBe(79); expect(getBoundTextMaxWidth(container)).toBe(79);
}); });
}); });
describe("Test getMaxContainerHeight", () => { describe("Test getBoundTextMaxHeight", () => {
const params = { const params = {
width: 178, width: 178,
height: 194, height: 194,
id: '"container-id',
}; };
const boundTextElement = API.createElement({
type: "text",
id: "text-id",
x: 560.51171875,
y: 202.033203125,
width: 154,
height: 175,
fontSize: 20,
fontFamily: 1,
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
textAlign: "center",
verticalAlign: "middle",
containerId: params.id,
}) as ExcalidrawTextElementWithContainer;
it("should return max height when container is rectangle", () => { it("should return max height when container is rectangle", () => {
const container = API.createElement({ type: "rectangle", ...params }); const container = API.createElement({ type: "rectangle", ...params });
expect(getMaxContainerHeight(container)).toBe(184); expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184);
}); });
it("should return max height when container is ellipse", () => { it("should return max height when container is ellipse", () => {
const container = API.createElement({ type: "ellipse", ...params }); const container = API.createElement({ type: "ellipse", ...params });
expect(getMaxContainerHeight(container)).toBe(127); expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127);
}); });
it("should return max height when container is diamond", () => { it("should return max height when container is diamond", () => {
const container = API.createElement({ type: "diamond", ...params }); const container = API.createElement({ type: "diamond", ...params });
expect(getMaxContainerHeight(container)).toBe(87); expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87);
});
it("should return max height when container is arrow", () => {
const container = API.createElement({
type: "arrow",
...params,
});
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194);
});
it("should return max height when container is arrow and height is less than threshold", () => {
const container = API.createElement({
type: "arrow",
...params,
height: 70,
boundElements: [{ type: "text", id: "text-id" }],
});
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(
boundTextElement.height,
);
}); });
}); });
}); });
+19 -32
View File
@@ -65,7 +65,7 @@ export const redrawTextBoundingBox = (
boundTextUpdates.text = textElement.text; boundTextUpdates.text = textElement.text;
if (container) { if (container) {
maxWidth = getMaxContainerWidth(container); maxWidth = getBoundTextMaxWidth(container);
boundTextUpdates.text = wrapText( boundTextUpdates.text = wrapText(
textElement.originalText, textElement.originalText,
getFontString(textElement), getFontString(textElement),
@@ -83,16 +83,11 @@ export const redrawTextBoundingBox = (
boundTextUpdates.baseline = metrics.baseline; boundTextUpdates.baseline = metrics.baseline;
if (container) { if (container) {
if (isArrowElement(container)) {
const centerX = textElement.x + textElement.width / 2;
const centerY = textElement.y + textElement.height / 2;
const diffWidth = metrics.width - textElement.width;
const diffHeight = metrics.height - textElement.height;
boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2;
boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2;
} else {
const containerDims = getContainerDims(container); const containerDims = getContainerDims(container);
let maxContainerHeight = getMaxContainerHeight(container); const maxContainerHeight = getBoundTextMaxHeight(
container,
textElement as ExcalidrawTextElementWithContainer,
);
let nextHeight = containerDims.height; let nextHeight = containerDims.height;
if (metrics.height > maxContainerHeight) { if (metrics.height > maxContainerHeight) {
@@ -101,7 +96,6 @@ export const redrawTextBoundingBox = (
container.type, container.type,
); );
mutateElement(container, { height: nextHeight }); mutateElement(container, { height: nextHeight });
maxContainerHeight = getMaxContainerHeight(container);
updateOriginalContainerCache(container.id, nextHeight); updateOriginalContainerCache(container.id, nextHeight);
} }
const updatedTextElement = { const updatedTextElement = {
@@ -112,7 +106,6 @@ export const redrawTextBoundingBox = (
boundTextUpdates.x = x; boundTextUpdates.x = x;
boundTextUpdates.y = y; boundTextUpdates.y = y;
} }
}
mutateElement(textElement, boundTextUpdates); mutateElement(textElement, boundTextUpdates);
}; };
@@ -183,8 +176,11 @@ export const handleBindTextResize = (
let nextHeight = textElement.height; let nextHeight = textElement.height;
let nextWidth = textElement.width; let nextWidth = textElement.width;
const containerDims = getContainerDims(container); const containerDims = getContainerDims(container);
const maxWidth = getMaxContainerWidth(container); const maxWidth = getBoundTextMaxWidth(container);
const maxHeight = getMaxContainerHeight(container); const maxHeight = getBoundTextMaxHeight(
container,
textElement as ExcalidrawTextElementWithContainer,
);
let containerHeight = containerDims.height; let containerHeight = containerDims.height;
let nextBaseLine = textElement.baseline; let nextBaseLine = textElement.baseline;
if (transformHandleType !== "n" && transformHandleType !== "s") { if (transformHandleType !== "n" && transformHandleType !== "s") {
@@ -256,8 +252,8 @@ export const computeBoundTextPosition = (
); );
} }
const containerCoords = getContainerCoords(container); const containerCoords = getContainerCoords(container);
const maxContainerHeight = getMaxContainerHeight(container); const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
const maxContainerWidth = getMaxContainerWidth(container); const maxContainerWidth = getBoundTextMaxWidth(container);
let x; let x;
let y; let y;
@@ -890,18 +886,10 @@ export const computeContainerDimensionForBoundText = (
return dimension + padding; return dimension + padding;
}; };
export const getMaxContainerWidth = (container: ExcalidrawElement) => { export const getBoundTextMaxWidth = (container: ExcalidrawElement) => {
const width = getContainerDims(container).width; const width = getContainerDims(container).width;
if (isArrowElement(container)) { if (isArrowElement(container)) {
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2; return width - BOUND_TEXT_PADDING * 8 * 2;
if (containerWidth <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.width;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return containerWidth;
} }
if (container.type === "ellipse") { if (container.type === "ellipse") {
@@ -918,16 +906,15 @@ export const getMaxContainerWidth = (container: ExcalidrawElement) => {
return width - BOUND_TEXT_PADDING * 2; return width - BOUND_TEXT_PADDING * 2;
}; };
export const getMaxContainerHeight = (container: ExcalidrawElement) => { export const getBoundTextMaxHeight = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer,
) => {
const height = getContainerDims(container).height; const height = getContainerDims(container).height;
if (isArrowElement(container)) { if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2; const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) { if (containerHeight <= 0) {
const boundText = getBoundTextElement(container); return boundTextElement.height;
if (boundText) {
return boundText.height;
}
return BOUND_TEXT_PADDING * 8 * 2;
} }
return height; return height;
} }
+9 -6
View File
@@ -32,8 +32,8 @@ import {
normalizeText, normalizeText,
redrawTextBoundingBox, redrawTextBoundingBox,
wrapText, wrapText,
getMaxContainerHeight, getBoundTextMaxHeight,
getMaxContainerWidth, getBoundTextMaxWidth,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
detectLineHeight, detectLineHeight,
} from "./textElement"; } from "./textElement";
@@ -205,8 +205,11 @@ export const textWysiwyg = ({
} }
} }
maxWidth = getMaxContainerWidth(container); maxWidth = getBoundTextMaxWidth(container);
maxHeight = getMaxContainerHeight(container); maxHeight = getBoundTextMaxHeight(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
);
// autogrow container height if text exceeds // autogrow container height if text exceeds
if (!isArrowElement(container) && textElementHeight > maxHeight) { if (!isArrowElement(container) && textElementHeight > maxHeight) {
@@ -377,7 +380,7 @@ export const textWysiwyg = ({
const wrappedText = wrapText( const wrappedText = wrapText(
`${editable.value}${data}`, `${editable.value}${data}`,
font, font,
getMaxContainerWidth(container), getBoundTextMaxWidth(container),
); );
const width = getTextWidth(wrappedText, font); const width = getTextWidth(wrappedText, font);
editable.style.width = `${width}px`; editable.style.width = `${width}px`;
@@ -394,7 +397,7 @@ export const textWysiwyg = ({
const wrappedText = wrapText( const wrappedText = wrapText(
normalizeText(editable.value), normalizeText(editable.value),
font, font,
getMaxContainerWidth(container!), getBoundTextMaxWidth(container!),
); );
const { width, height } = measureText( const { width, height } = measureText(
wrappedText, wrappedText,
+3 -2
View File
@@ -7,8 +7,9 @@ import {
import { DEFAULT_VERSION } from "../constants"; import { DEFAULT_VERSION } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { copyTextToSystemClipboard } from "../clipboard"; import { copyTextToSystemClipboard } from "../clipboard";
import { AppState } from "../types";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { UIAppState } from "../types";
type StorageSizes = { scene: number; total: number }; type StorageSizes = { scene: number; total: number };
const STORAGE_SIZE_TIMEOUT = 500; const STORAGE_SIZE_TIMEOUT = 500;
@@ -23,7 +24,7 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
type Props = { type Props = {
setToast: (message: string) => void; setToast: (message: string) => void;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
appState: AppState; appState: UIAppState;
}; };
const CustomStats = (props: Props) => { const CustomStats = (props: Props) => {
const [storageSizes, setStorageSizes] = useState<StorageSizes>({ const [storageSizes, setStorageSizes] = useState<StorageSizes>({
+2
View File
@@ -7,6 +7,8 @@ export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
export const SYNC_BROWSER_TABS_TIMEOUT = 50; export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
export const HIDDEN_DISCONNECT_TIMEOUT = 5 * 60 * 1000; // 5 minutes
export const RECONNECT_TOAST_DURATION = 2000;
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631) // 1 year (https://stackoverflow.com/a/25201898/927631)
@@ -18,7 +18,7 @@ import { getFrame } from "../../utils";
const exportToExcalidrawPlus = async ( const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: Partial<AppState>,
files: BinaryFiles, files: BinaryFiles,
) => { ) => {
const firebase = await loadFirebaseStorage(); const firebase = await loadFirebaseStorage();
@@ -75,7 +75,7 @@ const exportToExcalidrawPlus = async (
export const ExportToExcalidrawPlus: React.FC<{ export const ExportToExcalidrawPlus: React.FC<{
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
appState: AppState; appState: Partial<AppState>;
files: BinaryFiles; files: BinaryFiles;
onError: (error: Error) => void; onError: (error: Error) => void;
}> = ({ elements, appState, files, onError }) => { }> = ({ elements, appState, files, onError }) => {
+2 -2
View File
@@ -263,7 +263,7 @@ export const loadScene = async (
await importFromBackend(id, privateKey), await importFromBackend(id, privateKey),
localDataState?.appState, localDataState?.appState,
localDataState?.elements, localDataState?.elements,
{ repairBindings: true, refreshDimensions: true }, { repairBindings: true, refreshDimensions: false },
); );
} else { } else {
data = restore(localDataState || null, null, null, { data = restore(localDataState || null, null, null, {
@@ -284,7 +284,7 @@ export const loadScene = async (
export const exportToBackend = async ( export const exportToBackend = async (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: Partial<AppState>,
files: BinaryFiles, files: BinaryFiles,
) => { ) => {
const encryptionKey = await generateEncryptionKey("string"); const encryptionKey = await generateEncryptionKey("string");
+63 -6
View File
@@ -32,6 +32,7 @@ import {
ExcalidrawImperativeAPI, ExcalidrawImperativeAPI,
BinaryFiles, BinaryFiles,
ExcalidrawInitialDataState, ExcalidrawInitialDataState,
UIAppState,
} from "../types"; } from "../types";
import { import {
debounce, debounce,
@@ -44,6 +45,8 @@ import {
} from "../utils"; } from "../utils";
import { import {
FIREBASE_STORAGE_PREFIXES, FIREBASE_STORAGE_PREFIXES,
HIDDEN_DISCONNECT_TIMEOUT,
RECONNECT_TOAST_DURATION,
STORAGE_KEYS, STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT, SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants"; } from "./app_constants";
@@ -183,9 +186,8 @@ const initializeScene = async (opts: {
} }
if (roomLinkData) { if (roomLinkData) {
const { excalidrawAPI } = opts; const { excalidrawAPI, collabAPI } = opts;
const scene = await collabAPI.startCollaboration(roomLinkData);
const scene = await opts.collabAPI.startCollaboration(roomLinkData);
return { return {
// when collaborating, the state may have already been updated at this // when collaborating, the state may have already been updated at this
@@ -237,6 +239,9 @@ const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(appLangCodeAtom); const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const disconnectRef = useRef(false);
const toastRef = useRef<AppState["toast"]>(null);
// initial state // initial state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -436,7 +441,9 @@ const ExcalidrawWrapper = () => {
LocalData.flushSave(); LocalData.flushSave();
}; };
const visibilityChange = (event: FocusEvent | Event) => { let disconnectTimeout: ReturnType<typeof setTimeout>;
const visibilityChange = async (event: FocusEvent | Event) => {
if (event.type === EVENT.BLUR || document.hidden) { if (event.type === EVENT.BLUR || document.hidden) {
LocalData.flushSave(); LocalData.flushSave();
} }
@@ -445,6 +452,56 @@ const ExcalidrawWrapper = () => {
event.type === EVENT.FOCUS event.type === EVENT.FOCUS
) { ) {
syncData(); syncData();
const disconnect = () => {
disconnectTimeout = setTimeout(
() => collabAPI.stopCollaboration(false),
HIDDEN_DISCONNECT_TIMEOUT,
);
disconnectRef.current = true;
};
const cancelPrevDisconnect = () => clearTimeout(disconnectTimeout);
if (document.hidden && collabAPI.isCollaborating()) {
if (!disconnectTimeout) {
disconnect();
} else {
cancelPrevDisconnect();
disconnect();
}
} else {
cancelPrevDisconnect();
if (!collabAPI.isCollaborating()) {
if (!toastRef.current && disconnectRef.current) {
toastRef.current = {
message: t("toast.reconnectRoomServer"),
duration: RECONNECT_TOAST_DURATION,
closable: true,
};
}
disconnectRef.current &&
(await initializeScene({ collabAPI, excalidrawAPI }).then(
async (data) => {
loadImages(data, /* isInitialLoad */ true);
initialStatePromiseRef.current.promise.resolve(data.scene);
if (data.scene) {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null),
commitToHistory: true,
appState: {
...excalidrawAPI.getAppState(),
toast: toastRef.current,
},
});
excalidrawAPI.scrollToContent();
}
},
));
toastRef.current = null;
disconnectRef.current = false;
}
}
} }
}; };
@@ -550,7 +607,7 @@ const ExcalidrawWrapper = () => {
const onExportToBackend = async ( const onExportToBackend = async (
exportedElements: readonly NonDeletedExcalidrawElement[], exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: Partial<AppState>,
files: BinaryFiles, files: BinaryFiles,
canvas: HTMLCanvasElement | null, canvas: HTMLCanvasElement | null,
) => { ) => {
@@ -581,7 +638,7 @@ const ExcalidrawWrapper = () => {
const renderCustomStats = ( const renderCustomStats = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: UIAppState,
) => { ) => {
return ( return (
<CustomStats <CustomStats
+2 -2
View File
@@ -18,8 +18,8 @@ interface Window {
EXCALIDRAW_EXPORT_SOURCE: string; EXCALIDRAW_EXPORT_SOURCE: string;
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined; EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
gtag: Function; gtag: Function;
_paq: any[]; sa_event: Function;
_mtm: any[]; fathom: { trackEvent: Function };
} }
interface CanvasRenderingContext2D { interface CanvasRenderingContext2D {
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
export const useOutsideClickHook = (handler: (event: Event) => void) => { export const useOutsideClick = (handler: (event: Event) => void) => {
const ref = useRef(null); const ref = useRef(null);
useEffect( useEffect(
+5 -13
View File
@@ -54,6 +54,7 @@
"veryLarge": "كبير جدا", "veryLarge": "كبير جدا",
"solid": "كامل", "solid": "كامل",
"hachure": "خطوط", "hachure": "خطوط",
"zigzag": "",
"crossHatch": "خطوط متقطعة", "crossHatch": "خطوط متقطعة",
"thin": "نحيف", "thin": "نحيف",
"bold": "داكن", "bold": "داكن",
@@ -207,19 +208,10 @@
"collabSaveFailed": "تعذر الحفظ في قاعدة البيانات. إذا استمرت المشاكل، يفضل أن تحفظ ملفك محليا كي لا تفقد عملك.", "collabSaveFailed": "تعذر الحفظ في قاعدة البيانات. إذا استمرت المشاكل، يفضل أن تحفظ ملفك محليا كي لا تفقد عملك.",
"collabSaveFailed_sizeExceeded": "تعذر الحفظ في قاعدة البيانات، يبدو أن القماش كبير للغاية، يفضّل حفظ الملف محليا كي لا تفقد عملك.", "collabSaveFailed_sizeExceeded": "تعذر الحفظ في قاعدة البيانات، يبدو أن القماش كبير للغاية، يفضّل حفظ الملف محليا كي لا تفقد عملك.",
"brave_measure_text_error": { "brave_measure_text_error": {
"start": "", "line1": "",
"aggressive_block_fingerprint": "", "line2": "",
"setting_enabled": "", "line3": "",
"break": "", "line4": ""
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
} }
}, },
"toolBar": { "toolBar": {
+13 -39
View File
@@ -54,6 +54,7 @@
"veryLarge": "Много голям", "veryLarge": "Много голям",
"solid": "Солиден", "solid": "Солиден",
"hachure": "Хералдика", "hachure": "Хералдика",
"zigzag": "",
"crossHatch": "Двойно-пресечено", "crossHatch": "Двойно-пресечено",
"thin": "Тънък", "thin": "Тънък",
"bold": "Ясно очертан", "bold": "Ясно очертан",
@@ -207,19 +208,10 @@
"collabSaveFailed": "", "collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": "", "collabSaveFailed_sizeExceeded": "",
"brave_measure_text_error": { "brave_measure_text_error": {
"start": "", "line1": "",
"aggressive_block_fingerprint": "", "line2": "",
"setting_enabled": "", "line3": "",
"break": "", "line4": ""
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
} }
}, },
"toolBar": { "toolBar": {
@@ -272,16 +264,11 @@
"canvasTooBigTip": "Подсказка: пробвайте да приближите далечните елементи по-близко." "canvasTooBigTip": "Подсказка: пробвайте да приближите далечните елементи по-близко."
}, },
"errorSplash": { "errorSplash": {
"headingMain_pre": "Среща грешка. Опитайте ", "headingMain": "Среща грешка. Опитайте <button>презареждане на страницата.</button>",
"headingMain_button": "презареждане на страницата.", "clearCanvasMessage": "Ако презареждането не работи, опитайте <button>изчистване на платното.</button>",
"clearCanvasMessage": "Ако презареждането не работи, опитайте ",
"clearCanvasMessage_button": "изчистване на платното.",
"clearCanvasCaveat": " Това ще доведе до загуба на работа ", "clearCanvasCaveat": " Това ще доведе до загуба на работа ",
"trackedToSentry_pre": "Грешката с идентификатор ", "trackedToSentry": "Грешката с идентификатор {{eventId}} беше проследен в нашата система.",
"trackedToSentry_post": " беше проследен в нашата система.", "openIssueMessage": "Бяхме много предпазливи да не включите информацията за вашата сцена при грешката. Ако сцената ви не е частна, моля, помислете за последващи действия на нашата <button>тракер за грешки.</button> Моля, включете информация по-долу, като я копирате и добавите в GitHub.",
"openIssueMessage_pre": "Бяхме много предпазливи да не включите информацията за вашата сцена при грешката. Ако сцената ви не е частна, моля, помислете за последващи действия на нашата ",
"openIssueMessage_button": "тракер за грешки.",
"openIssueMessage_post": " Моля, включете информация по-долу, като я копирате и добавите в GitHub.",
"sceneContent": "Съдържание на сцената:" "sceneContent": "Съдържание на сцената:"
}, },
"roomDialog": { "roomDialog": {
@@ -361,29 +348,16 @@
"required": "", "required": "",
"website": "" "website": ""
}, },
"noteDescription": { "noteDescription": "",
"pre": "", "noteGuidelines": "",
"link": "", "noteLicense": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "", "noteItems": "",
"atleastOneLibItem": "", "atleastOneLibItem": "",
"republishWarning": "" "republishWarning": ""
}, },
"publishSuccessDialog": { "publishSuccessDialog": {
"title": "", "title": "",
"content": "", "content": ""
"link": ""
}, },
"confirmDialog": { "confirmDialog": {
"resetLibrary": "", "resetLibrary": "",
+15 -41
View File
@@ -1,7 +1,7 @@
{ {
"labels": { "labels": {
"paste": "পেস্ট করুন", "paste": "পেস্ট করুন",
"pasteAsPlaintext": "", "pasteAsPlaintext": "প্লেইনটেক্সট হিসাবে পেস্ট করুন",
"pasteCharts": "চার্ট পেস্ট করুন", "pasteCharts": "চার্ট পেস্ট করুন",
"selectAll": "সবটা সিলেক্ট করুন", "selectAll": "সবটা সিলেক্ট করুন",
"multiSelect": "একাধিক সিলেক্ট করুন", "multiSelect": "একাধিক সিলেক্ট করুন",
@@ -54,6 +54,7 @@
"veryLarge": "অনেক বড়", "veryLarge": "অনেক বড়",
"solid": "দৃঢ়", "solid": "দৃঢ়",
"hachure": "ভ্রুলেখা", "hachure": "ভ্রুলেখা",
"zigzag": "আঁকাবাঁকা",
"crossHatch": "ক্রস হ্যাচ", "crossHatch": "ক্রস হ্যাচ",
"thin": "পাতলা", "thin": "পাতলা",
"bold": "পুরু", "bold": "পুরু",
@@ -72,7 +73,7 @@
"layers": "মাত্রা", "layers": "মাত্রা",
"actions": "ক্রিয়া", "actions": "ক্রিয়া",
"language": "ভাষা", "language": "ভাষা",
"liveCollaboration": "", "liveCollaboration": "সরাসরি পারস্পরিক সহযোগিতা...",
"duplicateSelection": "সদৃশ সিলেক্ট", "duplicateSelection": "সদৃশ সিলেক্ট",
"untitled": "অনামী", "untitled": "অনামী",
"name": "নাম", "name": "নাম",
@@ -207,19 +208,10 @@
"collabSaveFailed": "", "collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": "", "collabSaveFailed_sizeExceeded": "",
"brave_measure_text_error": { "brave_measure_text_error": {
"start": "", "line1": "",
"aggressive_block_fingerprint": "", "line2": "",
"setting_enabled": "", "line3": "",
"break": "", "line4": ""
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
} }
}, },
"toolBar": { "toolBar": {
@@ -272,16 +264,11 @@
"canvasTooBigTip": "বিশেষ্য: দূরতম উপাদানগুলোকে একটু কাছাকাছি নিয়ে যাওয়ার চেষ্টা করুন।" "canvasTooBigTip": "বিশেষ্য: দূরতম উপাদানগুলোকে একটু কাছাকাছি নিয়ে যাওয়ার চেষ্টা করুন।"
}, },
"errorSplash": { "errorSplash": {
"headingMain_pre": "একটি ত্রুটির সম্মুখীন হয়েছে৷ চেষ্টা করুন ", "headingMain": "একটি ত্রুটির সম্মুখীন হয়েছে৷ চেষ্টা করুন <button>পৃষ্ঠাটি পুনরায় লোড করার।</button>",
"headingMain_button": "পৃষ্ঠাটি পুনরায় লোড করার।", "clearCanvasMessage": "যদি পুনরায় লোড করা কাজ না করে, চেষ্টা করুন <button>ক্যানভাস পরিষ্কার করার।</button>",
"clearCanvasMessage": "যদি পুনরায় লোড করা কাজ না করে, চেষ্টা করুন ",
"clearCanvasMessage_button": "ক্যানভাস পরিষ্কার করার।",
"clearCanvasCaveat": " এর ফলে কাজের ক্ষতি হবে ", "clearCanvasCaveat": " এর ফলে কাজের ক্ষতি হবে ",
"trackedToSentry_pre": "ত্রুটি ", "trackedToSentry": "ত্রুটি {{eventId}} আমাদের সিস্টেমে ট্র্যাক করা হয়েছিল।",
"trackedToSentry_post": " আমাদের সিস্টেমে ট্র্যাক করা হয়েছিল।", "openIssueMessage": "আমরা ত্রুটিতে আপনার দৃশ্যের তথ্য অন্তর্ভুক্ত না করার জন্য খুব সতর্ক ছিলাম। আপনার দৃশ্য ব্যক্তিগত না হলে, আমাদের অনুসরণ করার কথা বিবেচনা করুন <button>ত্রুটি ইতিবৃত্ত।</button> অনুগ্রহ করে GitHub ইস্যুতে অনুলিপি এবং পেস্ট করে নীচের তথ্য অন্তর্ভুক্ত করুন।",
"openIssueMessage_pre": "আমরা ত্রুটিতে আপনার দৃশ্যের তথ্য অন্তর্ভুক্ত না করার জন্য খুব সতর্ক ছিলাম। আপনার দৃশ্য ব্যক্তিগত না হলে, আমাদের অনুসরণ করার কথা বিবেচনা করুন ",
"openIssueMessage_button": "ত্রুটি ইতিবৃত্ত।",
"openIssueMessage_post": " অনুগ্রহ করে GitHub ইস্যুতে অনুলিপি এবং পেস্ট করে নীচের তথ্য অন্তর্ভুক্ত করুন।",
"sceneContent": "দৃশ্য বিষয়বস্তু:" "sceneContent": "দৃশ্য বিষয়বস্তু:"
}, },
"roomDialog": { "roomDialog": {
@@ -361,29 +348,16 @@
"required": "", "required": "",
"website": "" "website": ""
}, },
"noteDescription": { "noteDescription": "",
"pre": "", "noteGuidelines": "",
"link": "", "noteLicense": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "", "noteItems": "",
"atleastOneLibItem": "", "atleastOneLibItem": "",
"republishWarning": "" "republishWarning": ""
}, },
"publishSuccessDialog": { "publishSuccessDialog": {
"title": "", "title": "",
"content": "", "content": ""
"link": ""
}, },
"confirmDialog": { "confirmDialog": {
"resetLibrary": "", "resetLibrary": "",
+13 -39
View File
@@ -54,6 +54,7 @@
"veryLarge": "Molt gran", "veryLarge": "Molt gran",
"solid": "Sòlid", "solid": "Sòlid",
"hachure": "Ratlletes", "hachure": "Ratlletes",
"zigzag": "",
"crossHatch": "Ratlletes creuades", "crossHatch": "Ratlletes creuades",
"thin": "Fi", "thin": "Fi",
"bold": "Negreta", "bold": "Negreta",
@@ -207,19 +208,10 @@
"collabSaveFailed": "No s'ha pogut desar a la base de dades de fons. Si els problemes persisteixen, hauríeu de desar el fitxer localment per assegurar-vos que no perdeu el vostre treball.", "collabSaveFailed": "No s'ha pogut desar a la base de dades de fons. Si els problemes persisteixen, hauríeu de desar el fitxer localment per assegurar-vos que no perdeu el vostre treball.",
"collabSaveFailed_sizeExceeded": "No s'ha pogut desar a la base de dades de fons, sembla que el llenç és massa gran. Hauríeu de desar el fitxer localment per assegurar-vos que no perdeu el vostre treball.", "collabSaveFailed_sizeExceeded": "No s'ha pogut desar a la base de dades de fons, sembla que el llenç és massa gran. Hauríeu de desar el fitxer localment per assegurar-vos que no perdeu el vostre treball.",
"brave_measure_text_error": { "brave_measure_text_error": {
"start": "", "line1": "",
"aggressive_block_fingerprint": "", "line2": "",
"setting_enabled": "", "line3": "",
"break": "", "line4": ""
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
} }
}, },
"toolBar": { "toolBar": {
@@ -272,16 +264,11 @@
"canvasTooBigTip": "Consell: proveu dacostar una mica els elements més allunyats." "canvasTooBigTip": "Consell: proveu dacostar una mica els elements més allunyats."
}, },
"errorSplash": { "errorSplash": {
"headingMain_pre": "S'ha produït un error. Proveu ", "headingMain": "S'ha produït un error. Proveu <button>recarregar la pàgina.</button>",
"headingMain_button": "recarregar la pàgina.", "clearCanvasMessage": "Si la recàrrega no funciona, proveu <button>esborrar el llenç.</button>",
"clearCanvasMessage": "Si la recàrrega no funciona, proveu ",
"clearCanvasMessage_button": "esborrar el llenç.",
"clearCanvasCaveat": " Això resultarà en la pèrdua de feina ", "clearCanvasCaveat": " Això resultarà en la pèrdua de feina ",
"trackedToSentry_pre": "L'error amb l'identificador ", "trackedToSentry": "L'error amb l'identificador {{eventId}} s'ha rastrejat en el nostre sistema.",
"trackedToSentry_post": " s'ha rastrejat en el nostre sistema.", "openIssueMessage": "Anàvem amb molta cura de no incloure la informació de la vostra escena en l'error. Si l'escena no és privada, podeu fer-ne el seguiment al nostre <button>rastrejador d'errors.</button> Incloeu la informació a continuació copiant i enganxant a GitHub Issues.",
"openIssueMessage_pre": "Anàvem amb molta cura de no incloure la informació de la vostra escena en l'error. Si l'escena no és privada, podeu fer-ne el seguiment al nostre ",
"openIssueMessage_button": "rastrejador d'errors.",
"openIssueMessage_post": " Incloeu la informació a continuació copiant i enganxant a GitHub Issues.",
"sceneContent": "Contingut de l'escena:" "sceneContent": "Contingut de l'escena:"
}, },
"roomDialog": { "roomDialog": {
@@ -361,29 +348,16 @@
"required": "Requerit", "required": "Requerit",
"website": "Introduïu una URL vàlida" "website": "Introduïu una URL vàlida"
}, },
"noteDescription": { "noteDescription": "Envieu la vostra biblioteca perquè sigui inclosa al <link>repositori públic</link>per tal que altres persones puguin fer-ne ús en els seus dibuixos.",
"pre": "Envieu la vostra biblioteca perquè sigui inclosa al ", "noteGuidelines": "La biblioteca ha de ser aprovada manualment. Si us plau, llegiu les <link>directrius</link> abans d'enviar-hi res. Necessitareu un compte de GitHub per a comunicar i fer-hi canvis si cal, però no és requisit imprescindible.",
"link": "repositori públic", "noteLicense": "Quan l'envieu, accepteu que la biblioteca sigui publicada sota la <link>llicència MIT, </link>que, en resum, vol dir que qualsevol persona pot fer-ne ús sense restriccions.",
"post": "per tal que altres persones puguin fer-ne ús en els seus dibuixos."
},
"noteGuidelines": {
"pre": "La biblioteca ha de ser aprovada manualment. Si us plau, llegiu les ",
"link": "directrius",
"post": " abans d'enviar-hi res. Necessitareu un compte de GitHub per a comunicar i fer-hi canvis si cal, però no és requisit imprescindible."
},
"noteLicense": {
"pre": "Quan l'envieu, accepteu que la biblioteca sigui publicada sota la ",
"link": "llicència MIT, ",
"post": "que, en resum, vol dir que qualsevol persona pot fer-ne ús sense restriccions."
},
"noteItems": "Cada element de la biblioteca ha de tenir el seu propi nom per tal que sigui filtrable. S'hi inclouran els elements següents:", "noteItems": "Cada element de la biblioteca ha de tenir el seu propi nom per tal que sigui filtrable. S'hi inclouran els elements següents:",
"atleastOneLibItem": "Si us plau, seleccioneu si més no un element de la biblioteca per a començar", "atleastOneLibItem": "Si us plau, seleccioneu si més no un element de la biblioteca per a començar",
"republishWarning": "Nota: alguns dels elements seleccionats s'han marcat com a publicats/enviats. Només hauríeu de reenviar elements quan actualitzeu una biblioteca existent." "republishWarning": "Nota: alguns dels elements seleccionats s'han marcat com a publicats/enviats. Només hauríeu de reenviar elements quan actualitzeu una biblioteca existent."
}, },
"publishSuccessDialog": { "publishSuccessDialog": {
"title": "Biblioteca enviada", "title": "Biblioteca enviada",
"content": "Gràcies, {{authorName}}. La vostra biblioteca ha estat enviada per a ser revisada. Podeu comprovar-ne l'estat", "content": "Gràcies, {{authorName}}. La vostra biblioteca ha estat enviada per a ser revisada. Podeu comprovar-ne l'estat<link>aquí</link>"
"link": "aquí"
}, },
"confirmDialog": { "confirmDialog": {
"resetLibrary": "Restableix la biblioteca", "resetLibrary": "Restableix la biblioteca",
+12 -38
View File
@@ -54,6 +54,7 @@
"veryLarge": "Velmi velké", "veryLarge": "Velmi velké",
"solid": "Plný", "solid": "Plný",
"hachure": "", "hachure": "",
"zigzag": "",
"crossHatch": "", "crossHatch": "",
"thin": "Tenký", "thin": "Tenký",
"bold": "Tlustý", "bold": "Tlustý",
@@ -207,19 +208,10 @@
"collabSaveFailed": "", "collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": "", "collabSaveFailed_sizeExceeded": "",
"brave_measure_text_error": { "brave_measure_text_error": {
"start": "", "line1": "",
"aggressive_block_fingerprint": "", "line2": "",
"setting_enabled": "", "line3": "",
"break": "", "line4": ""
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
} }
}, },
"toolBar": { "toolBar": {
@@ -272,16 +264,11 @@
"canvasTooBigTip": "" "canvasTooBigTip": ""
}, },
"errorSplash": { "errorSplash": {
"headingMain_pre": "", "headingMain": "",
"headingMain_button": "",
"clearCanvasMessage": "", "clearCanvasMessage": "",
"clearCanvasMessage_button": "",
"clearCanvasCaveat": "", "clearCanvasCaveat": "",
"trackedToSentry_pre": "Chyba identifikátoru ", "trackedToSentry": "Chyba identifikátoru {{eventId}} byl zaznamenán v našem systému.",
"trackedToSentry_post": " byl zaznamenán v našem systému.", "openIssueMessage": "",
"openIssueMessage_pre": "",
"openIssueMessage_button": "",
"openIssueMessage_post": "",
"sceneContent": "" "sceneContent": ""
}, },
"roomDialog": { "roomDialog": {
@@ -361,29 +348,16 @@
"required": "Povinné", "required": "Povinné",
"website": "Zadejte platnou URL adresu" "website": "Zadejte platnou URL adresu"
}, },
"noteDescription": { "noteDescription": "Odešlete svou knihovnu, pro zařazení do <link>veřejného úložiště knihoven</link>, odkud ji budou moci při kreslení využít i ostatní uživatelé.",
"pre": "Odešlete svou knihovnu, pro zařazení do ", "noteGuidelines": "Knihovna musí být nejdříve ručně schválena. Přečtěte si prosím <link>pokyny</link>",
"link": "veřejného úložiště knihoven", "noteLicense": "",
"post": ", odkud ji budou moci při kreslení využít i ostatní uživatelé."
},
"noteGuidelines": {
"pre": "Knihovna musí být nejdříve ručně schválena. Přečtěte si prosím ",
"link": "pokyny",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "", "noteItems": "",
"atleastOneLibItem": "", "atleastOneLibItem": "",
"republishWarning": "" "republishWarning": ""
}, },
"publishSuccessDialog": { "publishSuccessDialog": {
"title": "Knihovna byla odeslána", "title": "Knihovna byla odeslána",
"content": "", "content": ""
"link": ""
}, },
"confirmDialog": { "confirmDialog": {
"resetLibrary": "", "resetLibrary": "",
+12 -38
View File
@@ -54,6 +54,7 @@
"veryLarge": "Meget stor", "veryLarge": "Meget stor",
"solid": "Solid", "solid": "Solid",
"hachure": "Skravering", "hachure": "Skravering",
"zigzag": "",
"crossHatch": "Krydsskravering", "crossHatch": "Krydsskravering",
"thin": "Tynd", "thin": "Tynd",
"bold": "Fed", "bold": "Fed",
@@ -207,19 +208,10 @@
"collabSaveFailed": "", "collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": "", "collabSaveFailed_sizeExceeded": "",
"brave_measure_text_error": { "brave_measure_text_error": {
"start": "", "line1": "",
"aggressive_block_fingerprint": "", "line2": "",
"setting_enabled": "", "line3": "",
"break": "", "line4": ""
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
} }
}, },
"toolBar": { "toolBar": {
@@ -272,16 +264,11 @@
"canvasTooBigTip": "" "canvasTooBigTip": ""
}, },
"errorSplash": { "errorSplash": {
"headingMain_pre": "", "headingMain": "",
"headingMain_button": "",
"clearCanvasMessage": "", "clearCanvasMessage": "",
"clearCanvasMessage_button": "",
"clearCanvasCaveat": "", "clearCanvasCaveat": "",
"trackedToSentry_pre": "", "trackedToSentry": "",
"trackedToSentry_post": "", "openIssueMessage": "<button></button> Kopiere og indsæt venligst oplysningerne nedenfor i et GitHub problem.",
"openIssueMessage_pre": "",
"openIssueMessage_button": "",
"openIssueMessage_post": " Kopiere og indsæt venligst oplysningerne nedenfor i et GitHub problem.",
"sceneContent": "Scene indhold:" "sceneContent": "Scene indhold:"
}, },
"roomDialog": { "roomDialog": {
@@ -361,29 +348,16 @@
"required": "", "required": "",
"website": "" "website": ""
}, },
"noteDescription": { "noteDescription": "",
"pre": "", "noteGuidelines": "",
"link": "", "noteLicense": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "", "noteItems": "",
"atleastOneLibItem": "", "atleastOneLibItem": "",
"republishWarning": "" "republishWarning": ""
}, },
"publishSuccessDialog": { "publishSuccessDialog": {
"title": "", "title": "",
"content": "", "content": ""
"link": ""
}, },
"confirmDialog": { "confirmDialog": {
"resetLibrary": "", "resetLibrary": "",
+13 -39
View File
@@ -54,6 +54,7 @@
"veryLarge": "Sehr groß", "veryLarge": "Sehr groß",
"solid": "Deckend", "solid": "Deckend",
"hachure": "Schraffiert", "hachure": "Schraffiert",
"zigzag": "Zickzack",
"crossHatch": "Kreuzschraffiert", "crossHatch": "Kreuzschraffiert",
"thin": "Dünn", "thin": "Dünn",
"bold": "Fett", "bold": "Fett",
@@ -207,19 +208,10 @@
"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": "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.", "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.",
"brave_measure_text_error": { "brave_measure_text_error": {
"start": "Sieht so aus, als ob du den Brave Browser benutzt mit der", "line1": "Sieht so aus, als ob Du den Brave-Browser verwendest und die <bold>aggressive Blockierung von Fingerabdrücken</bold> aktiviert hast.",
"aggressive_block_fingerprint": "\"Fingerprinting aggressiv blockieren\"", "line2": "Dies könnte dazu führen, dass die <bold>Textelemente</bold> in Ihren Zeichnungen zerstört werden.",
"setting_enabled": "Einstellung aktiviert", "line3": "Wir empfehlen dringend, diese Einstellung zu deaktivieren. Dazu kannst Du <link>diesen Schritten</link> folgen.",
"break": "Dies könnte zur inkorrekten Darstellung der", "line4": "Wenn die Deaktivierung dieser Einstellung die fehlerhafte Anzeige von Textelementen nicht behebt, öffne bitte ein <issueLink>Ticket</issueLink> auf unserem GitHub oder schreibe uns auf <discordLink>Discord</discordLink>"
"text_elements": "Textelemente",
"in_your_drawings": "in deinen Zeichnungen führen",
"strongly_recommend": "Wir empfehlen dringend, diese Einstellung zu deaktivieren. Du kannst",
"steps": "diesen Schritten entsprechend",
"how": "folgen",
"disable_setting": " Wenn die Deaktivierung dieser Einstellung nicht zu einer korrekten Textdarstellung führt, öffne bitte einen",
"issue": "Issue",
"write": "auf GitHub, oder schreibe uns auf",
"discord": "Discord"
} }
}, },
"toolBar": { "toolBar": {
@@ -272,16 +264,11 @@
"canvasTooBigTip": "Tipp: Schiebe die am weitesten entfernten Elemente ein wenig näher zusammen." "canvasTooBigTip": "Tipp: Schiebe die am weitesten entfernten Elemente ein wenig näher zusammen."
}, },
"errorSplash": { "errorSplash": {
"headingMain_pre": "Es ist ein Fehler aufgetreten. Versuche ", "headingMain": "Es ist ein Fehler aufgetreten. Versuche <button>die Seite neu zu laden.</button>",
"headingMain_button": "die Seite neu zu laden.", "clearCanvasMessage": "Wenn das Neuladen nicht funktioniert, versuche <button>die Zeichenfläche zu löschen.</button>",
"clearCanvasMessage": "Wenn das Neuladen nicht funktioniert, versuche ",
"clearCanvasMessage_button": "die Zeichenfläche zu löschen.",
"clearCanvasCaveat": " Dies wird zum Verlust von Daten führen ", "clearCanvasCaveat": " Dies wird zum Verlust von Daten führen ",
"trackedToSentry_pre": "Der Fehler mit der Kennung ", "trackedToSentry": "Der Fehler mit der Kennung {{eventId}} wurde in unserem System registriert.",
"trackedToSentry_post": " wurde in unserem System registriert.", "openIssueMessage": "Wir waren sehr vorsichtig und haben deine Zeichnungsinformationen nicht in die Fehlerinformationen aufgenommen. Wenn deine Zeichnung nicht privat ist, unterstütze uns bitte über unseren <button>Bug-Tracker.</button> Bitte teile die unten stehenden Informationen mit uns im GitHub Issue (Kopieren und Einfügen).",
"openIssueMessage_pre": "Wir waren sehr vorsichtig und haben deine Zeichnungsinformationen nicht in die Fehlerinformationen aufgenommen. Wenn deine Zeichnung nicht privat ist, unterstütze uns bitte über unseren ",
"openIssueMessage_button": "Bug-Tracker.",
"openIssueMessage_post": " Bitte teile die unten stehenden Informationen mit uns im GitHub Issue (Kopieren und Einfügen).",
"sceneContent": "Zeichnungsinhalt:" "sceneContent": "Zeichnungsinhalt:"
}, },
"roomDialog": { "roomDialog": {
@@ -361,29 +348,16 @@
"required": "Erforderlich", "required": "Erforderlich",
"website": "Gültige URL eingeben" "website": "Gültige URL eingeben"
}, },
"noteDescription": { "noteDescription": "Sende deine Bibliothek ein, um in die <link>öffentliche Bibliotheks-Repository aufgenommen zu werden</link>damit andere Nutzer sie in ihren Zeichnungen verwenden können.",
"pre": "Sende deine Bibliothek ein, um in die ", "noteGuidelines": "Die Bibliothek muss zuerst manuell freigegeben werden. Bitte lies die <link>Richtlinien</link> vor dem Absenden. Du benötigst ein GitHub-Konto, um zu kommunizieren und Änderungen vorzunehmen, falls erforderlich, aber es ist nicht unbedingt erforderlich.",
"link": "öffentliche Bibliotheks-Repository aufgenommen zu werden", "noteLicense": "Mit dem Absenden stimmst du zu, dass die Bibliothek unter der <link>MIT-Lizenz, </link>die zusammengefasst beinhaltet, dass jeder sie ohne Einschränkungen nutzen kann.",
"post": "damit andere Nutzer sie in ihren Zeichnungen verwenden können."
},
"noteGuidelines": {
"pre": "Die Bibliothek muss zuerst manuell freigegeben werden. Bitte lies die ",
"link": "Richtlinien",
"post": " vor dem Absenden. Du benötigst ein GitHub-Konto, um zu kommunizieren und Änderungen vorzunehmen, falls erforderlich, aber es ist nicht unbedingt erforderlich."
},
"noteLicense": {
"pre": "Mit dem Absenden stimmst du zu, dass die Bibliothek unter der ",
"link": "MIT-Lizenz, ",
"post": "die zusammengefasst beinhaltet, dass jeder sie ohne Einschränkungen nutzen kann."
},
"noteItems": "Jedes Bibliothekselement muss einen eigenen Namen haben, damit es gefiltert werden kann. Die folgenden Bibliothekselemente werden hinzugefügt:", "noteItems": "Jedes Bibliothekselement muss einen eigenen Namen haben, damit es gefiltert werden kann. Die folgenden Bibliothekselemente werden hinzugefügt:",
"atleastOneLibItem": "Bitte wähle mindestens ein Bibliothekselement aus, um zu beginnen", "atleastOneLibItem": "Bitte wähle mindestens ein Bibliothekselement aus, um zu beginnen",
"republishWarning": "Hinweis: Einige der ausgewählten Elemente sind bereits als veröffentlicht/eingereicht markiert. Du solltest Elemente nur erneut einreichen, wenn Du eine existierende Bibliothek oder Einreichung aktualisierst." "republishWarning": "Hinweis: Einige der ausgewählten Elemente sind bereits als veröffentlicht/eingereicht markiert. Du solltest Elemente nur erneut einreichen, wenn Du eine existierende Bibliothek oder Einreichung aktualisierst."
}, },
"publishSuccessDialog": { "publishSuccessDialog": {
"title": "Bibliothek übermittelt", "title": "Bibliothek übermittelt",
"content": "Vielen Dank {{authorName}}. Deine Bibliothek wurde zur Überprüfung eingereicht. Du kannst den Status verfolgen", "content": "Vielen Dank {{authorName}}. Deine Bibliothek wurde zur Überprüfung eingereicht. Du kannst den Status verfolgen<link>hier</link>"
"link": "hier"
}, },
"confirmDialog": { "confirmDialog": {
"resetLibrary": "Bibliothek zurücksetzen", "resetLibrary": "Bibliothek zurücksetzen",
+13 -39
View File
@@ -54,6 +54,7 @@
"veryLarge": "Πολύ μεγάλο", "veryLarge": "Πολύ μεγάλο",
"solid": "Συμπαγής", "solid": "Συμπαγής",
"hachure": "Εκκόλαψη", "hachure": "Εκκόλαψη",
"zigzag": "",
"crossHatch": "Διασταυρούμενη εκκόλαψη", "crossHatch": "Διασταυρούμενη εκκόλαψη",
"thin": "Λεπτή", "thin": "Λεπτή",
"bold": "Έντονη", "bold": "Έντονη",
@@ -207,19 +208,10 @@
"collabSaveFailed": "Η αποθήκευση στη βάση δεδομένων δεν ήταν δυνατή. Αν το προβλήματα παραμείνει, θα πρέπει να αποθηκεύσετε το αρχείο σας τοπικά για να βεβαιωθείτε ότι δεν χάνετε την εργασία σας.", "collabSaveFailed": "Η αποθήκευση στη βάση δεδομένων δεν ήταν δυνατή. Αν το προβλήματα παραμείνει, θα πρέπει να αποθηκεύσετε το αρχείο σας τοπικά για να βεβαιωθείτε ότι δεν χάνετε την εργασία σας.",
"collabSaveFailed_sizeExceeded": "Η αποθήκευση στη βάση δεδομένων δεν ήταν δυνατή, ο καμβάς φαίνεται να είναι πολύ μεγάλος. Θα πρέπει να αποθηκεύσετε το αρχείο τοπικά για να βεβαιωθείτε ότι δεν θα χάσετε την εργασία σας.", "collabSaveFailed_sizeExceeded": "Η αποθήκευση στη βάση δεδομένων δεν ήταν δυνατή, ο καμβάς φαίνεται να είναι πολύ μεγάλος. Θα πρέπει να αποθηκεύσετε το αρχείο τοπικά για να βεβαιωθείτε ότι δεν θα χάσετε την εργασία σας.",
"brave_measure_text_error": { "brave_measure_text_error": {
"start": "Φαίνεται ότι χρησιμοποιείτε το Brave browser με το", "line1": "",
"aggressive_block_fingerprint": "Αποκλεισμός \"Δακτυλικών Αποτυπωμάτων\"", "line2": "",
"setting_enabled": "ρύθμιση ενεργοποιημένη", "line3": "",
"break": "Αυτό θα μπορούσε να σπάσει το", "line4": ""
"text_elements": "Στοιχεία Κειμένου",
"in_your_drawings": "στα σχέδιά σας",
"strongly_recommend": "Συνιστούμε να απενεργοποιήσετε αυτή τη ρύθμιση. Μπορείτε να ακολουθήσετε",
"steps": "αυτά τα βήματα",
"how": "για το πώς να το κάνετε",
"disable_setting": " Εάν η απενεργοποίηση αυτής της ρύθμισης δεν διορθώνει την εμφάνιση των στοιχείων κειμένου, παρακαλώ ανοίξτε ένα",
"issue": "πρόβλημα",
"write": "στο GitHub, ή γράψτε μας στο",
"discord": "Discord"
} }
}, },
"toolBar": { "toolBar": {
@@ -272,16 +264,11 @@
"canvasTooBigTip": "Συμβουλή: προσπαθήστε να μετακινήσετε τα πιο απομακρυσμένα στοιχεία λίγο πιο κοντά μαζί." "canvasTooBigTip": "Συμβουλή: προσπαθήστε να μετακινήσετε τα πιο απομακρυσμένα στοιχεία λίγο πιο κοντά μαζί."
}, },
"errorSplash": { "errorSplash": {
"headingMain_pre": "Συνέβη κάποιο σφάλμα. Προσπάθησε ", "headingMain": "Συνέβη κάποιο σφάλμα. Προσπάθησε <button>φόρτωσε ξανά την σελίδα.</button>",
"headingMain_button": "φόρτωσε ξανά την σελίδα.", "clearCanvasMessage": "Εάν το παραπάνω δεν δουλέψει, προσπάθησε <button>καθαρίσετε τον κανβά.</button>",
"clearCanvasMessage": "Εάν το παραπάνω δεν δουλέψει, προσπάθησε ",
"clearCanvasMessage_button": "καθαρίσετε τον κανβά.",
"clearCanvasCaveat": " Αυτό θα προκαλέσει απώλεια της δουλειάς σου ", "clearCanvasCaveat": " Αυτό θα προκαλέσει απώλεια της δουλειάς σου ",
"trackedToSentry_pre": "Το σφάλμα με αναγνωριστικό ", "trackedToSentry": "Το σφάλμα με αναγνωριστικό {{eventId}} παρακολουθήθηκε στο σύστημά μας.",
"trackedToSentry_post": " παρακολουθήθηκε στο σύστημά μας.", "openIssueMessage": "Ήμασταν πολύ προσεκτικοί για να μην συμπεριλάβουμε τις πληροφορίες της σκηνής σου στο σφάλμα. Αν η σκηνή σου δεν είναι ιδιωτική, παρακαλώ σκέψου να ακολουθήσεις το δικό μας <button>ανιχνευτής σφαλμάτων.</button> Παρακαλώ να συμπεριλάβετε τις παρακάτω πληροφορίες, αντιγράφοντας και επικολλώντας το ζήτημα στο GitHub.",
"openIssueMessage_pre": "Ήμασταν πολύ προσεκτικοί για να μην συμπεριλάβουμε τις πληροφορίες της σκηνής σου στο σφάλμα. Αν η σκηνή σου δεν είναι ιδιωτική, παρακαλώ σκέψου να ακολουθήσεις το δικό μας ",
"openIssueMessage_button": "ανιχνευτής σφαλμάτων.",
"openIssueMessage_post": " Παρακαλώ να συμπεριλάβετε τις παρακάτω πληροφορίες, αντιγράφοντας και επικολλώντας το ζήτημα στο GitHub.",
"sceneContent": "Περιεχόμενο σκηνής:" "sceneContent": "Περιεχόμενο σκηνής:"
}, },
"roomDialog": { "roomDialog": {
@@ -361,29 +348,16 @@
"required": "Απαιτείται", "required": "Απαιτείται",
"website": "Εισάγετε μια έγκυρη διεύθυνση URL" "website": "Εισάγετε μια έγκυρη διεύθυνση URL"
}, },
"noteDescription": { "noteDescription": "Υποβάλετε τη βιβλιοθήκη σας για να συμπεριληφθεί στο <link>δημόσιο αποθετήριο βιβλιοθήκης</link>ώστε να χρησιμοποιηθεί από άλλα άτομα στα σχέδιά τους.",
"pre": "Υποβάλετε τη βιβλιοθήκη σας για να συμπεριληφθεί στο ", "noteGuidelines": "Η βιβλιοθήκη πρέπει πρώτα να εγκριθεί χειροκίνητα. Παρακαλώ διαβάστε τους <link>οδηγίες</link> πριν την υποβολή. Θα χρειαστείτε έναν λογαριασμό GitHub για την επικοινωνία και για να προβείτε σε αλλαγές εφ' όσον χρειαστεί, αλλά δεν είναι αυστηρή απαίτηση.",
"link": "δημόσιο αποθετήριο βιβλιοθήκης", "noteLicense": "Με την υποβολή, συμφωνείτε ότι η βιβλιοθήκη θα δημοσιευθεί υπό την <link>Άδεια MIT, </link>που εν συντομία σημαίνει ότι ο καθένας μπορεί να τα χρησιμοποιήσει χωρίς περιορισμούς.",
"post": "ώστε να χρησιμοποιηθεί από άλλα άτομα στα σχέδιά τους."
},
"noteGuidelines": {
"pre": "Η βιβλιοθήκη πρέπει πρώτα να εγκριθεί χειροκίνητα. Παρακαλώ διαβάστε τους ",
"link": "οδηγίες",
"post": " πριν την υποβολή. Θα χρειαστείτε έναν λογαριασμό GitHub για την επικοινωνία και για να προβείτε σε αλλαγές εφ' όσον χρειαστεί, αλλά δεν είναι αυστηρή απαίτηση."
},
"noteLicense": {
"pre": "Με την υποβολή, συμφωνείτε ότι η βιβλιοθήκη θα δημοσιευθεί υπό την ",
"link": "Άδεια MIT, ",
"post": "που εν συντομία σημαίνει ότι ο καθένας μπορεί να τα χρησιμοποιήσει χωρίς περιορισμούς."
},
"noteItems": "Κάθε αντικείμενο της βιβλιοθήκης πρέπει να έχει το δικό του όνομα ώστε να μπορεί να φιλτραριστεί. Θα συμπεριληφθούν τα ακόλουθα αντικείμενα βιβλιοθήκης:", "noteItems": "Κάθε αντικείμενο της βιβλιοθήκης πρέπει να έχει το δικό του όνομα ώστε να μπορεί να φιλτραριστεί. Θα συμπεριληφθούν τα ακόλουθα αντικείμενα βιβλιοθήκης:",
"atleastOneLibItem": "Παρακαλώ επιλέξτε τουλάχιστον ένα αντικείμενο βιβλιοθήκης για να ξεκινήσετε", "atleastOneLibItem": "Παρακαλώ επιλέξτε τουλάχιστον ένα αντικείμενο βιβλιοθήκης για να ξεκινήσετε",
"republishWarning": "Σημείωση: μερικά από τα επιλεγμένα αντικέιμενα έχουν ήδη επισημανθεί ως δημοσιευμένα/υποβεβλημένα. Θα πρέπει να υποβάλετε αντικείμενα εκ νέου μόνο για να ενημερώσετε μία ήδη υπάρχουσα βιβλιοθήκη ή υποβολή." "republishWarning": "Σημείωση: μερικά από τα επιλεγμένα αντικέιμενα έχουν ήδη επισημανθεί ως δημοσιευμένα/υποβεβλημένα. Θα πρέπει να υποβάλετε αντικείμενα εκ νέου μόνο για να ενημερώσετε μία ήδη υπάρχουσα βιβλιοθήκη ή υποβολή."
}, },
"publishSuccessDialog": { "publishSuccessDialog": {
"title": "Η βιβλιοθήκη υποβλήθηκε", "title": "Η βιβλιοθήκη υποβλήθηκε",
"content": "Ευχαριστούμε {{authorName}}. Η βιβλιοθήκη σας έχει υποβληθεί για αξιολόγηση. Μπορείτε να παρακολουθείτε τη διαδικασία", "content": "Ευχαριστούμε {{authorName}}. Η βιβλιοθήκη σας έχει υποβληθεί για αξιολόγηση. Μπορείτε να παρακολουθείτε τη διαδικασία<link>εδώ</link>"
"link": "εδώ"
}, },
"confirmDialog": { "confirmDialog": {
"resetLibrary": "Καθαρισμός βιβλιοθήκης", "resetLibrary": "Καθαρισμός βιβλιοθήκης",
+14 -40
View File
@@ -208,19 +208,10 @@
"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": "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.", "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.",
"brave_measure_text_error": { "brave_measure_text_error": {
"start": "Looks like you are using Brave browser with the", "line1": "Looks like you are using Brave browser with the <bold>Aggressively Block Fingerprinting</bold> setting enabled.",
"aggressive_block_fingerprint": "Aggressively Block Fingerprinting", "line2": "This could result in breaking the <bold>Text Elements</bold> in your drawings.",
"setting_enabled": "setting enabled", "line3": "We strongly recommend disabling this setting. You can follow <link>these steps</link> on how to do so.",
"break": "This could result in breaking the", "line4": "If disabling this setting doesn't fix the display of text elements, please open an <issueLink>issue</issueLink> on our GitHub, or write us on <discordLink>Discord</discordLink>"
"text_elements": "Text Elements",
"in_your_drawings": "in your drawings",
"strongly_recommend": "We strongly recommend disabling this setting. You can follow",
"steps": "these steps",
"how": "on how to do so",
"disable_setting": " If disabling this setting doesn't fix the display of text elements, please open an",
"issue": "issue",
"write": "on our GitHub, or write us on",
"discord": "Discord"
} }
}, },
"toolBar": { "toolBar": {
@@ -273,16 +264,11 @@
"canvasTooBigTip": "Tip: try moving the farthest elements a bit closer together." "canvasTooBigTip": "Tip: try moving the farthest elements a bit closer together."
}, },
"errorSplash": { "errorSplash": {
"headingMain_pre": "Encountered an error. Try ", "headingMain": "Encountered an error. Try <button>reloading the page</button>.",
"headingMain_button": "reloading the page.", "clearCanvasMessage": "If reloading doesn't work, try <button>clearing the canvas</button>.",
"clearCanvasMessage": "If reloading doesn't work, try ",
"clearCanvasMessage_button": "clearing the canvas.",
"clearCanvasCaveat": " This will result in loss of work ", "clearCanvasCaveat": " This will result in loss of work ",
"trackedToSentry_pre": "The error with identifier ", "trackedToSentry": "The error with identifier {{eventId}} was tracked on our system.",
"trackedToSentry_post": " was tracked on our system.", "openIssueMessage": "We were very cautious not to include your scene information on the error. If your scene is not private, please consider following up on our <button>bug tracker</button>. Please include information below by copying and pasting into the GitHub issue.",
"openIssueMessage_pre": "We were very cautious not to include your scene information on the error. If your scene is not private, please consider following up on our ",
"openIssueMessage_button": "bug tracker.",
"openIssueMessage_post": " Please include information below by copying and pasting into the GitHub issue.",
"sceneContent": "Scene content:" "sceneContent": "Scene content:"
}, },
"roomDialog": { "roomDialog": {
@@ -362,29 +348,16 @@
"required": "Required", "required": "Required",
"website": "Enter a valid URL" "website": "Enter a valid URL"
}, },
"noteDescription": { "noteDescription": "Submit your library to be included in the <link>public library repository</link> for other people to use in their drawings.",
"pre": "Submit your library to be included in the ", "noteGuidelines": "The library needs to be manually approved first. Please read the <link>guidelines</link> before submitting. You will need a GitHub account to communicate and make changes if requested, but it is not strictly required.",
"link": "public library repository", "noteLicense": "By submitting, you agree the library will be published under the <link>MIT License</link>, which in short means anyone can use them without restrictions.",
"post": "for other people to use in their drawings."
},
"noteGuidelines": {
"pre": "The library needs to be manually approved first. Please read the ",
"link": "guidelines",
"post": " before submitting. You will need a GitHub account to communicate and make changes if requested, but it is not strictly required."
},
"noteLicense": {
"pre": "By submitting, you agree the library will be published under the ",
"link": "MIT License, ",
"post": "which in short means anyone can use them without restrictions."
},
"noteItems": "Each library item must have its own name so it's filterable. The following library items will be included:", "noteItems": "Each library item must have its own name so it's filterable. The following library items will be included:",
"atleastOneLibItem": "Please select at least one library item to get started", "atleastOneLibItem": "Please select at least one library item to get started",
"republishWarning": "Note: some of the selected items are marked as already published/submitted. You should only resubmit items when updating an existing library or submission." "republishWarning": "Note: some of the selected items are marked as already published/submitted. You should only resubmit items when updating an existing library or submission."
}, },
"publishSuccessDialog": { "publishSuccessDialog": {
"title": "Library submitted", "title": "Library submitted",
"content": "Thank you {{authorName}}. Your library has been submitted for review. You can track the status", "content": "Thank you {{authorName}}. Your library has been submitted for review. You can track the status <link>here</link>"
"link": "here"
}, },
"confirmDialog": { "confirmDialog": {
"resetLibrary": "Reset library", "resetLibrary": "Reset library",
@@ -418,7 +391,8 @@
"fileSavedToFilename": "Saved to {filename}", "fileSavedToFilename": "Saved to {filename}",
"canvas": "canvas", "canvas": "canvas",
"selection": "selection", "selection": "selection",
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor" "pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor",
"reconnectRoomServer": "Reconnecting to server"
}, },
"colors": { "colors": {
"ffffff": "White", "ffffff": "White",
+15 -41
View File
@@ -54,6 +54,7 @@
"veryLarge": "Muy grande", "veryLarge": "Muy grande",
"solid": "Sólido", "solid": "Sólido",
"hachure": "Folleto", "hachure": "Folleto",
"zigzag": "Zigzag",
"crossHatch": "Rayado transversal", "crossHatch": "Rayado transversal",
"thin": "Fino", "thin": "Fino",
"bold": "Grueso", "bold": "Grueso",
@@ -207,19 +208,10 @@
"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": "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.", "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.",
"brave_measure_text_error": { "brave_measure_text_error": {
"start": "", "line1": "",
"aggressive_block_fingerprint": "", "line2": "",
"setting_enabled": "ajuste activado", "line3": "",
"break": "Esto podría resultar en romper los", "line4": ""
"text_elements": "Elementos de texto",
"in_your_drawings": "en tus dibujos",
"strongly_recommend": "Recomendamos desactivar esta configuración. Puedes seguir",
"steps": "estos pasos",
"how": "sobre cómo hacerlo",
"disable_setting": " Si deshabilitar esta opción no arregla la visualización de elementos de texto, por favor abre un",
"issue": "issue",
"write": "en GitHub, o escríbenos en",
"discord": "Discord"
} }
}, },
"toolBar": { "toolBar": {
@@ -272,16 +264,11 @@
"canvasTooBigTip": "Sugerencia: intenta acercar un poco más los elementos más lejanos." "canvasTooBigTip": "Sugerencia: intenta acercar un poco más los elementos más lejanos."
}, },
"errorSplash": { "errorSplash": {
"headingMain_pre": "Se encontró un error. Intente ", "headingMain": "Se encontró un error. Intente <button>recargando la página.</button>",
"headingMain_button": "recargando la página.", "clearCanvasMessage": "Si la recarga no funciona, intente <button>limpiando el lienzo.</button>",
"clearCanvasMessage": "Si la recarga no funciona, intente ",
"clearCanvasMessage_button": "limpiando el lienzo.",
"clearCanvasCaveat": " Esto provocará la pérdida de su trabajo ", "clearCanvasCaveat": " Esto provocará la pérdida de su trabajo ",
"trackedToSentry_pre": "El error con el identificador ", "trackedToSentry": "El error con el identificador {{eventId}} fue rastreado en nuestro sistema.",
"trackedToSentry_post": " fue rastreado en nuestro sistema.", "openIssueMessage": "Fuimos muy cautelosos de no incluir la información de tu escena en el error. Si tu escena no es privada, por favor considera seguir nuestro <button>rastreador de errores.</button> Por favor, incluya la siguiente información copiándola y pegándola en el issue de GitHub.",
"openIssueMessage_pre": "Fuimos muy cautelosos de no incluir la información de tu escena en el error. Si tu escena no es privada, por favor considera seguir nuestro ",
"openIssueMessage_button": "rastreador de errores.",
"openIssueMessage_post": " Por favor, incluya la siguiente información copiándola y pegándola en el issue de GitHub.",
"sceneContent": "Contenido de la escena:" "sceneContent": "Contenido de la escena:"
}, },
"roomDialog": { "roomDialog": {
@@ -319,8 +306,8 @@
"doubleClick": "doble clic", "doubleClick": "doble clic",
"drag": "arrastrar", "drag": "arrastrar",
"editor": "Editor", "editor": "Editor",
"editLineArrowPoints": "", "editLineArrowPoints": "Editar puntos de línea/flecha",
"editText": "", "editText": "Editar texto / añadir etiqueta",
"github": "¿Ha encontrado un problema? Envíelo", "github": "¿Ha encontrado un problema? Envíelo",
"howto": "Siga nuestras guías", "howto": "Siga nuestras guías",
"or": "o", "or": "o",
@@ -361,29 +348,16 @@
"required": "Requerido", "required": "Requerido",
"website": "Introduce una URL válida" "website": "Introduce una URL válida"
}, },
"noteDescription": { "noteDescription": "Envía tu biblioteca para ser incluida en el <link>repositorio de librería pública</link>para que otras personas utilicen en sus dibujos.",
"pre": "Envía tu biblioteca para ser incluida en el ", "noteGuidelines": "La biblioteca debe ser aprobada manualmente primero. Por favor, lea la <link>pautas</link> antes de enviar. Necesitará una cuenta de GitHub para comunicarse y hacer cambios si se solicita, pero no es estrictamente necesario.",
"link": "repositorio de librería pública", "noteLicense": "Al enviar, usted acepta que la biblioteca se publicará bajo el <link>Licencia MIT </link>que en breve significa que cualquiera puede utilizarlos sin restricciones.",
"post": "para que otras personas utilicen en sus dibujos."
},
"noteGuidelines": {
"pre": "La biblioteca debe ser aprobada manualmente primero. Por favor, lea la ",
"link": "pautas",
"post": " antes de enviar. Necesitará una cuenta de GitHub para comunicarse y hacer cambios si se solicita, pero no es estrictamente necesario."
},
"noteLicense": {
"pre": "Al enviar, usted acepta que la biblioteca se publicará bajo el ",
"link": "Licencia MIT ",
"post": "que en breve significa que cualquiera puede utilizarlos sin restricciones."
},
"noteItems": "Cada elemento de la biblioteca debe tener su propio nombre para que sea filtrable. Los siguientes elementos de la biblioteca serán incluidos:", "noteItems": "Cada elemento de la biblioteca debe tener su propio nombre para que sea filtrable. Los siguientes elementos de la biblioteca serán incluidos:",
"atleastOneLibItem": "Por favor, seleccione al menos un elemento de la biblioteca para empezar", "atleastOneLibItem": "Por favor, seleccione al menos un elemento de la biblioteca para empezar",
"republishWarning": "Nota: algunos de los elementos seleccionados están marcados como ya publicados/enviados. Sólo debería volver a enviar elementos cuando se actualice una biblioteca o envío." "republishWarning": "Nota: algunos de los elementos seleccionados están marcados como ya publicados/enviados. Sólo debería volver a enviar elementos cuando se actualice una biblioteca o envío."
}, },
"publishSuccessDialog": { "publishSuccessDialog": {
"title": "Biblioteca enviada", "title": "Biblioteca enviada",
"content": "Gracias {{authorName}}. Su biblioteca ha sido enviada para ser revisada. Puede seguir el estado", "content": "Gracias {{authorName}}. Su biblioteca ha sido enviada para ser revisada. Puede seguir el estado<link>aquí</link>"
"link": "aquí"
}, },
"confirmDialog": { "confirmDialog": {
"resetLibrary": "Reiniciar biblioteca", "resetLibrary": "Reiniciar biblioteca",
+13 -39
View File
@@ -54,6 +54,7 @@
"veryLarge": "Oso handia", "veryLarge": "Oso handia",
"solid": "Solidoa", "solid": "Solidoa",
"hachure": "Itzalduna", "hachure": "Itzalduna",
"zigzag": "",
"crossHatch": "Marraduna", "crossHatch": "Marraduna",
"thin": "Mehea", "thin": "Mehea",
"bold": "Lodia", "bold": "Lodia",
@@ -207,19 +208,10 @@
"collabSaveFailed": "Ezin izan da backend datu-basean gorde. Arazoak jarraitzen badu, zure fitxategia lokalean gorde beharko zenuke zure lana ez duzula galtzen ziurtatzeko.", "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.", "collabSaveFailed_sizeExceeded": "Ezin izan da backend datu-basean gorde, ohiala handiegia dela dirudi. Fitxategia lokalean gorde beharko zenuke zure lana galtzen ez duzula ziurtatzeko.",
"brave_measure_text_error": { "brave_measure_text_error": {
"start": "Brave nabigatzailea erabiltzen ari zarela dirudi", "line1": "",
"aggressive_block_fingerprint": "Aggressively Block Fingerprinting", "line2": "",
"setting_enabled": "ezarpena gaituta", "line3": "",
"break": "Honek honen haustea eragin dezake", "line4": ""
"text_elements": "Testu-elementuak",
"in_your_drawings": "zure marrazkietan",
"strongly_recommend": "Ezarpen hau desgaitzea gomendatzen dugu. Jarrai dezakezu",
"steps": "urrats hauek",
"how": "jakiteko nola egin",
"disable_setting": " Ezarpen hau desgaitzeak testu-elementuen bistaratzea konpontzen ez badu, ireki",
"issue": "eskaera (issue) bat",
"write": "gure Github-en edo idatz iezaguzu",
"discord": "Discord-en"
} }
}, },
"toolBar": { "toolBar": {
@@ -272,16 +264,11 @@
"canvasTooBigTip": "Aholkua: saiatu urrunen dauden elementuak pixka bat hurbiltzen." "canvasTooBigTip": "Aholkua: saiatu urrunen dauden elementuak pixka bat hurbiltzen."
}, },
"errorSplash": { "errorSplash": {
"headingMain_pre": "Errore bat aurkitu da. Saiatu ", "headingMain": "Errore bat aurkitu da. Saiatu <button>orria birkargatzen.</button>",
"headingMain_button": "orria birkargatzen.", "clearCanvasMessage": "Birkargatzea ez bada burutzen, saiatu <button>oihala garbitzen.</button>",
"clearCanvasMessage": "Birkargatzea ez bada burutzen, saiatu ",
"clearCanvasMessage_button": "oihala garbitzen.",
"clearCanvasCaveat": " Honen ondorioz lana galduko da ", "clearCanvasCaveat": " Honen ondorioz lana galduko da ",
"trackedToSentry_pre": "Identifikatzailearen errorea ", "trackedToSentry": "Identifikatzailearen errorea {{eventId}} gure sistemak behatu du.",
"trackedToSentry_post": " gure sistemak behatu du.", "openIssueMessage": "Oso kontuz ibili gara zure eszenaren informazioa errorean ez sartzeko. Zure eszena pribatua ez bada, kontuan hartu gure <button>erroreen jarraipena egitea.</button> Sartu beheko informazioa kopiatu eta itsatsi bidez GitHub issue-n.",
"openIssueMessage_pre": "Oso kontuz ibili gara zure eszenaren informazioa errorean ez sartzeko. Zure eszena pribatua ez bada, kontuan hartu gure ",
"openIssueMessage_button": "erroreen jarraipena egitea.",
"openIssueMessage_post": " Sartu beheko informazioa kopiatu eta itsatsi bidez GitHub issue-n.",
"sceneContent": "Eszenaren edukia:" "sceneContent": "Eszenaren edukia:"
}, },
"roomDialog": { "roomDialog": {
@@ -361,29 +348,16 @@
"required": "Beharrezkoa", "required": "Beharrezkoa",
"website": "Sartu baliozko URL bat" "website": "Sartu baliozko URL bat"
}, },
"noteDescription": { "noteDescription": "Bidali zure liburutegira sartu ahal izateko <link>zure liburutegiko biltegian</link>beste jendeak bere marrazkietan erabili ahal izateko.",
"pre": "Bidali zure liburutegira sartu ahal izateko ", "noteGuidelines": "Liburutegia eskuz onartu behar da. Irakurri <link>gidalerroak</link> bidali aurretik. GitHub kontu bat edukitzea komeni da komunikatzeko eta aldaketak egin ahal izateko, baina ez da guztiz beharrezkoa.",
"link": "zure liburutegiko biltegian", "noteLicense": "Bidaltzen baduzu, onartzen duzu liburutegia <link>MIT lizentziarekin argitaratuko dela, </link>zeinak, laburbilduz, esan nahi du edozeinek erabiltzen ahal duela murrizketarik gabe.",
"post": "beste jendeak bere marrazkietan erabili ahal izateko."
},
"noteGuidelines": {
"pre": "Liburutegia eskuz onartu behar da. Irakurri ",
"link": "gidalerroak",
"post": " bidali aurretik. GitHub kontu bat edukitzea komeni da komunikatzeko eta aldaketak egin ahal izateko, baina ez da guztiz beharrezkoa."
},
"noteLicense": {
"pre": "Bidaltzen baduzu, onartzen duzu liburutegia ",
"link": "MIT lizentziarekin argitaratuko dela, ",
"post": "zeinak, laburbilduz, esan nahi du edozeinek erabiltzen ahal duela murrizketarik gabe."
},
"noteItems": "Liburutegiko elementu bakoitzak bere izena eduki behar du iragazi ahal izateko. Liburutegiko hurrengo elementuak barne daude:", "noteItems": "Liburutegiko elementu bakoitzak bere izena eduki behar du iragazi ahal izateko. Liburutegiko hurrengo elementuak barne daude:",
"atleastOneLibItem": "Hautatu gutxienez liburutegiko elementu bat gutxienez hasi ahal izateko", "atleastOneLibItem": "Hautatu gutxienez liburutegiko elementu bat gutxienez hasi ahal izateko",
"republishWarning": "Oharra: hautatutako elementu batzuk dagoeneko argitaratuta/bidalita bezala markatuta daude. Elementuak berriro bidali behar dituzu lehendik dagoen liburutegi edo bidalketa eguneratzen duzunean." "republishWarning": "Oharra: hautatutako elementu batzuk dagoeneko argitaratuta/bidalita bezala markatuta daude. Elementuak berriro bidali behar dituzu lehendik dagoen liburutegi edo bidalketa eguneratzen duzunean."
}, },
"publishSuccessDialog": { "publishSuccessDialog": {
"title": "Liburutegia bidali da", "title": "Liburutegia bidali da",
"content": "Eskerrik asko {{authorName}}. Zure liburutegia bidali da berrikustera. Jarraitu dezakezu haren egoera", "content": "Eskerrik asko {{authorName}}. Zure liburutegia bidali da berrikustera. Jarraitu dezakezu haren egoera<link>hemen</link>"
"link": "hemen"
}, },
"confirmDialog": { "confirmDialog": {
"resetLibrary": "Leheneratu liburutegia", "resetLibrary": "Leheneratu liburutegia",
+26 -34
View File
@@ -1,7 +1,7 @@
{ {
"labels": { "labels": {
"paste": "جای گذاری", "paste": "جای گذاری",
"pasteAsPlaintext": "", "pasteAsPlaintext": "جای‌گذاری به عنوان متن ساده",
"pasteCharts": "قراردادن نمودارها", "pasteCharts": "قراردادن نمودارها",
"selectAll": "انتخاب همه", "selectAll": "انتخاب همه",
"multiSelect": "یک ایتم به انتخاب شده ها اضافه کنید.", "multiSelect": "یک ایتم به انتخاب شده ها اضافه کنید.",
@@ -54,6 +54,7 @@
"veryLarge": "بسیار بزرگ", "veryLarge": "بسیار بزرگ",
"solid": "توپر", "solid": "توپر",
"hachure": "هاشور", "hachure": "هاشور",
"zigzag": "زیگزاگ",
"crossHatch": "هاشور متقاطع", "crossHatch": "هاشور متقاطع",
"thin": "نازک", "thin": "نازک",
"bold": "ضخیم", "bold": "ضخیم",
@@ -110,7 +111,7 @@
"increaseFontSize": "افزایش دادن اندازه فونت", "increaseFontSize": "افزایش دادن اندازه فونت",
"unbindText": "بازکردن نوشته", "unbindText": "بازکردن نوشته",
"bindText": "بستن نوشته", "bindText": "بستن نوشته",
"createContainerFromText": "", "createContainerFromText": "متن را در یک جایگاه بپیچید",
"link": { "link": {
"edit": "ویرایش لینک", "edit": "ویرایش لینک",
"create": "ایجاد پیوند", "create": "ایجاد پیوند",
@@ -194,7 +195,7 @@
"resetLibrary": "ین کار کل صفحه را پاک میکند. آیا مطمئنید?", "resetLibrary": "ین کار کل صفحه را پاک میکند. آیا مطمئنید?",
"removeItemsFromsLibrary": "حذف {{count}} آیتم(ها) از کتابخانه?", "removeItemsFromsLibrary": "حذف {{count}} آیتم(ها) از کتابخانه?",
"invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است.", "invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است.",
"collabOfflineWarning": "" "collabOfflineWarning": "اتصال به اینترنت در دسترس نیست.\nتغییرات شما ذخیره نمی شود!"
}, },
"errors": { "errors": {
"unsupportedFileType": "نوع فایل پشتیبانی نشده.", "unsupportedFileType": "نوع فایل پشتیبانی نشده.",
@@ -204,22 +205,13 @@
"invalidSVGString": "SVG نادرست.", "invalidSVGString": "SVG نادرست.",
"cannotResolveCollabServer": "به سرور collab متصل نشد. لطفا صفحه را مجددا بارگذاری کنید و دوباره تلاش کنید.", "cannotResolveCollabServer": "به سرور collab متصل نشد. لطفا صفحه را مجددا بارگذاری کنید و دوباره تلاش کنید.",
"importLibraryError": "داده‌ها بارگذاری نشدند", "importLibraryError": "داده‌ها بارگذاری نشدند",
"collabSaveFailed": "", "collabSaveFailed": "در پایگاه داده باطن ذخیره نشد. اگر مشکلات همچنان ادامه داشت، باید فایل خود را به صورت محلی ذخیره کنید تا مطمئن شوید کار خود را از دست نمی دهید.",
"collabSaveFailed_sizeExceeded": "", "collabSaveFailed_sizeExceeded": "در پایگاه داده بکند ذخیره نشد. اگر مشکلات همچنان ادامه داشت، باید فایل خود را به صورت محلی ذخیره کنید تا مطمئن شوید کار خود را از دست نمی دهید.",
"brave_measure_text_error": { "brave_measure_text_error": {
"start": "", "line1": "به نظر می‌رسد از مرورگر Brave با تنظیم <bold> مسدود کردن شدید اثرانگشت</bold> استفاده می‌کنید.",
"aggressive_block_fingerprint": "", "line2": "این می تواند منجر به شکستن <bold>عناصر متن</bold> در نقاشی های شما شود.",
"setting_enabled": "", "line3": "اکیداً توصیه می کنیم این تنظیم را غیرفعال کنید. برای نحوه انجام این کار می‌توانید <link>این مراحل</link> را دنبال کنید.",
"break": "", "line4": "اگر غیرفعال کردن این تنظیم نمایش عناصر متنی را برطرف نکرد، لطفاً یک <issueLink>مشکل</issueLink> را در GitHub ما باز کنید یا برای ما در <discordLink>Discord</discordLink> بنویسید."
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
} }
}, },
"toolBar": { "toolBar": {
@@ -237,7 +229,7 @@
"penMode": "حالت قلم - جلوگیری از تماس", "penMode": "حالت قلم - جلوگیری از تماس",
"link": "افزودن/به‌روزرسانی پیوند برای شکل انتخابی", "link": "افزودن/به‌روزرسانی پیوند برای شکل انتخابی",
"eraser": "پاک کن", "eraser": "پاک کن",
"hand": "" "hand": "دست (ابزار پانینگ)"
}, },
"headings": { "headings": {
"canvasActions": "عملیات روی بوم", "canvasActions": "عملیات روی بوم",
@@ -245,7 +237,7 @@
"shapes": "شکل‌ها" "shapes": "شکل‌ها"
}, },
"hints": { "hints": {
"canvasPanning": "", "canvasPanning": "برای حرکت دادن بوم، چرخ ماوس یا فاصله را در حین کشیدن نگه دارید یا از ابزار دستی استفاده کنید",
"linearElement": "برای چند نقطه کلیک و برای یک خط بکشید", "linearElement": "برای چند نقطه کلیک و برای یک خط بکشید",
"freeDraw": "کلیک کنید و بکشید و وقتی کار تمام شد رها کنید", "freeDraw": "کلیک کنید و بکشید و وقتی کار تمام شد رها کنید",
"text": "نکته: با برنامه انتخاب شده شما میتوانید با دوبار کلیک کردن هرکجا میخواید متن اظاف کنید", "text": "نکته: با برنامه انتخاب شده شما میتوانید با دوبار کلیک کردن هرکجا میخواید متن اظاف کنید",
@@ -256,7 +248,7 @@
"resize": "می توانید با نگه داشتن SHIFT در هنگام تغییر اندازه، نسبت ها را محدود کنید،ALT را برای تغییر اندازه از مرکز نگه دارید", "resize": "می توانید با نگه داشتن SHIFT در هنگام تغییر اندازه، نسبت ها را محدود کنید،ALT را برای تغییر اندازه از مرکز نگه دارید",
"resizeImage": "با نگه داشتن SHIFT می توانید آزادانه اندازه را تغییر دهید،\nبرای تغییر اندازه از مرکز، ALT را نگه دارید", "resizeImage": "با نگه داشتن SHIFT می توانید آزادانه اندازه را تغییر دهید،\nبرای تغییر اندازه از مرکز، ALT را نگه دارید",
"rotate": "با نگه داشتن SHIFT هنگام چرخش می توانید زاویه ها را محدود کنید", "rotate": "با نگه داشتن SHIFT هنگام چرخش می توانید زاویه ها را محدود کنید",
"lineEditor_info": "", "lineEditor_info": "CtrlOrCmd را نگه دارید و دوبار کلیک کنید یا CtrlOrCmd + Enter را فشار دهید تا نقاط را ویرایش کنید.",
"lineEditor_pointSelected": "برای حذف نقطه Delete برای کپی زدن Ctrl یا Cmd+D را بزنید و یا برای جابجایی بکشید", "lineEditor_pointSelected": "برای حذف نقطه Delete برای کپی زدن Ctrl یا Cmd+D را بزنید و یا برای جابجایی بکشید",
"lineEditor_nothingSelected": "یک نقطه را برای ویرایش انتخاب کنید (SHIFT را برای انتخاب چندگانه نگه دارید)،\nیا Alt را نگه دارید و برای افزودن نقاط جدید کلیک کنید", "lineEditor_nothingSelected": "یک نقطه را برای ویرایش انتخاب کنید (SHIFT را برای انتخاب چندگانه نگه دارید)،\nیا Alt را نگه دارید و برای افزودن نقاط جدید کلیک کنید",
"placeImage": "برای قرار دادن تصویر کلیک کنید، یا کلیک کنید و بکشید تا اندازه آن به صورت دستی تنظیم شود", "placeImage": "برای قرار دادن تصویر کلیک کنید، یا کلیک کنید و بکشید تا اندازه آن به صورت دستی تنظیم شود",
@@ -264,7 +256,7 @@
"bindTextToElement": "برای افزودن اینتر را بزنید", "bindTextToElement": "برای افزودن اینتر را بزنید",
"deepBoxSelect": "CtrlOrCmd را برای انتخاب عمیق و جلوگیری از کشیدن نگه دارید", "deepBoxSelect": "CtrlOrCmd را برای انتخاب عمیق و جلوگیری از کشیدن نگه دارید",
"eraserRevert": "Alt را نگه دارید تا عناصر علامت گذاری شده برای حذف برگردند", "eraserRevert": "Alt را نگه دارید تا عناصر علامت گذاری شده برای حذف برگردند",
"firefox_clipboard_write": "" "firefox_clipboard_write": "احتمالاً می‌توان این ویژگی را با تنظیم پرچم «dom.events.asyncClipboard.clipboardItem» روی «true» فعال کرد. برای تغییر پرچم های مرورگر در فایرفاکس، از صفحه \"about:config\" دیدن کنید."
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "پیش نمایش نشان داده نمی شود", "cannotShowPreview": "پیش نمایش نشان داده نمی شود",
@@ -319,8 +311,8 @@
"doubleClick": "دابل کلیک", "doubleClick": "دابل کلیک",
"drag": "کشیدن", "drag": "کشیدن",
"editor": "ویرایشگر", "editor": "ویرایشگر",
"editLineArrowPoints": "", "editLineArrowPoints": "نقاط خط/پیکان را ویرایش کنید",
"editText": "", "editText": "ویرایش متن / افزودن برچسب",
"github": "اشکالی می بینید؟ گزارش دهید", "github": "اشکالی می بینید؟ گزارش دهید",
"howto": "راهنمای ما را دنبال کنید", "howto": "راهنمای ما را دنبال کنید",
"or": "یا", "or": "یا",
@@ -334,8 +326,8 @@
"zoomToFit": "بزرگنمایی برای دیدن تمام آیتم ها", "zoomToFit": "بزرگنمایی برای دیدن تمام آیتم ها",
"zoomToSelection": "بزرگنمایی قسمت انتخاب شده", "zoomToSelection": "بزرگنمایی قسمت انتخاب شده",
"toggleElementLock": "قفل/بازکردن انتخاب شده ها", "toggleElementLock": "قفل/بازکردن انتخاب شده ها",
"movePageUpDown": "", "movePageUpDown": "حرکت صفحه به بالا/پایین",
"movePageLeftRight": "" "movePageLeftRight": "حرکت صفحه به چپ/راست"
}, },
"clearCanvasDialog": { "clearCanvasDialog": {
"title": "پاک کردن بوم" "title": "پاک کردن بوم"
@@ -417,7 +409,7 @@
"fileSavedToFilename": "ذخیره در {filename}", "fileSavedToFilename": "ذخیره در {filename}",
"canvas": "بوم", "canvas": "بوم",
"selection": "انتخاب", "selection": "انتخاب",
"pasteAsSingleElement": "" "pasteAsSingleElement": "از {{shortcut}} برای چسباندن به عنوان یک عنصر استفاده کنید،\nیا در یک ویرایشگر متن موجود جایگذاری کنید"
}, },
"colors": { "colors": {
"ffffff": "سفید", "ffffff": "سفید",
@@ -468,15 +460,15 @@
}, },
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "", "center_heading": "تمام داده های شما به صورت محلی در مرورگر شما ذخیره می شود.",
"center_heading_plus": "", "center_heading_plus": "آیا می‌خواهید به جای آن به Excalidraw+ بروید؟",
"menuHint": "" "menuHint": "خروجی، ترجیحات، زبان ها، ..."
}, },
"defaults": { "defaults": {
"menuHint": "", "menuHint": "خروجی، ترجیحات، وبیشتر ...",
"center_heading": "", "center_heading": "نمودارها .ساخته شده. ساده.",
"toolbarHint": "", "toolbarHint": "ابزاری را انتخاب کنید و نقاشی را شروع کنید!",
"helpHint": "" "helpHint": "میانبرها و راهنما"
} }
} }
} }

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