Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax

This commit is contained in:
Daniel J. Geiger 2023-05-17 14:36:53 -05:00
commit 94f4b727bb
162 changed files with 4683 additions and 4133 deletions

View File

@ -12,24 +12,14 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
excalidraw/excalidraw
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v4 uses: docker/build-push-action@v3
with: with:
context: . context: .
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: excalidraw/excalidraw:latest
labels: ${{ steps.meta.outputs.labels }}

View File

@ -16,7 +16,6 @@ function App() {
className="custom-footer" className="custom-footer"
onClick={() => alert("This is dummy footer")} onClick={() => alert("This is dummy footer")}
> >
{" "}
custom footer custom footer
</button> </button>
</Footer> </Footer>

View File

@ -14,8 +14,7 @@ function App() {
Item1 Item1
</MainMenu.Item> </MainMenu.Item>
<MainMenu.Item onSelect={() => window.alert("Item2")}> <MainMenu.Item onSelect={() => window.alert("Item2")}>
{" "} Item 2
Item 2{" "}
</MainMenu.Item> </MainMenu.Item>
</MainMenu> </MainMenu>
</Excalidraw> </Excalidraw>
@ -93,7 +92,6 @@ function App() {
style={{ height: "2rem" }} style={{ height: "2rem" }}
onClick={() => window.alert("custom menu item")} onClick={() => window.alert("custom menu item")}
> >
{" "}
custom item custom item
</button> </button>
</MainMenu.ItemCustom> </MainMenu.ItemCustom>

View File

@ -3,7 +3,7 @@
## renderTopRightUI ## renderTopRightUI
<pre> <pre>
(isMobile: boolean, appState:{" "} (isMobile: boolean, appState:
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">
AppState AppState
</a> </a>
@ -29,8 +29,7 @@ function App() {
}} }}
onClick={() => window.alert("This is dummy top right UI")} onClick={() => window.alert("This is dummy top right UI")}
> >
{" "} Click me
Click me{" "}
</button> </button>
); );
}} }}
@ -55,8 +54,7 @@ function App() {
<Excalidraw <Excalidraw
renderCustomStats={() => ( renderCustomStats={() => (
<p style={{ color: "#70b1ec", fontWeight: "bold" }}> <p style={{ color: "#70b1ec", fontWeight: "bold" }}>
{" "} Dummy stats will be shown here
Dummy stats will be shown here{" "}
</p> </p>
)} )}
/> />
@ -105,8 +103,7 @@ function App() {
return ( return (
<div style={{ height: "500px" }}> <div style={{ height: "500px" }}>
<button className="custom-button" onClick={() => excalidrawAPI.toggleMenu("customSidebar")}> <button className="custom-button" onClick={() => excalidrawAPI.toggleMenu("customSidebar")}>
{" "} Toggle Custom Sidebar
Toggle Custom Sidebar{" "}
</button> </button>
<Excalidraw <Excalidraw
UIOptions={{ dockedSidebarBreakpoint: 100 }} UIOptions={{ dockedSidebarBreakpoint: 100 }}

View File

@ -20,7 +20,7 @@
}, },
"dependencies": { "dependencies": {
"@excalidraw/extensions": "link:src/packages/extensions", "@excalidraw/extensions": "link:src/packages/extensions",
"@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",
@ -52,7 +52,7 @@
"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": "2.3.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",

View File

@ -151,6 +151,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
@ -167,31 +175,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) -->
@ -245,5 +228,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>

View File

@ -0,0 +1,68 @@
import { Excalidraw } from "../packages/excalidraw/index";
import { queryByTestId, fireEvent } from "@testing-library/react";
import { render } from "../tests/test-utils";
import { Pointer, UI } from "../tests/helpers/ui";
import { API } from "../tests/helpers/api";
const { h } = window;
const mouse = new Pointer("mouse");
describe("element locking", () => {
it("should not show unlockAllElements action in contextMenu if no elements locked", async () => {
await render(<Excalidraw />);
mouse.rightClickAt(0, 0);
const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements");
expect(item).toBe(null);
});
it("should unlock all elements and select them when using unlockAllElements action in contextMenu", async () => {
await render(
<Excalidraw
initialData={{
elements: [
API.createElement({
x: 100,
y: 100,
width: 100,
height: 100,
locked: true,
}),
API.createElement({
x: 100,
y: 100,
width: 100,
height: 100,
locked: true,
}),
API.createElement({
x: 100,
y: 100,
width: 100,
height: 100,
locked: false,
}),
],
}}
/>,
);
mouse.rightClickAt(0, 0);
expect(Object.keys(h.state.selectedElementIds).length).toBe(0);
expect(h.elements.map((el) => el.locked)).toEqual([true, true, false]);
const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements");
expect(item).not.toBe(null);
fireEvent.click(item!.querySelector("button")!);
expect(h.elements.map((el) => el.locked)).toEqual([false, false, false]);
// should select the unlocked elements
expect(h.state.selectedElementIds).toEqual({
[h.elements[0].id]: true,
[h.elements[1].id]: true,
});
});
});

View File

@ -5,8 +5,11 @@ import { getSelectedElements } from "../scene";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { register } from "./register"; import { register } from "./register";
export const actionToggleLock = register({ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
name: "toggleLock", elements.every((el) => !el.locked);
export const actionToggleElementLock = register({
name: "toggleElementLock",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, true); const selectedElements = getSelectedElements(elements, appState, true);
@ -15,20 +18,21 @@ export const actionToggleLock = register({
return false; return false;
} }
const operation = getOperation(selectedElements); const nextLockState = shouldLock(selectedElements);
const selectedElementsMap = arrayToMap(selectedElements); const selectedElementsMap = arrayToMap(selectedElements);
const lock = operation === "lock";
return { return {
elements: elements.map((element) => { elements: elements.map((element) => {
if (!selectedElementsMap.has(element.id)) { if (!selectedElementsMap.has(element.id)) {
return element; return element;
} }
return newElementWith(element, { locked: lock }); return newElementWith(element, { locked: nextLockState });
}), }),
appState: { appState: {
...appState, ...appState,
selectedLinearElement: lock ? null : appState.selectedLinearElement, selectedLinearElement: nextLockState
? null
: appState.selectedLinearElement,
}, },
commitToHistory: true, commitToHistory: true,
}; };
@ -41,7 +45,7 @@ export const actionToggleLock = register({
: "labels.elementLock.lock"; : "labels.elementLock.lock";
} }
return getOperation(selected) === "lock" return shouldLock(selected)
? "labels.elementLock.lockAll" ? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll"; : "labels.elementLock.unlockAll";
}, },
@ -55,6 +59,31 @@ export const actionToggleLock = register({
}, },
}); });
const getOperation = ( export const actionUnlockAllElements = register({
elements: readonly ExcalidrawElement[], name: "unlockAllElements",
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock"); trackEvent: { category: "canvas" },
viewMode: false,
predicate: (elements) => {
return elements.some((element) => element.locked);
},
perform: (elements, appState) => {
const lockedElements = elements.filter((el) => el.locked);
return {
elements: elements.map((element) => {
if (element.locked) {
return newElementWith(element, { locked: false });
}
return element;
}),
appState: {
...appState,
selectedElementIds: Object.fromEntries(
lockedElements.map((el) => [el.id, true]),
),
},
commitToHistory: true,
};
},
contextItemLabel: "labels.elementLock.unlockAll",
});

View File

@ -84,5 +84,5 @@ export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats"; export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText"; export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "../element/Hyperlink"; export { actionLink } from "../element/Hyperlink";
export { actionToggleLock } from "./actionToggleLock"; export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor"; export { actionToggleLinearEditor } from "./actionLinearEditor";

View File

@ -34,7 +34,7 @@ export type ShortcutName =
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical"
| "hyperlink" | "hyperlink"
| "toggleLock" | "toggleElementLock"
> >
| "saveScene" | "saveScene"
| "imageExport"; | "imageExport";
@ -80,7 +80,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
flipVertical: [getShortcutKey("Shift+V")], flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")], viewMode: [getShortcutKey("Alt+R")],
hyperlink: [getShortcutKey("CtrlOrCmd+K")], hyperlink: [getShortcutKey("CtrlOrCmd+K")],
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")], toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
}; };
export type CustomShortcutName = string; export type CustomShortcutName = string;

View File

@ -120,7 +120,8 @@ export type ActionName =
| "unbindText" | "unbindText"
| "hyperlink" | "hyperlink"
| "bindText" | "bindText"
| "toggleLock" | "unlockAllElements"
| "toggleElementLock"
| "toggleLinearEditor" | "toggleLinearEditor"
| "toggleEraserTool" | "toggleEraserTool"
| "toggleHandTool" | "toggleHandTool"

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

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,
@ -152,7 +152,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 },

View File

@ -20,9 +20,13 @@ export const getClientColors = (clientId: string, appState: AppState) => {
}; };
}; };
export const getClientInitials = (userName?: string | null) => { /**
if (!userName?.trim()) { * returns first char, capitalized
return "?"; */
} export const getNameInitial = (name?: string | null) => {
return userName.trim()[0].toUpperCase(); // first char can be a surrogate pair, hence using codePointAt
const firstCodePoint = name?.trim()?.codePointAt(0);
return (
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
).toUpperCase();
}; };

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,
@ -29,19 +29,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"];
}) => { }) => {
@ -217,10 +218,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) => {

View File

@ -33,7 +33,7 @@ import {
actionBindText, actionBindText,
actionUngroup, actionUngroup,
actionLink, actionLink,
actionToggleLock, actionToggleElementLock,
actionToggleLinearEditor, actionToggleLinearEditor,
} from "../actions"; } from "../actions";
import { createRedoAction, createUndoAction } from "../actions/actionHistory"; import { createRedoAction, createUndoAction } from "../actions/actionHistory";
@ -59,6 +59,7 @@ import {
ELEMENT_TRANSLATE_AMOUNT, ELEMENT_TRANSLATE_AMOUNT,
ENV, ENV,
EVENT, EVENT,
EXPORT_IMAGE_TYPES,
GRID_SIZE, GRID_SIZE,
IMAGE_MIME_TYPES, IMAGE_MIME_TYPES,
IMAGE_RENDER_TIMEOUT, IMAGE_RENDER_TIMEOUT,
@ -82,7 +83,7 @@ import {
VERTICAL_ALIGN, VERTICAL_ALIGN,
ZOOM_STEP, ZOOM_STEP,
} from "../constants"; } from "../constants";
import { loadFromBlob } from "../data"; import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restore, restoreElements } from "../data/restore"; import { restore, restoreElements } from "../data/restore";
import { import {
@ -210,6 +211,8 @@ import {
PointerDownState, PointerDownState,
SceneData, SceneData,
Device, Device,
SidebarName,
SidebarTabName,
} from "../types"; } from "../types";
import { import {
debounce, debounce,
@ -235,6 +238,7 @@ import {
getShortcutKey, getShortcutKey,
isTransparent, isTransparent,
easeToValuesRAF, easeToValuesRAF,
muteFSAbortError,
} from "../utils"; } from "../utils";
import { import {
ContextMenu, ContextMenu,
@ -259,6 +263,7 @@ import {
generateIdFromFile, generateIdFromFile,
getDataURL, getDataURL,
getFileFromEvent, getFileFromEvent,
isImageFileHandle,
isSupportedImageFile, isSupportedImageFile,
loadSceneOrLibraryFromBlob, loadSceneOrLibraryFromBlob,
normalizeFile, normalizeFile,
@ -298,6 +303,7 @@ import {
isLocalLink, isLocalLink,
} from "../element/Hyperlink"; } from "../element/Hyperlink";
import { shouldShowBoundingBox } from "../element/transformHandles"; import { shouldShowBoundingBox } from "../element/transformHandles";
import { actionUnlockAllElements } from "../actions/actionElementLock";
import { Fonts } from "../scene/Fonts"; import { Fonts } from "../scene/Fonts";
import { actionPaste } from "../actions/actionClipboard"; import { actionPaste } from "../actions/actionClipboard";
import { import {
@ -309,6 +315,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,
@ -350,6 +359,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);
@ -410,7 +421,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;
@ -448,7 +459,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();
@ -487,7 +498,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);
@ -607,101 +618,92 @@ class App extends React.Component<AppProps, AppState> {
this.props.handleKeyboardGlobally ? undefined : this.onKeyDown this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
} }
> >
<ExcalidrawContainerContext.Provider <AppContext.Provider value={this}>
value={this.excalidrawContainerValue} <AppPropsContext.Provider value={this.props}>
> <ExcalidrawContainerContext.Provider
<DeviceContext.Provider value={this.device}> value={this.excalidrawContainerValue}
<ExcalidrawSetAppStateContext.Provider value={this.setAppState}> >
<ExcalidrawAppStateContext.Provider value={this.state}> <DeviceContext.Provider value={this.device}>
<ExcalidrawElementsContext.Provider <ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
value={this.scene.getNonDeletedElements()} <ExcalidrawAppStateContext.Provider value={this.state}>
> <ExcalidrawElementsContext.Provider
<ExcalidrawActionManagerContext.Provider value={this.scene.getNonDeletedElements()}
value={this.actionManager}
>
<LayerUI
canvas={this.canvas}
appState={this.state}
files={this.files}
setAppState={this.setAppState}
actionManager={this.actionManager}
elements={this.scene.getNonDeletedElements()}
onLockToggle={this.toggleLock}
onPenModeToggle={this.togglePenMode}
onHandToolToggle={this.onHandToolToggle}
onInsertElements={(elements) =>
this.addElementsFromPasteOrLibrary({
elements,
position: "center",
files: null,
})
}
langCode={getLanguage().code}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderCustomSidebar={this.props.renderSidebar}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" &&
this.state.zenModeEnabled
}
libraryReturnUrl={this.props.libraryReturnUrl}
UIOptions={this.props.UIOptions}
focusContainer={this.focusContainer}
library={this.library}
id={this.id}
onImageAction={this.onImageAction}
renderWelcomeScreen={
!this.state.isLoading &&
this.state.showWelcomeScreen &&
this.state.activeTool.type === "selection" &&
!this.scene.getElementsIncludingDeleted().length
}
> >
{this.props.children} <ExcalidrawActionManagerContext.Provider
</LayerUI> value={this.actionManager}
<div className="excalidraw-textEditorContainer" /> >
<div className="excalidraw-contextMenuContainer" /> <LayerUI
{selectedElement.length === 1 && canvas={this.canvas}
!this.state.contextMenu && appState={this.state}
this.state.showHyperlinkPopup && ( files={this.files}
<Hyperlink
key={selectedElement[0].id}
element={selectedElement[0]}
setAppState={this.setAppState} setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen} actionManager={this.actionManager}
/> elements={this.scene.getNonDeletedElements()}
)} onLockToggle={this.toggleLock}
{this.state.toast !== null && ( onPenModeToggle={this.togglePenMode}
<Toast onHandToolToggle={this.onHandToolToggle}
message={this.state.toast.message} langCode={getLanguage().code}
onClose={() => this.setToast(null)} renderTopRightUI={renderTopRightUI}
duration={this.state.toast.duration} renderCustomStats={renderCustomStats}
closable={this.state.toast.closable} showExitZenModeBtn={
/> typeof this.props?.zenModeEnabled === "undefined" &&
)} this.state.zenModeEnabled
{this.state.contextMenu && ( }
<ContextMenu UIOptions={this.props.UIOptions}
items={this.state.contextMenu.items} onImageAction={this.onImageAction}
top={this.state.contextMenu.top} onExportImage={this.onExportImage}
left={this.state.contextMenu.left} renderWelcomeScreen={
actionManager={this.actionManager} !this.state.isLoading &&
/> this.state.showWelcomeScreen &&
)} this.state.activeTool.type === "selection" &&
<main>{this.renderCanvas()}</main> !this.scene.getElementsIncludingDeleted().length
</ExcalidrawActionManagerContext.Provider> }
</ExcalidrawElementsContext.Provider>{" "} >
</ExcalidrawAppStateContext.Provider> {this.props.children}
</ExcalidrawSetAppStateContext.Provider> </LayerUI>
</DeviceContext.Provider> <div className="excalidraw-textEditorContainer" />
</ExcalidrawContainerContext.Provider> <div className="excalidraw-contextMenuContainer" />
{selectedElement.length === 1 &&
!this.state.contextMenu &&
this.state.showHyperlinkPopup && (
<Hyperlink
key={selectedElement[0].id}
element={selectedElement[0]}
setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen}
/>
)}
{this.state.toast !== null && (
<Toast
message={this.state.toast.message}
onClose={() => this.setToast(null)}
duration={this.state.toast.duration}
closable={this.state.toast.closable}
/>
)}
{this.state.contextMenu && (
<ContextMenu
items={this.state.contextMenu.items}
top={this.state.contextMenu.top}
left={this.state.contextMenu.left}
actionManager={this.actionManager}
/>
)}
<main>{this.renderCanvas()}</main>
</ExcalidrawActionManagerContext.Provider>
</ExcalidrawElementsContext.Provider>
</ExcalidrawAppStateContext.Provider>
</ExcalidrawSetAppStateContext.Provider>
</DeviceContext.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 = () => {
@ -712,6 +714,45 @@ 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,
});
};
public onExportImage = async (
type: keyof typeof EXPORT_IMAGE_TYPES,
elements: readonly NonDeletedExcalidrawElement[],
) => {
trackEvent("export", type, "ui");
const fileHandle = await exportCanvas(
type,
elements,
this.state,
this.files,
{
exportBackground: this.state.exportBackground,
name: this.state.name,
viewBackgroundColor: this.state.viewBackgroundColor,
},
)
.catch(muteFSAbortError)
.catch((error) => {
console.error(error);
this.setState({ errorMessage: error.message });
});
if (
this.state.exportEmbedScene &&
fileHandle &&
isImageFileHandle(fileHandle)
) {
this.setState({ fileHandle });
}
};
private syncActionResult = withBatchedUpdates( private syncActionResult = withBatchedUpdates(
(actionResult: ActionResult) => { (actionResult: ActionResult) => {
if (this.unmounted || actionResult === false) { if (this.unmounted || actionResult === false) {
@ -981,7 +1022,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();
} }
@ -1709,7 +1750,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(
@ -2048,30 +2089,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 (force === undefined) {
nextName = this.state.openSidebar?.name === name ? null : name;
} else {
nextName = force ? name : null;
} }
this.setState({ openSidebar: nextName ? { name: nextName, tab } : null });
if (type === "library" || type === "customSidebar") { return !!nextName;
let nextValue;
if (force === undefined) {
nextValue = this.state.openSidebar === type ? null : type;
} else {
nextValue = force ? type : null;
}
this.setState({ openSidebar: nextValue });
return !!nextValue;
}
return false;
}; };
private updateCurrentCursorPosition = withBatchedUpdates( private updateCurrentCursorPosition = withBatchedUpdates(
@ -6406,6 +6441,7 @@ class App extends React.Component<AppProps, AppState> {
copyText, copyText,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionSelectAll, actionSelectAll,
actionUnlockAllElements,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionToggleGridMode, actionToggleGridMode,
actionToggleZenMode, actionToggleZenMode,
@ -6452,7 +6488,7 @@ class App extends React.Component<AppProps, AppState> {
actionToggleLinearEditor, actionToggleLinearEditor,
actionLink, actionLink,
actionDuplicateSelection, actionDuplicateSelection,
actionToggleLock, actionToggleElementLock,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionDeleteSelected, actionDeleteSelected,
]; ];

View File

@ -1,7 +1,7 @@
import "./Avatar.scss"; import "./Avatar.scss";
import React, { useState } from "react"; import React, { useState } from "react";
import { getClientInitials } from "../clients"; import { getNameInitial } from "../clients";
type AvatarProps = { type AvatarProps = {
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void; onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
@ -12,7 +12,7 @@ type AvatarProps = {
}; };
export const Avatar = ({ color, onClick, name, src }: AvatarProps) => { export const Avatar = ({ color, onClick, name, src }: AvatarProps) => {
const shortName = getClientInitials(name); const shortName = getNameInitial(name);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const loadImg = !error && src; const loadImg = !error && src;
const style = loadImg ? undefined : { background: color }; const style = loadImg ? undefined : { background: color };

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
<a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"> i18nKey="errors.brave_measure_text_error.line2"
{" "} bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
{t("errors.brave_measure_text_error.steps")} />
</a>{" "}
{t("errors.brave_measure_text_error.how")}.
</p> </p>
<p> <p>
{t("errors.brave_measure_text_error.disable_setting")}{" "} <Trans
<a href="https://github.com/excalidraw/excalidraw/issues/new"> i18nKey="errors.brave_measure_text_error.line3"
{t("errors.brave_measure_text_error.issue")} link={(el) => (
</a>{" "} <a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
{t("errors.brave_measure_text_error.write")}{" "} {el}
<a href="https://discord.gg/UexuTaE"> </a>
{t("errors.brave_measure_text_error.discord")} )}
</a> />
. </p>
<p>
<Trans
i18nKey="errors.brave_measure_text_error.line4"
issueLink={(el) => (
<a href="https://github.com/excalidraw/excalidraw/issues/new">
{el}
</a>
)}
discordLink={(el) => <a href="https://discord.gg/UexuTaE">{el}.</a>}
/>
</p> </p>
</div> </div>
); );

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}

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"
/> />

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

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

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 {

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

View File

@ -4,18 +4,23 @@ 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 { AppClassProperties, 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,
EXPORT_IMAGE_TYPES,
isFirefox,
} from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem"; 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")!;
@ -64,21 +69,14 @@ const ImageExportModal = ({
elements, elements,
appState, appState,
files, files,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager, actionManager,
onExportToPng, onExportImage,
onExportToSvg,
onExportToClipboard,
}: { }: {
appState: AppState; appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number;
actionManager: ActionManager; actionManager: ActionManager;
onExportToPng: ExportCB; onExportImage: AppClassProperties["onExportImage"];
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
onCloseRequest: () => void;
}) => { }) => {
const someElementIsSelected = isSomeElementSelected(elements, appState); const someElementIsSelected = isSomeElementSelected(elements, appState);
const [exportSelected, setExportSelected] = useState(someElementIsSelected); const [exportSelected, setExportSelected] = useState(someElementIsSelected);
@ -89,10 +87,6 @@ const ImageExportModal = ({
? getSelectedElements(elements, appState, true) ? getSelectedElements(elements, appState, true)
: elements; : elements;
useEffect(() => {
setExportSelected(someElementIsSelected);
}, [someElementIsSelected]);
useEffect(() => { useEffect(() => {
const previewNode = previewRef.current; const previewNode = previewRef.current;
if (!previewNode) { if (!previewNode) {
@ -106,7 +100,7 @@ const ImageExportModal = ({
elements: exportedElements, elements: exportedElements,
appState, appState,
files, files,
exportPadding, exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: maxWidth, maxWidthOrHeight: maxWidth,
}) })
.then((canvas) => { .then((canvas) => {
@ -121,7 +115,7 @@ const ImageExportModal = ({
console.error(error); console.error(error);
setRenderError(error); setRenderError(error);
}); });
}, [appState, files, exportedElements, exportPadding]); }, [appState, files, exportedElements]);
return ( return (
<div className="ExportDialog"> <div className="ExportDialog">
@ -176,7 +170,9 @@ const ImageExportModal = ({
color="indigo" color="indigo"
title={t("buttons.exportToPng")} title={t("buttons.exportToPng")}
aria-label={t("buttons.exportToPng")} aria-label={t("buttons.exportToPng")}
onClick={() => onExportToPng(exportedElements)} onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
}
> >
PNG PNG
</ExportButton> </ExportButton>
@ -184,7 +180,9 @@ const ImageExportModal = ({
color="red" color="red"
title={t("buttons.exportToSvg")} title={t("buttons.exportToSvg")}
aria-label={t("buttons.exportToSvg")} aria-label={t("buttons.exportToSvg")}
onClick={() => onExportToSvg(exportedElements)} onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
}
> >
SVG SVG
</ExportButton> </ExportButton>
@ -193,7 +191,9 @@ const ImageExportModal = ({
{(probablySupportsClipboardBlob || isFirefox) && ( {(probablySupportsClipboardBlob || isFirefox) && (
<ExportButton <ExportButton
title={t("buttons.copyPngToClipboard")} title={t("buttons.copyPngToClipboard")}
onClick={() => onExportToClipboard(exportedElements)} onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
}
color="gray" color="gray"
shade={7} shade={7}
> >
@ -208,45 +208,31 @@ const ImageExportModal = ({
export const ImageExportDialog = ({ export const ImageExportDialog = ({
elements, elements,
appState, appState,
setAppState,
files, files,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager, actionManager,
onExportToPng, onExportImage,
onExportToSvg, onCloseRequest,
onExportToClipboard,
}: { }: {
appState: AppState; appState: UIAppState;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number;
actionManager: ActionManager; actionManager: ActionManager;
onExportToPng: ExportCB; onExportImage: AppClassProperties["onExportImage"];
onExportToSvg: ExportCB; onCloseRequest: () => void;
onExportToClipboard: ExportCB;
}) => { }) => {
const handleClose = React.useCallback(() => { if (appState.openDialog !== "imageExport") {
setAppState({ openDialog: null }); return null;
}, [setAppState]); }
return ( return (
<> <Dialog onCloseRequest={onCloseRequest} title={t("buttons.exportImage")}>
{appState.openDialog === "imageExport" && ( <ImageExportModal
<Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}> elements={elements}
<ImageExportModal appState={appState}
elements={elements} files={files}
appState={appState} actionManager={actionManager}
files={files} onExportImage={onExportImage}
exportPadding={exportPadding} />
actionManager={actionManager} </Dialog>
onExportToPng={onExportToPng}
onExportToSvg={onExportToSvg}
onExportToClipboard={onExportToClipboard}
onCloseRequest={handleClose}
/>
</Dialog>
)}
</>
); );
}; };

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

View File

@ -1,18 +1,23 @@
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 { 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 {
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; AppProps,
import { isShallowEqual, muteFSAbortError } from "../utils"; AppState,
ExcalidrawProps,
BinaryFiles,
UIAppState,
AppClassProperties,
} from "../types";
import { capitalizeString, isShallowEqual } 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 { ImageExportDialog } from "./ImageExportDialog";
import { FixedSideContainer } from "./FixedSideContainer"; import { FixedSideContainer } from "./FixedSideContainer";
import { HintViewer } from "./HintViewer"; import { HintViewer } from "./HintViewer";
import { Island } from "./Island"; import { Island } from "./Island";
@ -24,32 +29,31 @@ 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 { 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,18 +61,13 @@ 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;
onExportImage: AppClassProperties["onExportImage"];
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
children?: React.ReactNode; children?: React.ReactNode;
} }
@ -109,17 +108,12 @@ const LayerUI = ({
onLockToggle, onLockToggle,
onHandToolToggle, onHandToolToggle,
onPenModeToggle, onPenModeToggle,
onInsertElements,
showExitZenModeBtn, showExitZenModeBtn,
renderTopRightUI, renderTopRightUI,
renderCustomStats, renderCustomStats,
renderCustomSidebar,
libraryReturnUrl,
UIOptions, UIOptions,
focusContainer,
library,
id,
onImageAction, onImageAction,
onExportImage,
renderWelcomeScreen, renderWelcomeScreen,
children, children,
}: LayerUIProps) => { }: LayerUIProps) => {
@ -149,46 +143,14 @@ const LayerUI = ({
return null; return null;
} }
const createExporter =
(type: ExportType): ExportCB =>
async (exportedElements) => {
trackEvent("export", type, "ui");
const fileHandle = await exportCanvas(
type,
exportedElements,
appState,
files,
{
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
},
)
.catch(muteFSAbortError)
.catch((error) => {
console.error(error);
setAppState({ errorMessage: error.message });
});
if (
appState.exportEmbedScene &&
fileHandle &&
isImageFileHandle(fileHandle)
) {
setAppState({ fileHandle });
}
};
return ( return (
<ImageExportDialog <ImageExportDialog
elements={elements} elements={elements}
appState={appState} appState={appState}
setAppState={setAppState}
files={files} files={files}
actionManager={actionManager} actionManager={actionManager}
onExportToPng={createExporter("png")} onExportImage={onExportImage}
onExportToSvg={createExporter("svg")} onCloseRequest={() => setAppState({ openDialog: null })}
onExportToClipboard={createExporter("clipboard")}
/> />
); );
}; };
@ -197,8 +159,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 +212,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,9 +286,12 @@ 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>
</FixedSideContainer> </FixedSideContainer>
@ -334,21 +299,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 = (
<> <>
@ -358,8 +323,25 @@ const LayerUI = ({
{children} {children}
{/* render component fallbacks. Can be rendered anywhere as they'll be {/* render component fallbacks. Can be rendered anywhere as they'll be
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 +364,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 +391,6 @@ const LayerUI = ({
renderWelcomeScreen={renderWelcomeScreen} renderWelcomeScreen={renderWelcomeScreen}
/> />
)} )}
{!device.isMobile && ( {!device.isMobile && (
<> <>
<div <div
@ -422,15 +402,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 +432,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 +448,25 @@ const LayerUI = ({
); );
return ( return (
<Provider scope={tunnels.jotaiScope}> <UIAppStateContext.Provider value={appState}>
<TunnelsContext.Provider value={tunnels}> <Provider scope={tunnels.jotaiScope}>
{layerUIJSX} <TunnelsContext.Provider value={tunnels}>
</TunnelsContext.Provider> {layerUIJSX}
</Provider> </TunnelsContext.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 +476,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)
); );
}; };

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

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

View File

@ -1,38 +1,11 @@
@import "open-color/open-color"; @import "open-color/open-color";
.excalidraw { .excalidraw {
.layer-ui__library-sidebar {
display: flex;
flex-direction: column;
}
.layer-ui__library { .layer-ui__library {
display: flex; display: flex;
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 +60,27 @@
} }
} }
.library-menu-browse-button { .library-menu-control-buttons {
margin: 1rem auto; display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
position: relative;
padding: 0.875rem 1rem; &--at-bottom::before {
content: "";
width: calc(100% - 1.5rem);
height: 1px;
position: absolute;
top: -1px;
background: var(--sidebar-border-color);
}
}
.library-menu-browse-button {
flex: 1;
height: var(--lg-button-size);
display: flex; display: flex;
align-items: center; align-items: center;
@ -122,34 +112,39 @@
} }
} }
.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;
} right: 0;
left: initial;
bottom: 100%;
margin-bottom: 0.625rem;
.dropdown-menu-container { .dropdown-menu-container {
--gap: 0;
z-index: 1;
position: absolute;
top: 100%;
left: 0;
:root[dir="rtl"] & {
right: 0;
left: auto;
}
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);
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
} }
} }
.layer-ui__library .library-menu-dropdown-container {
position: relative;
&--in-heading {
padding: 0;
position: absolute;
top: 1rem;
right: 0.75rem;
z-index: 1;
.dropdown-menu {
top: 100%;
}
}
}
} }

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,7 +120,9 @@ export const LibraryMenuContent = ({
theme={appState.theme} theme={appState.theme}
/> />
{showBtn && ( {showBtn && (
<LibraryMenuBrowseButton <LibraryMenuControlButtons
className="library-menu-control-buttons--at-bottom"
style={{ padding: "16px 12px 0 12px" }}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={appState.theme} theme={appState.theme}
@ -168,71 +132,18 @@ export const LibraryMenuContent = ({
); );
}; };
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,69 +152,20 @@ 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 <LibraryMenuContent
__isInternal pendingElements={getSelectedElements(elements, appState, true)}
// necessary to remount when switching between internal onInsertLibraryItems={(libraryItems) => {
// and custom (host app) sidebar, so that the `props.onClose` onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
// 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} onAddToLibrary={deselectItems}
> setAppState={setAppState}
<Sidebar.Header className="layer-ui__library-header"> libraryReturnUrl={appProps.libraryReturnUrl}
<LibraryMenuHeader library={library}
appState={appState} id={id}
setAppState={setAppState} appState={appState}
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={setSelectedItems} onSelectItems={setSelectedItems}
library={library} />
onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems)
}
resetLibrary={resetLibrary}
/>
</Sidebar.Header>
<LibraryMenuContent
pendingElements={getSelectedElements(elements, appState, true)}
onInsertLibraryItems={(libraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
}}
onAddToLibrary={deselectItems}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
id={id}
appState={appState}
selectedItems={selectedItems}
onSelectItems={setSelectedItems}
/>
</Sidebar>
); );
}; };

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 =

View File

@ -0,0 +1,33 @@
import { ExcalidrawProps, UIAppState } from "../types";
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
import clsx from "clsx";
export const LibraryMenuControlButtons = ({
libraryReturnUrl,
theme,
id,
style,
children,
className,
}: {
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: UIAppState["theme"];
id: string;
style: React.CSSProperties;
children?: React.ReactNode;
className?: string;
}) => {
return (
<div
className={clsx("library-menu-control-buttons", className)}
style={style}
>
<LibraryMenuBrowseButton
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
/>
{children}
</div>
);
};

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,29 @@ 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";
import clsx from "clsx";
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;
className?: string;
}> = ({ }> = ({
setAppState, setAppState,
selectedItems, selectedItems,
@ -44,12 +47,14 @@ export const LibraryMenuHeader: React.FC<{
resetLibrary, resetLibrary,
onSelectItems, onSelectItems,
appState, appState,
className,
}) => { }) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom( const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
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 +109,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}
<a link={(el) => (
href={publishLibSuccess?.url} <a
target="_blank" href={publishLibSuccess?.url}
rel="noopener noreferrer" target="_blank"
> rel="noopener noreferrer"
{t("publishSuccessDialog.link")} >
</a> {el}
</a>
)}
/>
</p> </p>
<ToolButton <ToolButton
type="button" type="button"
@ -181,7 +189,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,8 +237,9 @@ export const LibraryMenuHeader: React.FC<{
</DropdownMenu> </DropdownMenu>
); );
}; };
return ( return (
<div style={{ position: "relative" }}> <div className={clsx("library-menu-dropdown-container", className)}>
{renderLibraryMenu()} {renderLibraryMenu()}
{selectedItems.length > 0 && ( {selectedItems.length > 0 && (
<div className="library-actions-counter">{selectedItems.length}</div> <div className="library-actions-counter">{selectedItems.length}</div>
@ -261,3 +269,51 @@ export const LibraryMenuHeader: React.FC<{
</div> </div>
); );
}; };
export const LibraryDropdownMenu = ({
selectedItems,
onSelectItems,
className,
}: {
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
className?: string;
}) => {
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}
className={className}
/>
);
};

View File

@ -26,6 +26,7 @@
} }
.library-menu-items-container { .library-menu-items-container {
width: 100%;
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; flex-shrink: 1;
@ -35,10 +36,14 @@
height: 100%; height: 100%;
justify-content: center; justify-content: center;
margin: 0; margin: 0;
border-bottom: 1px solid var(--sidebar-border-color);
position: relative; position: relative;
& > div {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
&__row { &__row {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
@ -47,7 +52,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;
@ -59,9 +64,12 @@
font-size: 1.125rem; font-size: 1.125rem;
font-weight: bold; font-weight: bold;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
width: 100%;
padding-right: 4rem; // due to dropdown button
box-sizing: border-box;
&--excal { &--excal {
margin-top: 2.5rem; margin-top: 2rem;
} }
} }
@ -75,4 +83,11 @@
color: var(--text-primary-color); color: var(--text-primary-color);
} }
} }
.library-menu-items-private-library-container {
// so that when you toggle between pending item and no items, there's
// no layout shift (this is hardcoded and works only with ENG locale)
min-height: 3.75rem;
width: 100%;
}
} }

View File

@ -2,17 +2,22 @@ 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 { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
import "./LibraryMenuItems.scss";
const CELLS_PER_ROW = 4; const CELLS_PER_ROW = 4;
@ -36,7 +41,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<
@ -201,11 +206,12 @@ const LibraryMenuItems = ({
(item) => item.status === "published", (item) => item.status === "published",
); );
const showBtn = const showBtn = !libraryItems.length && !pendingElements.length;
!libraryItems.length &&
const isLibraryEmpty =
!pendingElements.length &&
!unpublishedItems.length && !unpublishedItems.length &&
!publishedItems.length && !publishedItems.length;
!pendingElements.length;
return ( return (
<div <div
@ -215,9 +221,16 @@ const LibraryMenuItems = ({
unpublishedItems.length || unpublishedItems.length ||
publishedItems.length publishedItems.length
? { justifyContent: "flex-start" } ? { justifyContent: "flex-start" }
: {} : { borderBottom: 0 }
} }
> >
{!isLibraryEmpty && (
<LibraryDropdownMenu
selectedItems={selectedItems}
onSelectItems={onSelectItems}
className="library-menu-dropdown-container--in-heading"
/>
)}
<Stack.Col <Stack.Col
className="library-menu-items-container__items" className="library-menu-items-container__items"
align="start" align="start"
@ -228,51 +241,45 @@ const LibraryMenuItems = ({
}} }}
> >
<> <>
<div> {!isLibraryEmpty && (
{(pendingElements.length > 0 || <div className="library-menu-items-container__header">
unpublishedItems.length > 0 || {t("labels.personalLib")}
publishedItems.length > 0) && ( </div>
<div className="library-menu-items-container__header"> )}
{t("labels.personalLib")} {isLoading && (
</div> <div
)} style={{
{isLoading && ( position: "absolute",
<div top: "var(--container-padding-y)",
style={{ right: "var(--container-padding-x)",
position: "absolute", transform: "translateY(50%)",
top: "var(--container-padding-y)", }}
right: "var(--container-padding-x)", >
transform: "translateY(50%)", <Spinner />
}} </div>
> )}
<Spinner /> <div className="library-menu-items-private-library-container">
{!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items">
<div className="library-menu-items__no-items__label">
{t("library.noItems")}
</div>
<div className="library-menu-items__no-items__hint">
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
</div> </div>
) : (
renderLibrarySection([
// append pending library item
...(pendingElements.length
? [{ id: null, elements: pendingElements }]
: []),
...unpublishedItems,
])
)} )}
</div> </div>
{!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items">
<div
className={clsx({
"library-menu-items__no-items__label": showBtn,
})}
>
{t("library.noItems")}
</div>
<div className="library-menu-items__no-items__hint">
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
</div>
) : (
renderLibrarySection([
// append pending library item
...(pendingElements.length
? [{ id: null, elements: pendingElements }]
: []),
...unpublishedItems,
])
)}
</> </>
<> <>
@ -303,11 +310,17 @@ const LibraryMenuItems = ({
</> </>
{showBtn && ( {showBtn && (
<LibraryMenuBrowseButton <LibraryMenuControlButtons
style={{ padding: "16px 0", width: "100%" }}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={theme} theme={theme}
/> >
<LibraryDropdownMenu
selectedItems={selectedItems}
onSelectItems={onSelectItems}
/>
</LibraryMenuControlButtons>
)} )}
</Stack.Col> </Stack.Col>
</div> </div>

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")}

View File

@ -11,8 +11,10 @@ import {
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";
import { ensureSubtypesLoaded } from "../subtypes"; import { ensureSubtypesLoaded } from "../subtypes";
import { isTextElement } from "../element"; import { isTextElement } from "../element";
@ -108,13 +110,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();
@ -122,7 +123,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,

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
<a i18nKey="publishDialog.noteDescription"
href="https://libraries.excalidraw.com" link={(el) => (
target="_blank" <a
rel="noopener noreferrer" href="https://libraries.excalidraw.com"
> target="_blank"
{t("publishDialog.noteDescription.link")} rel="noopener noreferrer"
</a>{" "} >
{t("publishDialog.noteDescription.post")} {el}
</a>
)}
/>
</div> </div>
<span className="publish-library-note"> <span className="publish-library-note">
{t("publishDialog.noteGuidelines.pre")} <Trans
<a i18nKey="publishDialog.noteGuidelines"
href="https://github.com/excalidraw/excalidraw-libraries#guidelines" link={(el) => (
target="_blank" <a
rel="noopener noreferrer" href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
> target="_blank"
{t("publishDialog.noteGuidelines.link")} rel="noopener noreferrer"
</a> >
{t("publishDialog.noteGuidelines.post")} {el}
</a>
)}
/>
</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
<a i18nKey="publishDialog.noteLicense"
href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE" link={(el) => (
target="_blank" <a
rel="noopener noreferrer" href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
> target="_blank"
{t("publishDialog.noteLicense.link")} rel="noopener noreferrer"
</a> >
{t("publishDialog.noteLicense.post")} {el}
</a>
)}
/>
</span> </span>
</div> </div>
<div className="publish-library__buttons"> <div className="publish-library__buttons">

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,139 @@
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 {
border-radius: var(--border-radius-md);
}
.ToolIcon__icon__close {
.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 { // ---------------------------- sidebar header ------------------------------
.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: 1rem 0.75rem;
border-bottom: 1px solid var(--sidebar-border-color); position: relative;
&::after {
content: "";
width: calc(100% - 1.5rem);
height: 1px;
background: var(--sidebar-border-color);
position: absolute;
bottom: -1px;
}
} }
.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;
[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;
}
}
} }
} }

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,346 +11,321 @@ 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", () => {
it("should render custom sidebar", async () => { describe("General behavior", () => {
const { container } = await render( it("should render custom sidebar", async () => {
<Excalidraw const { container } = await render(
initialData={{ appState: { openSidebar: "customSidebar" } }} <Excalidraw
renderSidebar={() => ( initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
<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 only one sidebar and prefer the custom one", async () => {
const { container } = await render(
<Excalidraw
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
>
<Sidebar name="customSidebar">
<div id="test-sidebar-content">42</div>
</Sidebar>
</Excalidraw>,
);
await waitFor(() => {
// make sure the custom sidebar is rendered
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
// make sure only one sidebar is rendered
const sidebars = container.querySelectorAll(".sidebar");
expect(sidebars.length).toBe(1);
});
});
it("should toggle sidebar using props.toggleMenu()", async () => {
const { container } = await render(
<Excalidraw>
<Sidebar name="customSidebar">
<div id="test-sidebar-content">42</div>
</Sidebar>
</Excalidraw>,
);
// sidebar isn't rendered initially
// -------------------------------------------------------------------------
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// toggle sidebar on
// -------------------------------------------------------------------------
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
});
// toggle sidebar off
// -------------------------------------------------------------------------
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// force-toggle sidebar off (=> still hidden)
// -------------------------------------------------------------------------
expect(
window.h.app.toggleSidebar({ name: "customSidebar", force: false }),
).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// force-toggle sidebar on
// -------------------------------------------------------------------------
expect(
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
).toBe(true);
expect(
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
});
// toggle library (= hide custom sidebar)
// -------------------------------------------------------------------------
expect(window.h.app.toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(
true,
);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
// make sure only one sidebar is rendered
const sidebars = container.querySelectorAll(".sidebar");
expect(sidebars.length).toBe(1);
});
});
}); });
it("should render custom sidebar header", async () => { describe("<Sidebar.Header/>", () => {
const { container } = await render( it("should render custom sidebar header", async () => {
<Excalidraw const { container } = await render(
initialData={{ appState: { openSidebar: "customSidebar" } }} <Excalidraw
renderSidebar={() => ( initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
<Sidebar> >
<Sidebar name="customSidebar">
<Sidebar.Header> <Sidebar.Header>
<div id="test-sidebar-header-content">42</div> <div id="test-sidebar-header-content">42</div>
</Sidebar.Header> </Sidebar.Header>
</Sidebar> </Sidebar>
)} </Excalidraw>,
/>, );
);
const node = container.querySelector("#test-sidebar-header-content"); 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 () => {
const { container } = await render(
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar>
<div id="test-sidebar-content">42</div>
</Sidebar>
)}
/>,
);
await waitFor(() => {
// make sure the custom sidebar is rendered
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null); expect(node).not.toBe(null);
// make sure we don't render the default fallback header,
// make sure only one sidebar is rendered // just the custom one
const sidebars = container.querySelectorAll(".layer-ui__sidebar"); expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
expect(sidebars.length).toBe(1);
}); });
});
it("should always render custom sidebar with close button & close on click", async () => { it("should not render <Sidebar.Header> for custom sidebars by default", async () => {
const onClose = jest.fn(); const CustomExcalidraw = () => {
const CustomExcalidraw = () => { return (
return ( <Excalidraw
<Excalidraw initialData={{
initialData={{ appState: { openSidebar: "customSidebar" } }} appState: { openSidebar: { name: "customSidebar" } },
renderSidebar={() => ( }}
<Sidebar className="test-sidebar" onClose={onClose}> >
<Sidebar name="customSidebar" className="test-sidebar">
hello hello
</Sidebar> </Sidebar>
)} </Excalidraw>
/> );
); };
};
const { container } = await render(<CustomExcalidraw />); 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"); const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null); expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-dock"); const closeButton = queryByTestId(sidebar!, "sidebar-close");
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); expect(closeButton).toBe(null);
}); });
});
it("should support controlled docking", async () => { it("<Sidebar.Header> should render close button", async () => {
let _setDockable: (dockable: boolean) => void = null!; const onStateChange = jest.fn();
const CustomExcalidraw = () => {
const CustomExcalidraw = () => { return (
const [dockable, setDockable] = React.useState(false); <Excalidraw
_setDockable = setDockable; initialData={{
return ( appState: { openSidebar: { name: "customSidebar" } },
<Excalidraw }}
initialData={{ appState: { openSidebar: "customSidebar" } }} >
renderSidebar={() => (
<Sidebar <Sidebar
name="customSidebar"
className="test-sidebar" className="test-sidebar"
docked={false} onStateChange={onStateChange}
dockable={dockable}
> >
hello <Sidebar.Header />
</Sidebar> </Sidebar>
)} </Excalidraw>
/> );
); };
};
const { container } = await render(<CustomExcalidraw />); const { container } = await render(<CustomExcalidraw />);
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => { // initial open
// should not show dock button when `dockable` is `false` expect(onStateChange).toHaveBeenCalledWith({ name: "customSidebar" });
// -------------------------------------------------------------------------
act(() => { const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
_setDockable(false); expect(sidebar).not.toBe(null);
}); const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
expect(closeButton).not.toBe(null);
fireEvent.click(closeButton);
await waitFor(() => { await waitFor(() => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar"); expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(
expect(sidebar).not.toBe(null); null,
const closeButton = queryByTestId(sidebar!, "sidebar-dock"); );
expect(closeButton).toBe(null); expect(onStateChange).toHaveBeenCalledWith(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 () => { describe("Docking behavior", () => {
let _setDocked: (docked?: boolean) => void = null!; it("shouldn't be user-dockable if `onDock` not supplied", async () => {
await assertExcalidrawWithSidebar(
<Sidebar name="customSidebar">
<Sidebar.Header />
</Sidebar>,
"customSidebar",
async () => {
await assertSidebarDockButton(false);
},
);
});
const CustomExcalidraw = () => { it("shouldn't be user-dockable if `onDock` not supplied & `docked={true}`", async () => {
const [docked, setDocked] = React.useState<boolean | undefined>(); await assertExcalidrawWithSidebar(
_setDocked = setDocked; <Sidebar name="customSidebar" docked={true}>
return ( <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 <Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }} initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
renderSidebar={() => ( >
<Sidebar className="test-sidebar" docked={docked}> <Sidebar
hello name="customSidebar"
</Sidebar> className="test-sidebar"
)} onDock={() => {}}
/> docked
); >
}; <Sidebar.Header />
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 () => {
const { container } = await render(
<Excalidraw
renderSidebar={() => (
<Sidebar>
<div id="test-sidebar-content">42</div>
</Sidebar> </Sidebar>
)} </Excalidraw>,
/>, );
);
// sidebar isn't rendered initially await withExcalidrawDimensions(
// ------------------------------------------------------------------------- { width: 1920, height: 1080 },
await waitFor(() => { async () => {
const node = container.querySelector("#test-sidebar-content"); await assertSidebarDockButton(true);
expect(node).toBe(null); },
);
}); });
// toggle sidebar on it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
// ------------------------------------------------------------------------- await render(
expect(window.h.app.toggleMenu("customSidebar")).toBe(true); <Excalidraw
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
>
<Sidebar
name="customSidebar"
className="test-sidebar"
onDock={() => {}}
>
<Sidebar.Header />
</Sidebar>
</Excalidraw>,
);
await waitFor(() => { await withExcalidrawDimensions(
const node = container.querySelector("#test-sidebar-content"); { width: 1920, height: 1080 },
expect(node).not.toBe(null); async () => {
}); await assertSidebarDockButton(false);
},
// toggle sidebar off );
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar")).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// force-toggle sidebar off (=> still hidden)
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar", false)).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// force-toggle sidebar on
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
});
// toggle library (= hide custom sidebar)
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("library")).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
// make sure only one sidebar is rendered
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
expect(sidebars.length).toBe(1);
}); });
}); });
}); });

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,
export const Sidebar = Object.assign( ) => {
forwardRef( useEffect(() => {
( const listener = (event: MouseEvent) => {
{ if (!ref.current) {
children, return;
onClose,
onDock,
docked,
/** Undocumented, may be removed later. Generally should either be
* `props.docked` or `appState.isSidebarDocked`. Currently serves to
* prevent unwanted animation of the shadow if initially docked. */
//
// NOTE we'll want to remove this after we sort out how to subscribe to
// individual appState properties
initialDockedState = docked,
dockable = true,
className,
__isInternal,
}: SidebarProps<{
// 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>,
) => {
const [hostSidebarCounters, setHostSidebarCounters] = useAtom(
hostSidebarCountersAtom,
jotaiScope,
);
const setAppState = useExcalidrawSetAppState();
const [isDockedFallback, setIsDockedFallback] = useState(
docked ?? initialDockedState ?? false,
);
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 });
};
headerPropsRef.current.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
// rerender. We can't pass down as component props manually because
// the <Sidebar.Header/> can be rendered upsream.
headerPropsRef.current = updateObject(headerPropsRef.current, {
docked: docked ?? isDockedFallback,
dockable,
});
if (hostSidebarCounters.rendered > 0 && __isInternal) {
return null;
} }
return ( if (
<Island event.target instanceof Element &&
className={clsx( (ref.current.contains(event.target) ||
"layer-ui__sidebar", !document.body.contains(event.target))
{ "layer-ui__sidebar--docked": isDockedFallback }, ) {
className, return;
)} }
ref={ref}
> cb(event);
<SidebarPropsContext.Provider value={headerPropsRef.current}> };
<SidebarHeaderComponents.Context> document.addEventListener("pointerdown", listener, false);
<SidebarHeaderComponents.Component __isFallback />
{children} return () => {
</SidebarHeaderComponents.Context> document.removeEventListener("pointerdown", listener);
</SidebarPropsContext.Provider> };
</Island> }, [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,
onDock,
docked,
className,
...rest
}: SidebarProps & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">,
ref: React.ForwardedRef<HTMLDivElement>,
) => {
if (process.env.NODE_ENV === "development" && onDock && docked == null) {
console.warn(
"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();
Header: SidebarHeaderComponents.Component,
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
useLayoutEffect(() => {
setIsSidebarDockedAtom(!!docked);
return () => {
setIsSidebarDockedAtom(false);
};
}, [setIsSidebarDockedAtom, docked]);
const headerPropsRef = useRef<SidebarPropsContextValue>(
{} as SidebarPropsContextValue,
);
headerPropsRef.current.onCloseRequest = () => {
setAppState({ openSidebar: null });
};
headerPropsRef.current.onDock = (isDocked) => onDock?.(isDocked);
// renew the ref object if the following props change since we want to
// rerender. We can't pass down as component props manually because
// the <Sidebar.Header/> can be rendered upstream.
headerPropsRef.current = updateObject(headerPropsRef.current, {
docked,
// explicit prop to rerender on update
shouldRenderDockButton: !!onDock && docked != null,
});
const islandRef = useRef<HTMLDivElement>(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 (
<Island
{...rest}
className={clsx("sidebar", { "sidebar--docked": docked }, className)}
ref={islandRef}
>
<SidebarPropsContext.Provider value={headerPropsRef.current}>
{children}
</SidebarPropsContext.Provider>
</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: SidebarHeader,
TabTriggers: SidebarTabTriggers,
TabTrigger: SidebarTabTrigger,
Tabs: SidebarTabs,
Tab: SidebarTab,
Trigger: SidebarTrigger,
},
);
Sidebar.displayName = "Sidebar";

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 && ( <Tooltip label={t("labels.sidebarLock")}>
<SidebarDockButton <Button
checked={!!props.docked} onSelect={() => props.onDock?.(!props.docked)}
onChange={() => { selected={!!props.docked}
props.onDock?.(!props.docked); className="sidebar__dock"
}} data-testid="sidebar-dock"
/> aria-label={t("labels.sidebarLock")}
)}
{renderCloseButton && (
<button
data-testid="sidebar-close"
className="Sidebar__close-btn"
onClick={props.onClose}
aria-label={t("buttons.close")}
> >
{CloseIcon} {PinIcon}
</button> </Button>
)} </Tooltip>
</div> )}
)} <Button
data-testid="sidebar-close"
className="sidebar__close"
onSelect={props.onCloseRequest}
aria-label={t("buttons.close")}
>
{CloseIcon}
</Button>
</div>
</div> </div>
); );
}; };
const [Context, Component] = withUpstreamOverride(_SidebarHeader); SidebarHeader.displayName = "SidebarHeader";
/** @private */
export const SidebarHeaderComponents = { Context, Component };

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

View File

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

View File

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

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

View File

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

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

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

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"];

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,25 +75,31 @@ 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
<button i18nKey="errorSplash.clearCanvasMessage"
onClick={() => { button={(el) => (
try { <button
localStorage.clear(); onClick={() => {
window.location.reload(); try {
} catch (error: any) { localStorage.clear();
console.error(error); window.location.reload();
} } catch (error: any) {
}} console.error(error);
> }
{t("errorSplash.clearCanvasMessage_button")} }}
</button> >
{el}
</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">

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
src/components/Trans.tsx Normal file
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;

View File

@ -5,59 +5,46 @@ exports[`Test <App/> should show error modal when using brave and measureText AP
data-testid="brave-measure-text-error" data-testid="brave-measure-text-error"
> >
<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 /> This could result in breaking the
<br />
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>
`; `;

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(),
};
}, []);
};

View File

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

View File

@ -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}`,

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)}
/> />

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

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;

View File

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

View File

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

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) {

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

View File

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

View File

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

View File

@ -131,6 +131,12 @@ export const MIME_TYPES = {
...IMAGE_MIME_TYPES, ...IMAGE_MIME_TYPES,
} as const; } as const;
export const EXPORT_IMAGE_TYPES = {
png: "png",
svg: "svg",
clipboard: "clipboard",
} as const;
export const EXPORT_DATA_TYPES = { export const EXPORT_DATA_TYPES = {
excalidraw: "excalidraw", excalidraw: "excalidraw",
excalidrawClipboard: "excalidraw/clipboard", excalidrawClipboard: "excalidraw/clipboard",
@ -275,3 +281,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
src/context/tunnels.ts Normal file
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(),
};
}, []);
};

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

View File

@ -538,6 +538,10 @@
height: 3px; height: 3px;
} }
select::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb); background: var(--scrollbar-thumb);
border-radius: 10px; border-radius: 10px;
@ -567,7 +571,7 @@
border-radius: 0; border-radius: 0;
} }
.library-button { .default-sidebar-trigger {
border: 0; border: 0;
} }
} }

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

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 {

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

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";
@ -432,21 +433,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),
]; ];
}, },
}; };
@ -524,13 +519,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,
}; };
}; };

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 {

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;

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(

View File

@ -294,7 +294,6 @@ export const textWysiwyg = ({
const initialSelectionStart = editable.selectionStart; const initialSelectionStart = editable.selectionStart;
const initialSelectionEnd = editable.selectionEnd; const initialSelectionEnd = editable.selectionEnd;
const initialLength = editable.value.length; const initialLength = editable.value.length;
editable.value = updatedTextElement.originalText;
// restore cursor position after value updated so it doesn't // restore cursor position after value updated so it doesn't
// go to the end of text when container auto expanded // go to the end of text when container auto expanded
@ -418,6 +417,7 @@ export const textWysiwyg = ({
boxSizing: "content-box", boxSizing: "content-box",
...getEditorStyle(element), ...getEditorStyle(element),
}); });
editable.value = element.originalText;
updateWysiwygStyle(); updateWysiwygStyle();
if (onChange) { if (onChange) {

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>({

View File

@ -154,7 +154,7 @@ const RoomDialog = ({
<p> <p>
<span role="img" aria-hidden="true" className="RoomDialog-emoji"> <span role="img" aria-hidden="true" className="RoomDialog-emoji">
{"🔒"} {"🔒"}
</span>{" "} </span>
{t("roomDialog.desc_privacy")} {t("roomDialog.desc_privacy")}
</p> </p>
<p>{t("roomDialog.desc_exitSession")}</p> <p>{t("roomDialog.desc_exitSession")}</p>

View File

@ -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 }) => {

View File

@ -1,7 +1,7 @@
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import React from "react"; import React from "react";
import { appLangCodeAtom } from ".."; import { appLangCodeAtom } from "..";
import { defaultLang, useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { languages } from "../../i18n"; import { languages } from "../../i18n";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
@ -16,9 +16,6 @@ export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
aria-label={t("buttons.selectLanguage")} aria-label={t("buttons.selectLanguage")}
style={style} style={style}
> >
<option key={defaultLang.code} value={defaultLang.code}>
{defaultLang.label}
</option>
{languages.map((lang) => ( {languages.map((lang) => (
<option key={lang.code} value={lang.code}> <option key={lang.code} value={lang.code}>
{lang.label} {lang.label}

View File

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

View File

@ -33,6 +33,7 @@ import {
ExcalidrawImperativeAPI, ExcalidrawImperativeAPI,
BinaryFiles, BinaryFiles,
ExcalidrawInitialDataState, ExcalidrawInitialDataState,
UIAppState,
} from "../types"; } from "../types";
import { import {
debounce, debounce,
@ -555,7 +556,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,
) => { ) => {
@ -586,7 +587,7 @@ const ExcalidrawWrapper = () => {
const renderCustomStats = ( const renderCustomStats = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: UIAppState,
) => { ) => {
return ( return (
<CustomStats <CustomStats

4
src/global.d.ts vendored
View File

@ -19,8 +19,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 {

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(

View File

@ -14,60 +14,61 @@ export interface Language {
export const defaultLang = { code: "en", label: "English" }; export const defaultLang = { code: "en", label: "English" };
const allLanguages: Language[] = [ export const languages: Language[] = [
{ code: "ar-SA", label: "العربية", rtl: true }, defaultLang,
{ code: "bg-BG", label: "Български" }, ...[
{ code: "ca-ES", label: "Català" }, { code: "ar-SA", label: "العربية", rtl: true },
{ code: "cs-CZ", label: "Česky" }, { code: "bg-BG", label: "Български" },
{ code: "de-DE", label: "Deutsch" }, { code: "ca-ES", label: "Català" },
{ code: "el-GR", label: "Ελληνικά" }, { code: "cs-CZ", label: "Česky" },
{ code: "es-ES", label: "Español" }, { code: "de-DE", label: "Deutsch" },
{ code: "eu-ES", label: "Euskara" }, { code: "el-GR", label: "Ελληνικά" },
{ code: "fa-IR", label: "فارسی", rtl: true }, { code: "es-ES", label: "Español" },
{ code: "fi-FI", label: "Suomi" }, { code: "eu-ES", label: "Euskara" },
{ code: "fr-FR", label: "Français" }, { code: "fa-IR", label: "فارسی", rtl: true },
{ code: "gl-ES", label: "Galego" }, { code: "fi-FI", label: "Suomi" },
{ code: "he-IL", label: "עברית", rtl: true }, { code: "fr-FR", label: "Français" },
{ code: "hi-IN", label: "हिन्दी" }, { code: "gl-ES", label: "Galego" },
{ code: "hu-HU", label: "Magyar" }, { code: "he-IL", label: "עברית", rtl: true },
{ code: "id-ID", label: "Bahasa Indonesia" }, { code: "hi-IN", label: "हिन्दी" },
{ code: "it-IT", label: "Italiano" }, { code: "hu-HU", label: "Magyar" },
{ code: "ja-JP", label: "日本語" }, { code: "id-ID", label: "Bahasa Indonesia" },
{ code: "kab-KAB", label: "Taqbaylit" }, { code: "it-IT", label: "Italiano" },
{ code: "kk-KZ", label: "Қазақ тілі" }, { code: "ja-JP", label: "日本語" },
{ code: "ko-KR", label: "한국어" }, { code: "kab-KAB", label: "Taqbaylit" },
{ code: "ku-TR", label: "Kurdî" }, { code: "kk-KZ", label: "Қазақ тілі" },
{ code: "lt-LT", label: "Lietuvių" }, { code: "ko-KR", label: "한국어" },
{ code: "lv-LV", label: "Latviešu" }, { code: "ku-TR", label: "Kurdî" },
{ code: "my-MM", label: "Burmese" }, { code: "lt-LT", label: "Lietuvių" },
{ code: "nb-NO", label: "Norsk bokmål" }, { code: "lv-LV", label: "Latviešu" },
{ code: "nl-NL", label: "Nederlands" }, { code: "my-MM", label: "Burmese" },
{ code: "nn-NO", label: "Norsk nynorsk" }, { code: "nb-NO", label: "Norsk bokmål" },
{ code: "oc-FR", label: "Occitan" }, { code: "nl-NL", label: "Nederlands" },
{ code: "pa-IN", label: "ਪੰਜਾਬੀ" }, { code: "nn-NO", label: "Norsk nynorsk" },
{ code: "pl-PL", label: "Polski" }, { code: "oc-FR", label: "Occitan" },
{ code: "pt-BR", label: "Português Brasileiro" }, { code: "pa-IN", label: "ਪੰਜਾਬੀ" },
{ code: "pt-PT", label: "Português" }, { code: "pl-PL", label: "Polski" },
{ code: "ro-RO", label: "Română" }, { code: "pt-BR", label: "Português Brasileiro" },
{ code: "ru-RU", label: "Русский" }, { code: "pt-PT", label: "Português" },
{ code: "sk-SK", label: "Slovenčina" }, { code: "ro-RO", label: "Română" },
{ code: "sv-SE", label: "Svenska" }, { code: "ru-RU", label: "Русский" },
{ code: "sl-SI", label: "Slovenščina" }, { code: "sk-SK", label: "Slovenčina" },
{ code: "tr-TR", label: "Türkçe" }, { code: "sv-SE", label: "Svenska" },
{ code: "uk-UA", label: "Українська" }, { code: "sl-SI", label: "Slovenščina" },
{ code: "zh-CN", label: "简体中文" }, { code: "tr-TR", label: "Türkçe" },
{ code: "zh-TW", label: "繁體中文" }, { code: "uk-UA", label: "Українська" },
{ code: "vi-VN", label: "Tiếng Việt" }, { code: "zh-CN", label: "简体中文" },
{ code: "mr-IN", label: "मराठी" }, { code: "zh-TW", label: "繁體中文" },
].concat([defaultLang]); { code: "vi-VN", label: "Tiếng Việt" },
{ code: "mr-IN", label: "मराठी" },
export const languages: Language[] = allLanguages ]
.sort((left, right) => (left.label > right.label ? 1 : -1)) .filter(
.filter( (lang) =>
(lang) => (percentages as Record<string, number>)[lang.code] >=
(percentages as Record<string, number>)[lang.code] >= COMPLETION_THRESHOLD,
COMPLETION_THRESHOLD, )
); .sort((left, right) => (left.label > right.label ? 1 : -1)),
];
const TEST_LANG_CODE = "__test__"; const TEST_LANG_CODE = "__test__";
if (process.env.NODE_ENV === ENV.DEVELOPMENT) { if (process.env.NODE_ENV === ENV.DEVELOPMENT) {

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": "",
"headingMain_button": "إعادة تحميل الصفحة.",
"clearCanvasMessage": "إذا لم تعمل إعادة التحميل، حاول مرة أخرى ", "clearCanvasMessage": "إذا لم تعمل إعادة التحميل، حاول مرة أخرى ",
"clearCanvasMessage_button": "مسح اللوحة.",
"clearCanvasCaveat": " هذا سيؤدي إلى فقدان العمل ", "clearCanvasCaveat": " هذا سيؤدي إلى فقدان العمل ",
"trackedToSentry_pre": "الخطأ ", "trackedToSentry": "",
"trackedToSentry_post": " تم تعقبه على نظامنا.", "openIssueMessage": "",
"openIssueMessage_pre": "كنا حذرين جدا لعدم تضمين معلومات المشهد الخاصة بك في الخطأ. إذا لم يكن المشهد خاصًا ، يرجى النظر في متابعة هذا الأمر ",
"openIssueMessage_button": "متعقّب الخلل.",
"openIssueMessage_post": " يرجى تضمين المعلومات أدناة عن طريق نسخ ولصق المشكلة في GitHub.",
"sceneContent": "محتوى المشهد:" "sceneContent": "محتوى المشهد:"
}, },
"roomDialog": { "roomDialog": {
@ -361,29 +348,16 @@
"required": "مطلوب", "required": "مطلوب",
"website": "أدخل عنوان URL صالح" "website": "أدخل عنوان URL صالح"
}, },
"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": "شكرا لك {{authorName}}. لقد تم إرسال مكتبتك للمراجعة. يمكنك تتبع الحالة", "content": "شكرا لك {{authorName}}. لقد تم إرسال مكتبتك للمراجعة. يمكنك تتبع الحالة"
"link": "هنا"
}, },
"confirmDialog": { "confirmDialog": {
"resetLibrary": "إعادة ضبط المكتبة", "resetLibrary": "إعادة ضبط المكتبة",

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": "",

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": "",

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",

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": "",

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": "",

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",

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": "Καθαρισμός βιβλιοθήκης",

View File

@ -209,19 +209,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": {
@ -274,16 +265,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": {
@ -363,29 +349,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",

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",

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",

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": "پیش نمایش نشان داده نمی شود",
@ -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": "",
"openIssueMessage_pre": "ما خیلی محتاط هستیم که اطلاعات شما را در خطا قرار ندهیم. با این حال اگر اطلاعات شما خصوصی نیست لطفا پیگیری کنید ",
"openIssueMessage_button": "پیگیری اشکالات.",
"openIssueMessage_post": " لطفا اطلاعات زیر را با کپی کردن در صفحه مشکلات GitHub بگذارید.",
"sceneContent": "محتوای صحنه:" "sceneContent": "محتوای صحنه:"
}, },
"roomDialog": { "roomDialog": {
@ -319,8 +306,8 @@
"doubleClick": "دابل کلیک", "doubleClick": "دابل کلیک",
"drag": "کشیدن", "drag": "کشیدن",
"editor": "ویرایشگر", "editor": "ویرایشگر",
"editLineArrowPoints": "", "editLineArrowPoints": "نقاط خط/پیکان را ویرایش کنید",
"editText": "", "editText": "ویرایش متن / افزودن برچسب",
"github": "اشکالی می بینید؟ گزارش دهید", "github": "اشکالی می بینید؟ گزارش دهید",
"howto": "راهنمای ما را دنبال کنید", "howto": "راهنمای ما را دنبال کنید",
"or": "یا", "or": "یا",
@ -334,8 +321,8 @@
"zoomToFit": "بزرگنمایی برای دیدن تمام آیتم ها", "zoomToFit": "بزرگنمایی برای دیدن تمام آیتم ها",
"zoomToSelection": "بزرگنمایی قسمت انتخاب شده", "zoomToSelection": "بزرگنمایی قسمت انتخاب شده",
"toggleElementLock": "قفل/بازکردن انتخاب شده ها", "toggleElementLock": "قفل/بازکردن انتخاب شده ها",
"movePageUpDown": "", "movePageUpDown": "حرکت صفحه به بالا/پایین",
"movePageLeftRight": "" "movePageLeftRight": "حرکت صفحه به چپ/راست"
}, },
"clearCanvasDialog": { "clearCanvasDialog": {
"title": "پاک کردن بوم" "title": "پاک کردن بوم"
@ -361,29 +348,16 @@
"required": "لازم", "required": "لازم",
"website": "وارد کردن آدرس درست" "website": "وارد کردن آدرس درست"
}, },
"noteDescription": { "noteDescription": "",
"pre": "کتابخانه خود را ارسال کنید تا در آن گنجانده شود ", "noteGuidelines": "",
"link": "مخزن کتابخانه عمومی", "noteLicense": "",
"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": "اینجا"
}, },
"confirmDialog": { "confirmDialog": {
"resetLibrary": "تنظیم مجدد کتابخانه", "resetLibrary": "تنظیم مجدد کتابخانه",
@ -417,7 +391,7 @@
"fileSavedToFilename": "ذخیره در {filename}", "fileSavedToFilename": "ذخیره در {filename}",
"canvas": "بوم", "canvas": "بوم",
"selection": "انتخاب", "selection": "انتخاب",
"pasteAsSingleElement": "" "pasteAsSingleElement": "از {{shortcut}} برای چسباندن به عنوان یک عنصر استفاده کنید،\nیا در یک ویرایشگر متن موجود جایگذاری کنید"
}, },
"colors": { "colors": {
"ffffff": "سفید", "ffffff": "سفید",
@ -468,15 +442,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