Merge branch 'master' into dwelle/fix-export
# Conflicts: # src/components/LayerUI.tsx # src/packages/excalidraw/index.tsx # src/packages/utils.ts
This commit is contained in:
commit
b71bf2072e
@ -18,7 +18,7 @@
|
|||||||
"@docusaurus/core": "2.2.0",
|
"@docusaurus/core": "2.2.0",
|
||||||
"@docusaurus/preset-classic": "2.2.0",
|
"@docusaurus/preset-classic": "2.2.0",
|
||||||
"@docusaurus/theme-live-codeblock": "2.2.0",
|
"@docusaurus/theme-live-codeblock": "2.2.0",
|
||||||
"@excalidraw/excalidraw": "0.15.0",
|
"@excalidraw/excalidraw": "0.15.2",
|
||||||
"@mdx-js/react": "^1.6.22",
|
"@mdx-js/react": "^1.6.22",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"docusaurus-plugin-sass": "0.2.3",
|
"docusaurus-plugin-sass": "0.2.3",
|
||||||
|
@ -1631,10 +1631,10 @@
|
|||||||
url-loader "^4.1.1"
|
url-loader "^4.1.1"
|
||||||
webpack "^5.73.0"
|
webpack "^5.73.0"
|
||||||
|
|
||||||
"@excalidraw/excalidraw@0.15.0":
|
"@excalidraw/excalidraw@0.15.2":
|
||||||
version "0.15.0"
|
version "0.15.2"
|
||||||
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.0.tgz#47170de8d3ff006e9d09dfede2815682b0d4485b"
|
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.2.tgz#7dba4f6e10c52015a007efb75a9fc1afe598574c"
|
||||||
integrity sha512-PJmh1VcuRHG4l+Zgt9qhezxrJ16tYCZFZ8if5IEfmTL9A/7c5mXxY/qrPTqiGlVC7jYs+ciePXQ0YUDzfOfbzw==
|
integrity sha512-rTI02kgWSTXiUdIkBxt9u/581F3eXcqQgJdIxmz54TFtG3ughoxO5fr4t7Fr2LZIturBPqfocQHGKZ0t2KLKgw==
|
||||||
|
|
||||||
"@hapi/hoek@^9.0.0":
|
"@hapi/hoek@^9.0.0":
|
||||||
version "9.3.0"
|
version "9.3.0"
|
||||||
@ -7159,9 +7159,9 @@ typescript@^4.7.4:
|
|||||||
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
|
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
|
||||||
|
|
||||||
ua-parser-js@^0.7.30:
|
ua-parser-js@^0.7.30:
|
||||||
version "0.7.31"
|
version "0.7.33"
|
||||||
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.31.tgz#649a656b191dffab4f21d5e053e27ca17cbff5c6"
|
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532"
|
||||||
integrity sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==
|
integrity sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw==
|
||||||
|
|
||||||
unescape@^1.0.1:
|
unescape@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dwelle/tunnel-rat": "0.1.1",
|
"@radix-ui/react-tabs": "1.0.2",
|
||||||
"@sentry/browser": "6.2.5",
|
"@sentry/browser": "6.2.5",
|
||||||
"@sentry/integrations": "6.2.5",
|
"@sentry/integrations": "6.2.5",
|
||||||
"@testing-library/jest-dom": "5.16.2",
|
"@testing-library/jest-dom": "5.16.2",
|
||||||
@ -51,7 +51,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",
|
||||||
|
@ -150,6 +150,14 @@
|
|||||||
</script>
|
</script>
|
||||||
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
|
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
|
||||||
|
|
||||||
|
<!-- Fathom - privacy-friendly analytics -->
|
||||||
|
<script
|
||||||
|
src="https://cdn.usefathom.com/script.js"
|
||||||
|
data-site="VMSBUEYA"
|
||||||
|
defer
|
||||||
|
></script>
|
||||||
|
<!-- / Fathom -->
|
||||||
|
|
||||||
<!-- LEGACY GOOGLE ANALYTICS -->
|
<!-- LEGACY GOOGLE ANALYTICS -->
|
||||||
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
||||||
<script
|
<script
|
||||||
@ -166,31 +174,6 @@
|
|||||||
</script>
|
</script>
|
||||||
<% } %>
|
<% } %>
|
||||||
<!-- end LEGACY GOOGLE ANALYTICS -->
|
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||||
|
|
||||||
<!-- Matomo -->
|
|
||||||
<% if (process.env.REACT_APP_MATOMO_URL &&
|
|
||||||
process.env.REACT_APP_MATOMO_SITE_ID &&
|
|
||||||
process.env.REACT_APP_CDN_MATOMO_TRACKER_URL) { %>
|
|
||||||
<script>
|
|
||||||
var _paq = (window._paq = window._paq || []);
|
|
||||||
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
|
|
||||||
_paq.push(["trackPageView"]);
|
|
||||||
_paq.push(["enableLinkTracking"]);
|
|
||||||
(function () {
|
|
||||||
var u = "%REACT_APP_MATOMO_URL%";
|
|
||||||
_paq.push(["setTrackerUrl", u + "matomo.php"]);
|
|
||||||
_paq.push(["setSiteId", "%REACT_APP_MATOMO_SITE_ID%"]);
|
|
||||||
var d = document,
|
|
||||||
g = d.createElement("script"),
|
|
||||||
s = d.getElementsByTagName("script")[0];
|
|
||||||
g.async = true;
|
|
||||||
g.src = "%REACT_APP_CDN_MATOMO_TRACKER_URL%";
|
|
||||||
s.parentNode.insertBefore(g, s);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<% } %>
|
|
||||||
<!-- end Matomo analytics -->
|
|
||||||
|
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||||
@ -244,5 +227,17 @@
|
|||||||
<h1 class="visually-hidden">Excalidraw</h1>
|
<h1 class="visually-hidden">Excalidraw</h1>
|
||||||
</header>
|
</header>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<!-- 100% privacy friendly analytics -->
|
||||||
|
<script
|
||||||
|
async
|
||||||
|
defer
|
||||||
|
src="https://scripts.simpleanalyticscdn.com/latest.js"
|
||||||
|
></script>
|
||||||
|
<noscript
|
||||||
|
><img
|
||||||
|
src="https://queue.simpleanalyticscdn.com/noscript.gif"
|
||||||
|
alt=""
|
||||||
|
referrerpolicy="no-referrer-when-downgrade"
|
||||||
|
/></noscript>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -18,7 +18,7 @@ export const actionCopy = register({
|
|||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState, true);
|
const selectedElements = getSelectedElements(elements, appState, true);
|
||||||
|
|
||||||
copyToClipboard(selectedElements, appState, app.files);
|
copyToClipboard(selectedElements, app.files);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
|
@ -84,7 +84,7 @@ import {
|
|||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { hasStrokeColor } from "../scene/comparisons";
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
@ -314,9 +314,9 @@ export const actionChangeFillStyle = register({
|
|||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
const allElementsZigZag = selectedElements.every(
|
const allElementsZigZag =
|
||||||
(el) => el.fillStyle === "zigzag",
|
selectedElements.length > 0 &&
|
||||||
);
|
selectedElements.every((el) => el.fillStyle === "zigzag");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@ -326,7 +326,9 @@ export const actionChangeFillStyle = register({
|
|||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
value: "hachure",
|
value: "hachure",
|
||||||
text: t("labels.hachure"),
|
text: `${
|
||||||
|
allElementsZigZag ? t("labels.zigzag") : t("labels.hachure")
|
||||||
|
} (${getShortcutKey("Alt-Click")})`,
|
||||||
icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon,
|
icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon,
|
||||||
active: allElementsZigZag ? true : undefined,
|
active: allElementsZigZag ? true : undefined,
|
||||||
},
|
},
|
||||||
|
@ -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);
|
||||||
|
@ -59,7 +59,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,
|
||||||
@ -151,7 +151,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 },
|
||||||
|
@ -2,12 +2,12 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { AppState, BinaryFiles } from "./types";
|
import { BinaryFiles } from "./types";
|
||||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||||
import { isInitializedImageElement } from "./element/typeChecks";
|
import { isInitializedImageElement } from "./element/typeChecks";
|
||||||
import { isPromiseLike } from "./utils";
|
import { isPromiseLike, isTestEnv } from "./utils";
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
@ -55,24 +55,40 @@ const clipboardContainsElements = (
|
|||||||
|
|
||||||
export const copyToClipboard = async (
|
export const copyToClipboard = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
|
||||||
files: BinaryFiles | null,
|
files: BinaryFiles | null,
|
||||||
) => {
|
) => {
|
||||||
|
let foundFile = false;
|
||||||
|
|
||||||
|
const _files = elements.reduce((acc, element) => {
|
||||||
|
if (isInitializedImageElement(element)) {
|
||||||
|
foundFile = true;
|
||||||
|
if (files && files[element.fileId]) {
|
||||||
|
acc[element.fileId] = files[element.fileId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as BinaryFiles);
|
||||||
|
|
||||||
|
if (foundFile && !files) {
|
||||||
|
console.warn(
|
||||||
|
"copyToClipboard: attempting to file element(s) without providing associated `files` object.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// select binded text elements when copying
|
// select binded text elements when copying
|
||||||
const contents: ElementsClipboard = {
|
const contents: ElementsClipboard = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||||
elements,
|
elements,
|
||||||
files: files
|
files: files ? _files : undefined,
|
||||||
? elements.reduce((acc, element) => {
|
|
||||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
|
||||||
acc[element.fileId] = files[element.fileId];
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {} as BinaryFiles)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
const json = JSON.stringify(contents);
|
const json = JSON.stringify(contents);
|
||||||
|
|
||||||
|
if (isTestEnv()) {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
CLIPBOARD = json;
|
CLIPBOARD = json;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
PREFER_APP_CLIPBOARD = false;
|
PREFER_APP_CLIPBOARD = false;
|
||||||
await copyTextToSystemClipboard(json);
|
await copyTextToSystemClipboard(json);
|
||||||
|
@ -60,6 +60,7 @@ import {
|
|||||||
ENV,
|
ENV,
|
||||||
EVENT,
|
EVENT,
|
||||||
GRID_SIZE,
|
GRID_SIZE,
|
||||||
|
IMAGE_MIME_TYPES,
|
||||||
IMAGE_RENDER_TIMEOUT,
|
IMAGE_RENDER_TIMEOUT,
|
||||||
isAndroid,
|
isAndroid,
|
||||||
isBrave,
|
isBrave,
|
||||||
@ -209,6 +210,8 @@ import {
|
|||||||
PointerDownState,
|
PointerDownState,
|
||||||
SceneData,
|
SceneData,
|
||||||
Device,
|
Device,
|
||||||
|
SidebarName,
|
||||||
|
SidebarTabName,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
debounce,
|
debounce,
|
||||||
@ -298,6 +301,9 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
|||||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||||
|
|
||||||
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
|
|
||||||
const deviceContextInitialValue = {
|
const deviceContextInitialValue = {
|
||||||
isSmScreen: false,
|
isSmScreen: false,
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
@ -339,6 +345,8 @@ const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
|
|||||||
);
|
);
|
||||||
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
|
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
|
||||||
|
|
||||||
|
export const useApp = () => useContext(AppContext);
|
||||||
|
export const useAppProps = () => useContext(AppPropsContext);
|
||||||
export const useDevice = () => useContext<Device>(DeviceContext);
|
export const useDevice = () => useContext<Device>(DeviceContext);
|
||||||
export const useExcalidrawContainer = () =>
|
export const useExcalidrawContainer = () =>
|
||||||
useContext(ExcalidrawContainerContext);
|
useContext(ExcalidrawContainerContext);
|
||||||
@ -399,7 +407,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
private nearestScrollableContainer: HTMLElement | Document | undefined;
|
private nearestScrollableContainer: HTMLElement | Document | undefined;
|
||||||
public library: AppClassProperties["library"];
|
public library: AppClassProperties["library"];
|
||||||
public libraryItemsFromStorage: LibraryItems | undefined;
|
public libraryItemsFromStorage: LibraryItems | undefined;
|
||||||
private id: string;
|
public id: string;
|
||||||
private history: History;
|
private history: History;
|
||||||
private excalidrawContainerValue: {
|
private excalidrawContainerValue: {
|
||||||
container: HTMLDivElement | null;
|
container: HTMLDivElement | null;
|
||||||
@ -437,7 +445,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
width: window.innerWidth,
|
width: window.innerWidth,
|
||||||
height: window.innerHeight,
|
height: window.innerHeight,
|
||||||
showHyperlinkPopup: false,
|
showHyperlinkPopup: false,
|
||||||
isSidebarDocked: false,
|
defaultSidebarDockedPreference: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.id = nanoid();
|
this.id = nanoid();
|
||||||
@ -468,7 +476,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
setActiveTool: this.setActiveTool,
|
setActiveTool: this.setActiveTool,
|
||||||
setCursor: this.setCursor,
|
setCursor: this.setCursor,
|
||||||
resetCursor: this.resetCursor,
|
resetCursor: this.resetCursor,
|
||||||
toggleMenu: this.toggleMenu,
|
toggleSidebar: this.toggleSidebar,
|
||||||
} as const;
|
} as const;
|
||||||
if (typeof excalidrawRef === "function") {
|
if (typeof excalidrawRef === "function") {
|
||||||
excalidrawRef(api);
|
excalidrawRef(api);
|
||||||
@ -576,6 +584,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
|
this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<AppContext.Provider value={this}>
|
||||||
|
<AppPropsContext.Provider value={this.props}>
|
||||||
<ExcalidrawContainerContext.Provider
|
<ExcalidrawContainerContext.Provider
|
||||||
value={this.excalidrawContainerValue}
|
value={this.excalidrawContainerValue}
|
||||||
>
|
>
|
||||||
@ -598,26 +608,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
onLockToggle={this.toggleLock}
|
onLockToggle={this.toggleLock}
|
||||||
onPenModeToggle={this.togglePenMode}
|
onPenModeToggle={this.togglePenMode}
|
||||||
onHandToolToggle={this.onHandToolToggle}
|
onHandToolToggle={this.onHandToolToggle}
|
||||||
onInsertElements={(elements) =>
|
|
||||||
this.addElementsFromPasteOrLibrary({
|
|
||||||
elements,
|
|
||||||
position: "center",
|
|
||||||
files: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
langCode={getLanguage().code}
|
langCode={getLanguage().code}
|
||||||
renderTopRightUI={renderTopRightUI}
|
renderTopRightUI={renderTopRightUI}
|
||||||
renderCustomStats={renderCustomStats}
|
renderCustomStats={renderCustomStats}
|
||||||
renderCustomSidebar={this.props.renderSidebar}
|
|
||||||
showExitZenModeBtn={
|
showExitZenModeBtn={
|
||||||
typeof this.props?.zenModeEnabled === "undefined" &&
|
typeof this.props?.zenModeEnabled === "undefined" &&
|
||||||
this.state.zenModeEnabled
|
this.state.zenModeEnabled
|
||||||
}
|
}
|
||||||
libraryReturnUrl={this.props.libraryReturnUrl}
|
|
||||||
UIOptions={this.props.UIOptions}
|
UIOptions={this.props.UIOptions}
|
||||||
focusContainer={this.focusContainer}
|
|
||||||
library={this.library}
|
|
||||||
id={this.id}
|
|
||||||
onImageAction={this.onImageAction}
|
onImageAction={this.onImageAction}
|
||||||
renderWelcomeScreen={
|
renderWelcomeScreen={
|
||||||
!this.state.isLoading &&
|
!this.state.isLoading &&
|
||||||
@ -663,14 +661,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
</ExcalidrawSetAppStateContext.Provider>
|
</ExcalidrawSetAppStateContext.Provider>
|
||||||
</DeviceContext.Provider>
|
</DeviceContext.Provider>
|
||||||
</ExcalidrawContainerContext.Provider>
|
</ExcalidrawContainerContext.Provider>
|
||||||
|
</AppPropsContext.Provider>
|
||||||
|
</AppContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public focusContainer: AppClassProperties["focusContainer"] = () => {
|
public focusContainer: AppClassProperties["focusContainer"] = () => {
|
||||||
if (this.props.autoFocus) {
|
|
||||||
this.excalidrawContainerRef.current?.focus();
|
this.excalidrawContainerRef.current?.focus();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public getSceneElementsIncludingDeleted = () => {
|
public getSceneElementsIncludingDeleted = () => {
|
||||||
@ -681,6 +679,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return this.scene.getNonDeletedElements();
|
return this.scene.getNonDeletedElements();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
|
||||||
|
this.addElementsFromPasteOrLibrary({
|
||||||
|
elements,
|
||||||
|
position: "center",
|
||||||
|
files: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
private syncActionResult = withBatchedUpdates(
|
private syncActionResult = withBatchedUpdates(
|
||||||
(actionResult: ActionResult) => {
|
(actionResult: ActionResult) => {
|
||||||
if (this.unmounted || actionResult === false) {
|
if (this.unmounted || actionResult === false) {
|
||||||
@ -950,7 +956,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.addCallback(this.onSceneUpdated);
|
this.scene.addCallback(this.onSceneUpdated);
|
||||||
this.addEventListeners();
|
this.addEventListeners();
|
||||||
|
|
||||||
if (this.excalidrawContainerRef.current) {
|
if (this.props.autoFocus && this.excalidrawContainerRef.current) {
|
||||||
this.focusContainer();
|
this.focusContainer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1589,6 +1595,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
elements: data.elements,
|
elements: data.elements,
|
||||||
files: data.files || null,
|
files: data.files || null,
|
||||||
position: "cursor",
|
position: "cursor",
|
||||||
|
retainSeed: isPlainPaste,
|
||||||
});
|
});
|
||||||
} else if (data.text) {
|
} else if (data.text) {
|
||||||
this.addTextFromPaste(data.text, isPlainPaste);
|
this.addTextFromPaste(data.text, isPlainPaste);
|
||||||
@ -1602,6 +1609,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
files: BinaryFiles | null;
|
files: BinaryFiles | null;
|
||||||
position: { clientX: number; clientY: number } | "cursor" | "center";
|
position: { clientX: number; clientY: number } | "cursor" | "center";
|
||||||
|
retainSeed?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const elements = restoreElements(opts.elements, null);
|
const elements = restoreElements(opts.elements, null);
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
@ -1639,6 +1647,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
y: element.y + gridY - minY,
|
y: element.y + gridY - minY,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
|
randomizeSeed: !opts.retainSeed,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextElements = [
|
const nextElements = [
|
||||||
@ -1673,7 +1684,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
openSidebar:
|
openSidebar:
|
||||||
this.state.openSidebar &&
|
this.state.openSidebar &&
|
||||||
this.device.canDeviceFitSidebar &&
|
this.device.canDeviceFitSidebar &&
|
||||||
this.state.isSidebarDocked
|
this.state.defaultSidebarDockedPreference
|
||||||
? this.state.openSidebar
|
? this.state.openSidebar
|
||||||
: null,
|
: null,
|
||||||
selectedElementIds: newElements.reduce(
|
selectedElementIds: newElements.reduce(
|
||||||
@ -2011,30 +2022,24 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
/**
|
/**
|
||||||
* @returns whether the menu was toggled on or off
|
* @returns whether the menu was toggled on or off
|
||||||
*/
|
*/
|
||||||
public toggleMenu = (
|
public toggleSidebar = ({
|
||||||
type: "library" | "customSidebar",
|
name,
|
||||||
force?: boolean,
|
tab,
|
||||||
): boolean => {
|
force,
|
||||||
if (type === "customSidebar" && !this.props.renderSidebar) {
|
}: {
|
||||||
console.warn(
|
name: SidebarName;
|
||||||
`attempting to toggle "customSidebar", but no "props.renderSidebar" is defined`,
|
tab?: SidebarTabName;
|
||||||
);
|
force?: boolean;
|
||||||
return false;
|
}): boolean => {
|
||||||
}
|
let nextName;
|
||||||
|
|
||||||
if (type === "library" || type === "customSidebar") {
|
|
||||||
let nextValue;
|
|
||||||
if (force === undefined) {
|
if (force === undefined) {
|
||||||
nextValue = this.state.openSidebar === type ? null : type;
|
nextName = this.state.openSidebar?.name === name ? null : name;
|
||||||
} else {
|
} else {
|
||||||
nextValue = force ? type : null;
|
nextName = force ? name : null;
|
||||||
}
|
}
|
||||||
this.setState({ openSidebar: nextValue });
|
this.setState({ openSidebar: nextName ? { name: nextName, tab } : null });
|
||||||
|
|
||||||
return !!nextValue;
|
return !!nextName;
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private updateCurrentCursorPosition = withBatchedUpdates(
|
private updateCurrentCursorPosition = withBatchedUpdates(
|
||||||
@ -2744,6 +2749,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
containerId: shouldBindToContainer ? container?.id : undefined,
|
containerId: shouldBindToContainer ? container?.id : undefined,
|
||||||
groupIds: container?.groupIds ?? [],
|
groupIds: container?.groupIds ?? [],
|
||||||
lineHeight,
|
lineHeight,
|
||||||
|
angle: container?.angle ?? 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existingTextElement && shouldBindToContainer && container) {
|
if (!existingTextElement && shouldBindToContainer && container) {
|
||||||
@ -4719,7 +4725,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
pointerDownState.drag.hasOccurred = true;
|
pointerDownState.drag.hasOccurred = true;
|
||||||
// prevent dragging even if we're no longer holding cmd/ctrl otherwise
|
// prevent dragging even if we're no longer holding cmd/ctrl otherwise
|
||||||
// it would have weird results (stuff jumping all over the screen)
|
// it would have weird results (stuff jumping all over the screen)
|
||||||
if (selectedElements.length > 0 && !pointerDownState.withCmdOrCtrl) {
|
// Checking for editingElement to avoid jump while editing on mobile #6503
|
||||||
|
if (
|
||||||
|
selectedElements.length > 0 &&
|
||||||
|
!pointerDownState.withCmdOrCtrl &&
|
||||||
|
!this.state.editingElement
|
||||||
|
) {
|
||||||
const [dragX, dragY] = getGridPoint(
|
const [dragX, dragY] = getGridPoint(
|
||||||
pointerCoords.x - pointerDownState.drag.offset.x,
|
pointerCoords.x - pointerDownState.drag.offset.x,
|
||||||
pointerCoords.y - pointerDownState.drag.offset.y,
|
pointerCoords.y - pointerDownState.drag.offset.y,
|
||||||
@ -5742,7 +5753,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
const imageFile = await fileOpen({
|
const imageFile = await fileOpen({
|
||||||
description: "Image",
|
description: "Image",
|
||||||
extensions: ["jpg", "png", "svg", "gif"],
|
extensions: Object.keys(
|
||||||
|
IMAGE_MIME_TYPES,
|
||||||
|
) as (keyof typeof IMAGE_MIME_TYPES)[],
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageElement = this.createImageElement({
|
const imageElement = this.createImageElement({
|
||||||
|
@ -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")}
|
<Trans
|
||||||
<span style={{ fontWeight: 600 }}>
|
i18nKey="errors.brave_measure_text_error.line1"
|
||||||
{t("errors.brave_measure_text_error.aggressive_block_fingerprint")}
|
bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
|
||||||
</span>{" "}
|
/>
|
||||||
{t("errors.brave_measure_text_error.setting_enabled")}.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
{t("errors.brave_measure_text_error.break")}{" "}
|
|
||||||
<span style={{ fontWeight: 600 }}>
|
|
||||||
{t("errors.brave_measure_text_error.text_elements")}
|
|
||||||
</span>{" "}
|
|
||||||
{t("errors.brave_measure_text_error.in_your_drawings")}.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{t("errors.brave_measure_text_error.strongly_recommend")}{" "}
|
<Trans
|
||||||
|
i18nKey="errors.brave_measure_text_error.line2"
|
||||||
|
bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Trans
|
||||||
|
i18nKey="errors.brave_measure_text_error.line3"
|
||||||
|
link={(el) => (
|
||||||
<a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
|
<a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
|
||||||
{" "}
|
{el}
|
||||||
{t("errors.brave_measure_text_error.steps")}
|
</a>
|
||||||
</a>{" "}
|
)}
|
||||||
{t("errors.brave_measure_text_error.how")}.
|
/>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{t("errors.brave_measure_text_error.disable_setting")}{" "}
|
<Trans
|
||||||
|
i18nKey="errors.brave_measure_text_error.line4"
|
||||||
|
issueLink={(el) => (
|
||||||
<a href="https://github.com/excalidraw/excalidraw/issues/new">
|
<a href="https://github.com/excalidraw/excalidraw/issues/new">
|
||||||
{t("errors.brave_measure_text_error.issue")}
|
{el}
|
||||||
</a>{" "}
|
|
||||||
{t("errors.brave_measure_text_error.write")}{" "}
|
|
||||||
<a href="https://discord.gg/UexuTaE">
|
|
||||||
{t("errors.brave_measure_text_error.discord")}
|
|
||||||
</a>
|
</a>
|
||||||
.
|
)}
|
||||||
|
discordLink={(el) => <a href="https://discord.gg/UexuTaE">{el}.</a>}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -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}
|
||||||
|
@ -183,6 +183,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--text-primary-color);
|
color: var(--text-primary-color);
|
||||||
border: 0;
|
border: 0;
|
||||||
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
@ -30,6 +30,7 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 0.2fr;
|
grid-template-columns: 1fr 0.2fr;
|
||||||
|
144
src/components/DefaultSidebar.test.tsx
Normal file
144
src/components/DefaultSidebar.test.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { DEFAULT_SIDEBAR } from "../constants";
|
||||||
|
import { DefaultSidebar } from "../packages/excalidraw/index";
|
||||||
|
import {
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
withExcalidrawDimensions,
|
||||||
|
} from "../tests/test-utils";
|
||||||
|
import {
|
||||||
|
assertExcalidrawWithSidebar,
|
||||||
|
assertSidebarDockButton,
|
||||||
|
} from "./Sidebar/Sidebar.test";
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
|
||||||
|
describe("DefaultSidebar", () => {
|
||||||
|
it("when `docked={undefined}` & `onDock={undefined}`, should allow docking", async () => {
|
||||||
|
await assertExcalidrawWithSidebar(
|
||||||
|
<DefaultSidebar />,
|
||||||
|
DEFAULT_SIDEBAR.name,
|
||||||
|
async () => {
|
||||||
|
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||||
|
|
||||||
|
const { dockButton } = await assertSidebarDockButton(true);
|
||||||
|
|
||||||
|
fireEvent.click(dockButton);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.state.defaultSidebarDockedPreference).toBe(true);
|
||||||
|
expect(dockButton).toHaveClass("selected");
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(dockButton);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||||
|
expect(dockButton).not.toHaveClass("selected");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when `docked={undefined}` & `onDock`, should allow docking", async () => {
|
||||||
|
await assertExcalidrawWithSidebar(
|
||||||
|
<DefaultSidebar onDock={() => {}} />,
|
||||||
|
DEFAULT_SIDEBAR.name,
|
||||||
|
async () => {
|
||||||
|
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||||
|
|
||||||
|
const { dockButton } = await assertSidebarDockButton(true);
|
||||||
|
|
||||||
|
fireEvent.click(dockButton);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.state.defaultSidebarDockedPreference).toBe(true);
|
||||||
|
expect(dockButton).toHaveClass("selected");
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(dockButton);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||||
|
expect(dockButton).not.toHaveClass("selected");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when `docked={true}` & `onDock`, should allow docking", async () => {
|
||||||
|
await assertExcalidrawWithSidebar(
|
||||||
|
<DefaultSidebar onDock={() => {}} />,
|
||||||
|
DEFAULT_SIDEBAR.name,
|
||||||
|
async () => {
|
||||||
|
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||||
|
|
||||||
|
const { dockButton } = await assertSidebarDockButton(true);
|
||||||
|
|
||||||
|
fireEvent.click(dockButton);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.state.defaultSidebarDockedPreference).toBe(true);
|
||||||
|
expect(dockButton).toHaveClass("selected");
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(dockButton);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||||
|
expect(dockButton).not.toHaveClass("selected");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when `onDock={false}`, should disable docking", async () => {
|
||||||
|
await assertExcalidrawWithSidebar(
|
||||||
|
<DefaultSidebar onDock={false} />,
|
||||||
|
DEFAULT_SIDEBAR.name,
|
||||||
|
async () => {
|
||||||
|
await withExcalidrawDimensions(
|
||||||
|
{ width: 1920, height: 1080 },
|
||||||
|
async () => {
|
||||||
|
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||||
|
|
||||||
|
await assertSidebarDockButton(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when `docked={true}` & `onDock={false}`, should force-dock sidebar", async () => {
|
||||||
|
await assertExcalidrawWithSidebar(
|
||||||
|
<DefaultSidebar docked onDock={false} />,
|
||||||
|
DEFAULT_SIDEBAR.name,
|
||||||
|
async () => {
|
||||||
|
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||||
|
|
||||||
|
const { sidebar } = await assertSidebarDockButton(false);
|
||||||
|
expect(sidebar).toHaveClass("sidebar--docked");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when `docked={true}` & `onDock={undefined}`, should force-dock sidebar", async () => {
|
||||||
|
await assertExcalidrawWithSidebar(
|
||||||
|
<DefaultSidebar docked />,
|
||||||
|
DEFAULT_SIDEBAR.name,
|
||||||
|
async () => {
|
||||||
|
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||||
|
|
||||||
|
const { sidebar } = await assertSidebarDockButton(false);
|
||||||
|
expect(sidebar).toHaveClass("sidebar--docked");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when `docked={false}` & `onDock={undefined}`, should force-undock sidebar", async () => {
|
||||||
|
await assertExcalidrawWithSidebar(
|
||||||
|
<DefaultSidebar docked={false} />,
|
||||||
|
DEFAULT_SIDEBAR.name,
|
||||||
|
async () => {
|
||||||
|
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||||
|
|
||||||
|
const { sidebar } = await assertSidebarDockButton(false);
|
||||||
|
expect(sidebar).not.toHaveClass("sidebar--docked");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
118
src/components/DefaultSidebar.tsx
Normal file
118
src/components/DefaultSidebar.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
);
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
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 { exportAsImage } from "../data";
|
import { exportAsImage } from "../data";
|
||||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
@ -9,7 +9,7 @@ import { Language, t } from "../i18n";
|
|||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||||
import { isShallowEqual, muteFSAbortError } from "../utils";
|
import { capitalizeString, isShallowEqual, muteFSAbortError } from "../utils";
|
||||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||||
import { ErrorDialog } from "./ErrorDialog";
|
import { ErrorDialog } from "./ErrorDialog";
|
||||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
||||||
@ -24,28 +24,28 @@ import { Section } from "./Section";
|
|||||||
import { HelpDialog } from "./HelpDialog";
|
import { HelpDialog } from "./HelpDialog";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import Library from "../data/library";
|
|
||||||
import { JSONExportDialog } from "./JSONExportDialog";
|
import { JSONExportDialog } from "./JSONExportDialog";
|
||||||
import { LibraryButton } from "./LibraryButton";
|
|
||||||
import { isImageFileHandle } from "../data/blob";
|
import { isImageFileHandle } from "../data/blob";
|
||||||
import { LibraryMenu } from "./LibraryMenu";
|
|
||||||
|
|
||||||
import "./LayerUI.scss";
|
|
||||||
import "./Toolbar.scss";
|
|
||||||
import { PenModeButton } from "./PenModeButton";
|
import { PenModeButton } from "./PenModeButton";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { useDevice } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
import { Stats } from "./Stats";
|
import { Stats } from "./Stats";
|
||||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||||
import Footer from "./footer/Footer";
|
import Footer from "./footer/Footer";
|
||||||
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
|
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||||
import { jotaiScope } from "../jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
import { Provider, useAtom } from "jotai";
|
import { Provider, useAtomValue } from "jotai";
|
||||||
import MainMenu from "./main-menu/MainMenu";
|
import MainMenu from "./main-menu/MainMenu";
|
||||||
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
||||||
import { HandButton } from "./HandButton";
|
import { HandButton } from "./HandButton";
|
||||||
import { isHandToolActive } from "../appState";
|
import { isHandToolActive } from "../appState";
|
||||||
import { TunnelsContext, useInitializeTunnels } from "./context/tunnels";
|
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
|
||||||
|
import { LibraryIcon } from "./icons";
|
||||||
|
import { UIAppStateContext } from "../context/ui-appState";
|
||||||
|
import { DefaultSidebar } from "./DefaultSidebar";
|
||||||
|
|
||||||
|
import "./LayerUI.scss";
|
||||||
|
import "./Toolbar.scss";
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
@ -57,17 +57,11 @@ interface LayerUIProps {
|
|||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onHandToolToggle: () => void;
|
onHandToolToggle: () => void;
|
||||||
onPenModeToggle: () => void;
|
onPenModeToggle: () => void;
|
||||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
|
||||||
showExitZenModeBtn: boolean;
|
showExitZenModeBtn: boolean;
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||||
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
|
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
|
||||||
UIOptions: AppProps["UIOptions"];
|
UIOptions: AppProps["UIOptions"];
|
||||||
focusContainer: () => void;
|
|
||||||
library: Library;
|
|
||||||
id: string;
|
|
||||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
renderWelcomeScreen: boolean;
|
renderWelcomeScreen: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@ -109,16 +103,10 @@ const LayerUI = ({
|
|||||||
onLockToggle,
|
onLockToggle,
|
||||||
onHandToolToggle,
|
onHandToolToggle,
|
||||||
onPenModeToggle,
|
onPenModeToggle,
|
||||||
onInsertElements,
|
|
||||||
showExitZenModeBtn,
|
showExitZenModeBtn,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderCustomStats,
|
renderCustomStats,
|
||||||
renderCustomSidebar,
|
|
||||||
libraryReturnUrl,
|
|
||||||
UIOptions,
|
UIOptions,
|
||||||
focusContainer,
|
|
||||||
library,
|
|
||||||
id,
|
|
||||||
onImageAction,
|
onImageAction,
|
||||||
renderWelcomeScreen,
|
renderWelcomeScreen,
|
||||||
children,
|
children,
|
||||||
@ -197,8 +185,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 +238,7 @@ const LayerUI = ({
|
|||||||
{(heading: React.ReactNode) => (
|
{(heading: React.ReactNode) => (
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
{renderWelcomeScreen && (
|
{renderWelcomeScreen && (
|
||||||
<tunnels.welcomeScreenToolbarHintTunnel.Out />
|
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
||||||
)}
|
)}
|
||||||
<Stack.Col gap={4} align="start">
|
<Stack.Col gap={4} align="start">
|
||||||
<Stack.Row
|
<Stack.Row
|
||||||
@ -324,8 +312,11 @@ const LayerUI = ({
|
|||||||
>
|
>
|
||||||
<UserList collaborators={appState.collaborators} />
|
<UserList collaborators={appState.collaborators} />
|
||||||
{renderTopRightUI?.(device.isMobile, appState)}
|
{renderTopRightUI?.(device.isMobile, appState)}
|
||||||
{!appState.viewModeEnabled && (
|
{!appState.viewModeEnabled &&
|
||||||
<LibraryButton appState={appState} setAppState={setAppState} />
|
// hide button when sidebar docked
|
||||||
|
(!isSidebarDocked ||
|
||||||
|
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
|
||||||
|
<tunnels.DefaultSidebarTriggerTunnel.Out />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -334,21 +325,21 @@ const LayerUI = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderSidebars = () => {
|
const renderSidebars = () => {
|
||||||
return appState.openSidebar === "customSidebar" ? (
|
return (
|
||||||
renderCustomSidebar?.() || null
|
<DefaultSidebar
|
||||||
) : appState.openSidebar === "library" ? (
|
__fallback
|
||||||
<LibraryMenu
|
onDock={(docked) => {
|
||||||
appState={appState}
|
trackEvent(
|
||||||
onInsertElements={onInsertElements}
|
"sidebar",
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
`toggleDock (${docked ? "dock" : "undock"})`,
|
||||||
focusContainer={focusContainer}
|
`(${device.isMobile ? "mobile" : "desktop"})`,
|
||||||
library={library}
|
);
|
||||||
id={id}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null;
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
|
const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope);
|
||||||
|
|
||||||
const layerUIJSX = (
|
const layerUIJSX = (
|
||||||
<>
|
<>
|
||||||
@ -360,6 +351,23 @@ const LayerUI = ({
|
|||||||
tunneled away. We only render tunneled components that actually
|
tunneled away. We only render tunneled components that actually
|
||||||
have defaults when host do not render anything. */}
|
have defaults when host do not render anything. */}
|
||||||
<DefaultMainMenu UIOptions={UIOptions} />
|
<DefaultMainMenu UIOptions={UIOptions} />
|
||||||
|
<DefaultSidebar.Trigger
|
||||||
|
__fallback
|
||||||
|
icon={LibraryIcon}
|
||||||
|
title={capitalizeString(t("toolBar.library"))}
|
||||||
|
onToggle={(open) => {
|
||||||
|
if (open) {
|
||||||
|
trackEvent(
|
||||||
|
"sidebar",
|
||||||
|
`${DEFAULT_SIDEBAR.name} (open)`,
|
||||||
|
`button (${device.isMobile ? "mobile" : "desktop"})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tab={DEFAULT_SIDEBAR.defaultTab}
|
||||||
|
>
|
||||||
|
{t("toolBar.library")}
|
||||||
|
</DefaultSidebar.Trigger>
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
|
|
||||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||||
@ -382,7 +390,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 +417,6 @@ const LayerUI = ({
|
|||||||
renderWelcomeScreen={renderWelcomeScreen}
|
renderWelcomeScreen={renderWelcomeScreen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!device.isMobile && (
|
{!device.isMobile && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@ -422,15 +428,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}
|
||||||
@ -469,17 +474,22 @@ const LayerUI = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<UIAppStateContext.Provider value={appState}>
|
||||||
<Provider scope={tunnels.jotaiScope}>
|
<Provider scope={tunnels.jotaiScope}>
|
||||||
<TunnelsContext.Provider value={tunnels}>
|
<TunnelsContext.Provider value={tunnels}>
|
||||||
{layerUIJSX}
|
{layerUIJSX}
|
||||||
</TunnelsContext.Provider>
|
</TunnelsContext.Provider>
|
||||||
</Provider>
|
</Provider>
|
||||||
|
</UIAppStateContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stripIrrelevantAppStateProps = (
|
const stripIrrelevantAppStateProps = (
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
): Partial<AppState> => {
|
): Omit<
|
||||||
|
AppState,
|
||||||
|
"suggestedBindings" | "startBoundElement" | "cursorButton"
|
||||||
|
> => {
|
||||||
const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
|
const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
|
||||||
appState;
|
appState;
|
||||||
return ret;
|
return ret;
|
||||||
@ -491,24 +501,17 @@ 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),
|
stripIrrelevantAppStateProps(prevAppState),
|
||||||
stripIrrelevantAppStateProps(nextAppState),
|
stripIrrelevantAppStateProps(nextAppState),
|
||||||
|
{
|
||||||
|
selectedElementIds: isShallowEqual,
|
||||||
|
selectedGroupIds: isShallowEqual,
|
||||||
|
},
|
||||||
) && isShallowEqual(prev, next)
|
) && isShallowEqual(prev, next)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,9 +1,9 @@
|
|||||||
@import "open-color/open-color";
|
@import "open-color/open-color";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.layer-ui__library-sidebar {
|
.library-menu-items-container {
|
||||||
display: flex;
|
height: 100%;
|
||||||
flex-direction: column;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layer-ui__library {
|
.layer-ui__library {
|
||||||
@ -11,28 +11,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|
||||||
.layer-ui__library-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
margin: 2px 0 15px 0;
|
|
||||||
.Spinner {
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
// 2px from the left to account for focus border of left-most button
|
|
||||||
margin: 0 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__sidebar {
|
|
||||||
.library-menu-items-container {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-actions-counter {
|
.library-actions-counter {
|
||||||
@ -87,10 +65,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-menu-browse-button {
|
.library-menu-control-buttons {
|
||||||
margin: 1rem auto;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
padding: 0.875rem 1rem;
|
.library-menu-browse-button {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
height: var(--lg-button-size);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -122,30 +107,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-menu-browse-button--mobile {
|
&.excalidraw--mobile .library-menu-browse-button {
|
||||||
min-height: 22px;
|
height: var(--default-button-size);
|
||||||
margin-left: auto;
|
|
||||||
a {
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.layer-ui__sidebar__header .dropdown-menu {
|
.layer-ui__library .dropdown-menu {
|
||||||
&.dropdown-menu--mobile {
|
width: auto;
|
||||||
top: 100%;
|
top: initial;
|
||||||
}
|
|
||||||
.dropdown-menu-container {
|
|
||||||
--gap: 0;
|
|
||||||
z-index: 1;
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
right: 0;
|
right: 0;
|
||||||
left: auto;
|
left: initial;
|
||||||
}
|
bottom: 100%;
|
||||||
|
margin-bottom: 0.625rem;
|
||||||
|
|
||||||
|
.dropdown-menu-container {
|
||||||
width: 196px;
|
width: 196px;
|
||||||
box-shadow: var(--library-dropdown-shadow);
|
box-shadow: var(--library-dropdown-shadow);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
|
@ -1,11 +1,4 @@
|
|||||||
import {
|
import React, { useState, useCallback } from "react";
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
RefObject,
|
|
||||||
forwardRef,
|
|
||||||
} from "react";
|
|
||||||
import Library, {
|
import Library, {
|
||||||
distributeLibraryItemsOnSquareGrid,
|
distributeLibraryItemsOnSquareGrid,
|
||||||
libraryItemsAtom,
|
libraryItemsAtom,
|
||||||
@ -13,65 +6,29 @@ import 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, LibraryItem, AppState, ExcalidrawProps } from "../types";
|
||||||
|
|
||||||
import "./LibraryMenu.scss";
|
|
||||||
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,
|
||||||
@ -158,81 +115,31 @@ export const LibraryMenuContent = ({
|
|||||||
theme={appState.theme}
|
theme={appState.theme}
|
||||||
/>
|
/>
|
||||||
{showBtn && (
|
{showBtn && (
|
||||||
<LibraryMenuBrowseButton
|
<LibraryMenuControlButtons
|
||||||
|
style={{ padding: "16px 12px 0 12px" }}
|
||||||
id={id}
|
id={id}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
theme={appState.theme}
|
theme={appState.theme}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onSelectItems={onSelectItems}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</LibraryMenuWrapper>
|
</LibraryMenuWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LibraryMenu: React.FC<{
|
/**
|
||||||
appState: AppState;
|
* This component is meant to be rendered inside <Sidebar.Tab/> inside our
|
||||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
* <DefaultSidebar/> or host apps Sidebar components.
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
*/
|
||||||
focusContainer: () => void;
|
export const LibraryMenu = () => {
|
||||||
library: Library;
|
const { library, id, onInsertElements } = useApp();
|
||||||
id: string;
|
const appProps = useAppProps();
|
||||||
}> = ({
|
const appState = useUIAppState();
|
||||||
appState,
|
|
||||||
onInsertElements,
|
|
||||||
libraryReturnUrl,
|
|
||||||
focusContainer,
|
|
||||||
library,
|
|
||||||
id,
|
|
||||||
}) => {
|
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
const elements = useExcalidrawElements();
|
const elements = useExcalidrawElements();
|
||||||
const device = useDevice();
|
|
||||||
|
|
||||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const closeLibrary = useCallback(() => {
|
|
||||||
const isDialogOpen = !!document.querySelector(".Dialog");
|
|
||||||
|
|
||||||
// Prevent closing if any dialog is open
|
|
||||||
if (isDialogOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAppState({ openSidebar: null });
|
|
||||||
}, [setAppState]);
|
|
||||||
|
|
||||||
useOnClickOutside(
|
|
||||||
ref,
|
|
||||||
useCallback(
|
|
||||||
(event) => {
|
|
||||||
// If click on the library icon, do nothing so that LibraryButton
|
|
||||||
// can toggle library menu
|
|
||||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) {
|
|
||||||
closeLibrary();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (
|
|
||||||
event.key === KEYS.ESCAPE &&
|
|
||||||
(!appState.isSidebarDocked || !device.canDeviceFitSidebar)
|
|
||||||
) {
|
|
||||||
closeLibrary();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar]);
|
|
||||||
|
|
||||||
const deselectItems = useCallback(() => {
|
const deselectItems = useCallback(() => {
|
||||||
setAppState({
|
setAppState({
|
||||||
@ -241,55 +148,7 @@ export const LibraryMenu: React.FC<{
|
|||||||
});
|
});
|
||||||
}, [setAppState]);
|
}, [setAppState]);
|
||||||
|
|
||||||
const removeFromLibrary = useCallback(
|
|
||||||
async (libraryItems: LibraryItems) => {
|
|
||||||
const nextItems = libraryItems.filter(
|
|
||||||
(item) => !selectedItems.includes(item.id),
|
|
||||||
);
|
|
||||||
library.setLibrary(nextItems).catch(() => {
|
|
||||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
|
||||||
});
|
|
||||||
setSelectedItems([]);
|
|
||||||
},
|
|
||||||
[library, setAppState, selectedItems, setSelectedItems],
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetLibrary = useCallback(() => {
|
|
||||||
library.resetLibrary();
|
|
||||||
focusContainer();
|
|
||||||
}, [library, focusContainer]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar
|
|
||||||
__isInternal
|
|
||||||
// necessary to remount when switching between internal
|
|
||||||
// and custom (host app) sidebar, so that the `props.onClose`
|
|
||||||
// is colled correctly
|
|
||||||
key="library"
|
|
||||||
className="layer-ui__library-sidebar"
|
|
||||||
initialDockedState={appState.isSidebarDocked}
|
|
||||||
onDock={(docked) => {
|
|
||||||
trackEvent(
|
|
||||||
"library",
|
|
||||||
`toggleLibraryDock (${docked ? "dock" : "undock"})`,
|
|
||||||
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<Sidebar.Header className="layer-ui__library-header">
|
|
||||||
<LibraryMenuHeader
|
|
||||||
appState={appState}
|
|
||||||
setAppState={setAppState}
|
|
||||||
selectedItems={selectedItems}
|
|
||||||
onSelectItems={setSelectedItems}
|
|
||||||
library={library}
|
|
||||||
onRemoveFromLibrary={() =>
|
|
||||||
removeFromLibrary(libraryItemsData.libraryItems)
|
|
||||||
}
|
|
||||||
resetLibrary={resetLibrary}
|
|
||||||
/>
|
|
||||||
</Sidebar.Header>
|
|
||||||
<LibraryMenuContent
|
<LibraryMenuContent
|
||||||
pendingElements={getSelectedElements(elements, appState, true)}
|
pendingElements={getSelectedElements(elements, appState, true)}
|
||||||
onInsertLibraryItems={(libraryItems) => {
|
onInsertLibraryItems={(libraryItems) => {
|
||||||
@ -297,13 +156,12 @@ export const LibraryMenu: React.FC<{
|
|||||||
}}
|
}}
|
||||||
onAddToLibrary={deselectItems}
|
onAddToLibrary={deselectItems}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={appProps.libraryReturnUrl}
|
||||||
library={library}
|
library={library}
|
||||||
id={id}
|
id={id}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
selectedItems={selectedItems}
|
selectedItems={selectedItems}
|
||||||
onSelectItems={setSelectedItems}
|
onSelectItems={setSelectedItems}
|
||||||
/>
|
/>
|
||||||
</Sidebar>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
33
src/components/LibraryMenuControlButtons.tsx
Normal file
33
src/components/LibraryMenuControlButtons.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { LibraryItem, ExcalidrawProps, AppState } from "../types";
|
||||||
|
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
|
||||||
|
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
|
||||||
|
|
||||||
|
export const LibraryMenuControlButtons = ({
|
||||||
|
selectedItems,
|
||||||
|
onSelectItems,
|
||||||
|
libraryReturnUrl,
|
||||||
|
theme,
|
||||||
|
id,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
selectedItems: LibraryItem["id"][];
|
||||||
|
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||||
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
|
theme: AppState["theme"];
|
||||||
|
id: string;
|
||||||
|
style: React.CSSProperties;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="library-menu-control-buttons" style={style}>
|
||||||
|
<LibraryMenuBrowseButton
|
||||||
|
id={id}
|
||||||
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
<LibraryDropdownMenu
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onSelectItems={onSelectItems}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,8 +1,10 @@
|
|||||||
import React, { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { jotaiScope } from "../jotai";
|
||||||
|
import { AppState, LibraryItem, LibraryItems } from "../types";
|
||||||
|
import { useApp, useExcalidrawAppState, 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,22 +15,19 @@ 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);
|
|
||||||
|
|
||||||
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, AppState>["setState"];
|
||||||
selectedItems: LibraryItem["id"][];
|
selectedItems: LibraryItem["id"][];
|
||||||
library: Library;
|
library: Library;
|
||||||
@ -50,6 +49,7 @@ export const LibraryMenuHeader: React.FC<{
|
|||||||
isLibraryMenuOpenAtom,
|
isLibraryMenuOpenAtom,
|
||||||
jotaiScope,
|
jotaiScope,
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderRemoveLibAlert = useCallback(() => {
|
const renderRemoveLibAlert = useCallback(() => {
|
||||||
const content = selectedItems.length
|
const content = selectedItems.length
|
||||||
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||||
@ -181,7 +181,6 @@ export const LibraryMenuHeader: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu open={isLibraryMenuOpen}>
|
<DropdownMenu open={isLibraryMenuOpen}>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
className="Sidebar__dropdown-btn"
|
|
||||||
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
|
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
|
||||||
>
|
>
|
||||||
{DotsIcon}
|
{DotsIcon}
|
||||||
@ -230,6 +229,7 @@ export const LibraryMenuHeader: React.FC<{
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
{renderLibraryMenu()}
|
{renderLibraryMenu()}
|
||||||
@ -261,3 +261,48 @@ export const LibraryMenuHeader: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const LibraryDropdownMenu = ({
|
||||||
|
selectedItems,
|
||||||
|
onSelectItems,
|
||||||
|
}: {
|
||||||
|
selectedItems: LibraryItem["id"][];
|
||||||
|
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||||
|
}) => {
|
||||||
|
const { library } = useApp();
|
||||||
|
const appState = useExcalidrawAppState();
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
&__items {
|
&__items {
|
||||||
row-gap: 0.5rem;
|
row-gap: 0.5rem;
|
||||||
padding: var(--container-padding-y) var(--container-padding-x);
|
padding: var(--container-padding-y) 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@ -61,7 +61,7 @@
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
|
|
||||||
&--excal {
|
&--excal {
|
||||||
margin-top: 2.5rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,9 +10,8 @@ import Stack from "./Stack";
|
|||||||
import "./LibraryMenuItems.scss";
|
import "./LibraryMenuItems.scss";
|
||||||
import { MIME_TYPES } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { duplicateElements } from "../element/newElement";
|
import { duplicateElements } from "../element/newElement";
|
||||||
|
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||||
|
|
||||||
const CELLS_PER_ROW = 4;
|
const CELLS_PER_ROW = 4;
|
||||||
|
|
||||||
@ -102,7 +101,7 @@ const LibraryMenuItems = ({
|
|||||||
...item,
|
...item,
|
||||||
// duplicate each library item before inserting on canvas to confine
|
// duplicate each library item before inserting on canvas to confine
|
||||||
// ids and bindings to each library item. See #6465
|
// ids and bindings to each library item. See #6465
|
||||||
elements: duplicateElements(item.elements),
|
elements: duplicateElements(item.elements, { randomizeSeed: true }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -201,11 +200,7 @@ const LibraryMenuItems = ({
|
|||||||
(item) => item.status === "published",
|
(item) => item.status === "published",
|
||||||
);
|
);
|
||||||
|
|
||||||
const showBtn =
|
const showBtn = !libraryItems.length && !pendingElements.length;
|
||||||
!libraryItems.length &&
|
|
||||||
!unpublishedItems.length &&
|
|
||||||
!publishedItems.length &&
|
|
||||||
!pendingElements.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -215,7 +210,7 @@ const LibraryMenuItems = ({
|
|||||||
unpublishedItems.length ||
|
unpublishedItems.length ||
|
||||||
publishedItems.length
|
publishedItems.length
|
||||||
? { justifyContent: "flex-start" }
|
? { justifyContent: "flex-start" }
|
||||||
: {}
|
: { borderBottom: 0 }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Stack.Col
|
<Stack.Col
|
||||||
@ -251,11 +246,7 @@ const LibraryMenuItems = ({
|
|||||||
</div>
|
</div>
|
||||||
{!pendingElements.length && !unpublishedItems.length ? (
|
{!pendingElements.length && !unpublishedItems.length ? (
|
||||||
<div className="library-menu-items__no-items">
|
<div className="library-menu-items__no-items">
|
||||||
<div
|
<div className="library-menu-items__no-items__label">
|
||||||
className={clsx({
|
|
||||||
"library-menu-items__no-items__label": showBtn,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t("library.noItems")}
|
{t("library.noItems")}
|
||||||
</div>
|
</div>
|
||||||
<div className="library-menu-items__no-items__hint">
|
<div className="library-menu-items__no-items__hint">
|
||||||
@ -303,10 +294,13 @@ const LibraryMenuItems = ({
|
|||||||
</>
|
</>
|
||||||
|
|
||||||
{showBtn && (
|
{showBtn && (
|
||||||
<LibraryMenuBrowseButton
|
<LibraryMenuControlButtons
|
||||||
|
style={{ padding: "16px 0", width: "100%" }}
|
||||||
id={id}
|
id={id}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onSelectItems={onSelectItems}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
|
@ -13,13 +13,12 @@ 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: AppState;
|
||||||
@ -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,7 +189,7 @@ 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={() => {
|
||||||
|
@ -5,7 +5,8 @@ import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
|
|||||||
import { ChartType } from "../element/types";
|
import { ChartType } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { exportToSvg } from "../scene/export";
|
import { exportToSvg } from "../scene/export";
|
||||||
import { AppState, LibraryItem } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import { useApp } from "./App";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import "./PasteChartDialog.scss";
|
import "./PasteChartDialog.scss";
|
||||||
|
|
||||||
@ -78,13 +79,12 @@ export const PasteChartDialog = ({
|
|||||||
setAppState,
|
setAppState,
|
||||||
appState,
|
appState,
|
||||||
onClose,
|
onClose,
|
||||||
onInsertChart,
|
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
onInsertChart: (elements: LibraryItem["elements"]) => void;
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const { onInsertElements } = useApp();
|
||||||
const handleClose = React.useCallback(() => {
|
const handleClose = React.useCallback(() => {
|
||||||
if (onClose) {
|
if (onClose) {
|
||||||
onClose();
|
onClose();
|
||||||
@ -92,7 +92,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,
|
||||||
|
@ -2,67 +2,26 @@
|
|||||||
@import "../../css/variables.module";
|
@import "../../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Sidebar {
|
.sidebar {
|
||||||
&__close-btn,
|
display: flex;
|
||||||
&__pin-btn,
|
flex-direction: column;
|
||||||
&__dropdown-btn {
|
|
||||||
@include outlineButtonStyles;
|
|
||||||
width: var(--lg-button-size);
|
|
||||||
height: var(--lg-button-size);
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: var(--lg-icon-size);
|
|
||||||
height: var(--lg-icon-size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__pin-btn {
|
|
||||||
&--pinned {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:active {
|
|
||||||
background-color: var(--color-primary-darker);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.theme--dark {
|
|
||||||
.Sidebar {
|
|
||||||
&__pin-btn {
|
|
||||||
&--pinned {
|
|
||||||
svg {
|
|
||||||
color: var(--color-gray-90);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__sidebar {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
background-color: var(--sidebar-bg-color);
|
||||||
|
box-shadow: var(--sidebar-shadow);
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
left: 0;
|
left: 0;
|
||||||
right: auto;
|
right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
background-color: var(--sidebar-bg-color);
|
|
||||||
|
|
||||||
box-shadow: var(--sidebar-shadow);
|
|
||||||
|
|
||||||
&--docked {
|
&--docked {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
@ -77,52 +36,134 @@
|
|||||||
border-right: 1px solid var(--sidebar-border-color);
|
border-right: 1px solid var(--sidebar-border-color);
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
.Island {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon__icon {
|
// ---------------------------- sidebar header ------------------------------
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__icon__close {
|
.sidebar__header {
|
||||||
.Modal__close {
|
|
||||||
width: calc(var(--space-factor) * 7);
|
|
||||||
height: calc(var(--space-factor) * 7);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Island {
|
|
||||||
--padding: 0;
|
|
||||||
background-color: var(--island-bg-color);
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
padding: calc(var(--padding) * var(--space-factor));
|
|
||||||
position: relative;
|
|
||||||
transition: box-shadow 0.5s ease-in-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__sidebar__header {
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1rem;
|
padding-top: 1rem;
|
||||||
border-bottom: 1px solid var(--sidebar-border-color);
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layer-ui__sidebar__header__buttons {
|
.sidebar__header__buttons {
|
||||||
|
gap: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.625rem;
|
margin-left: auto;
|
||||||
|
|
||||||
|
button {
|
||||||
|
@include outlineButtonStyles;
|
||||||
|
--button-bg: transparent;
|
||||||
|
border: 0 !important;
|
||||||
|
|
||||||
|
width: var(--lg-button-size);
|
||||||
|
height: var(--lg-button-size);
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: var(--lg-icon-size);
|
||||||
|
height: var(--lg-icon-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-hover-bg, var(--island-bg-color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__dock.selected {
|
||||||
|
svg {
|
||||||
|
stroke: var(--color-primary);
|
||||||
|
fill: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------- sidebar tabs ------------------------------
|
||||||
|
|
||||||
|
.sidebar-tabs-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
|
||||||
|
[role="tabpanel"] {
|
||||||
|
flex: 1;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role="tabpanel"][data-state="inactive"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role="tablist"] {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tabs-root > .sidebar__header {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tab-trigger {
|
||||||
|
--button-width: auto;
|
||||||
|
--button-bg: transparent;
|
||||||
|
--button-hover-bg: transparent;
|
||||||
|
--button-active-bg: var(--color-primary);
|
||||||
|
--button-hover-color: var(--color-primary);
|
||||||
|
--button-hover-border: var(--color-primary);
|
||||||
|
|
||||||
|
&[data-state="active"] {
|
||||||
|
--button-bg: var(--color-primary);
|
||||||
|
--button-hover-bg: var(--color-primary-darker);
|
||||||
|
--button-hover-color: var(--color-icon-white);
|
||||||
|
--button-border: var(--color-primary);
|
||||||
|
color: var(--color-icon-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------- default sidebar ------------------------------
|
||||||
|
|
||||||
|
.default-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.sidebar-triggers {
|
||||||
|
$padding: 2px;
|
||||||
|
$border: 1px;
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
padding: $padding;
|
||||||
|
// offset by padding + border to vertically center the list with sibling
|
||||||
|
// buttons (both from top and bototm, due to flex layout)
|
||||||
|
margin-top: -#{$padding + $border};
|
||||||
|
margin-bottom: -#{$padding + $border};
|
||||||
|
border: $border solid var(--sidebar-border-color);
|
||||||
|
background: var(--default-bg-color);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
|
||||||
|
.sidebar-tab-trigger {
|
||||||
|
height: var(--lg-button-size);
|
||||||
|
width: var(--lg-button-size);
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__header {
|
||||||
|
border-bottom: 1px solid var(--sidebar-border-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { DEFAULT_SIDEBAR } from "../../constants";
|
||||||
import { Excalidraw, Sidebar } from "../../packages/excalidraw/index";
|
import { Excalidraw, Sidebar } from "../../packages/excalidraw/index";
|
||||||
import {
|
import {
|
||||||
act,
|
|
||||||
fireEvent,
|
fireEvent,
|
||||||
|
GlobalTestState,
|
||||||
queryAllByTestId,
|
queryAllByTestId,
|
||||||
queryByTestId,
|
queryByTestId,
|
||||||
render,
|
render,
|
||||||
@ -10,54 +11,66 @@ import {
|
|||||||
withExcalidrawDimensions,
|
withExcalidrawDimensions,
|
||||||
} from "../../tests/test-utils";
|
} from "../../tests/test-utils";
|
||||||
|
|
||||||
|
export const assertSidebarDockButton = async <T extends boolean>(
|
||||||
|
hasDockButton: T,
|
||||||
|
): Promise<
|
||||||
|
T extends false
|
||||||
|
? { dockButton: null; sidebar: HTMLElement }
|
||||||
|
: { dockButton: HTMLElement; sidebar: HTMLElement }
|
||||||
|
> => {
|
||||||
|
const sidebar =
|
||||||
|
GlobalTestState.renderResult.container.querySelector<HTMLElement>(
|
||||||
|
".sidebar",
|
||||||
|
);
|
||||||
|
expect(sidebar).not.toBe(null);
|
||||||
|
const dockButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||||
|
if (hasDockButton) {
|
||||||
|
expect(dockButton).not.toBe(null);
|
||||||
|
return { dockButton: dockButton!, sidebar: sidebar! } as any;
|
||||||
|
}
|
||||||
|
expect(dockButton).toBe(null);
|
||||||
|
return { dockButton: null, sidebar: sidebar! } as any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertExcalidrawWithSidebar = async (
|
||||||
|
sidebar: React.ReactNode,
|
||||||
|
name: string,
|
||||||
|
test: () => void,
|
||||||
|
) => {
|
||||||
|
await render(
|
||||||
|
<Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
|
||||||
|
{sidebar}
|
||||||
|
</Excalidraw>,
|
||||||
|
);
|
||||||
|
await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
|
||||||
|
};
|
||||||
|
|
||||||
describe("Sidebar", () => {
|
describe("Sidebar", () => {
|
||||||
|
describe("General behavior", () => {
|
||||||
it("should render custom sidebar", async () => {
|
it("should render custom sidebar", async () => {
|
||||||
const { container } = await render(
|
const { container } = await render(
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||||
renderSidebar={() => (
|
>
|
||||||
<Sidebar>
|
<Sidebar name="customSidebar">
|
||||||
<div id="test-sidebar-content">42</div>
|
<div id="test-sidebar-content">42</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)}
|
</Excalidraw>,
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
expect(node).not.toBe(null);
|
expect(node).not.toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render custom sidebar header", async () => {
|
|
||||||
const { container } = await render(
|
|
||||||
<Excalidraw
|
|
||||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
|
||||||
renderSidebar={() => (
|
|
||||||
<Sidebar>
|
|
||||||
<Sidebar.Header>
|
|
||||||
<div id="test-sidebar-header-content">42</div>
|
|
||||||
</Sidebar.Header>
|
|
||||||
</Sidebar>
|
|
||||||
)}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const node = container.querySelector("#test-sidebar-header-content");
|
|
||||||
expect(node).not.toBe(null);
|
|
||||||
// make sure we don't render the default fallback header,
|
|
||||||
// just the custom one
|
|
||||||
expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render only one sidebar and prefer the custom one", async () => {
|
it("should render only one sidebar and prefer the custom one", async () => {
|
||||||
const { container } = await render(
|
const { container } = await render(
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||||
renderSidebar={() => (
|
>
|
||||||
<Sidebar>
|
<Sidebar name="customSidebar">
|
||||||
<div id="test-sidebar-content">42</div>
|
<div id="test-sidebar-content">42</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)}
|
</Excalidraw>,
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@ -66,233 +79,18 @@ describe("Sidebar", () => {
|
|||||||
expect(node).not.toBe(null);
|
expect(node).not.toBe(null);
|
||||||
|
|
||||||
// make sure only one sidebar is rendered
|
// make sure only one sidebar is rendered
|
||||||
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
|
const sidebars = container.querySelectorAll(".sidebar");
|
||||||
expect(sidebars.length).toBe(1);
|
expect(sidebars.length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should always render custom sidebar with close button & close on click", async () => {
|
|
||||||
const onClose = jest.fn();
|
|
||||||
const CustomExcalidraw = () => {
|
|
||||||
return (
|
|
||||||
<Excalidraw
|
|
||||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
|
||||||
renderSidebar={() => (
|
|
||||||
<Sidebar className="test-sidebar" onClose={onClose}>
|
|
||||||
hello
|
|
||||||
</Sidebar>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = await render(<CustomExcalidraw />);
|
|
||||||
|
|
||||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
|
||||||
expect(sidebar).not.toBe(null);
|
|
||||||
const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
|
|
||||||
expect(closeButton).not.toBe(null);
|
|
||||||
|
|
||||||
fireEvent.click(closeButton);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
|
|
||||||
expect(onClose).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render custom sidebar with dock (irrespective of onDock prop)", async () => {
|
|
||||||
const CustomExcalidraw = () => {
|
|
||||||
return (
|
|
||||||
<Excalidraw
|
|
||||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
|
||||||
renderSidebar={() => (
|
|
||||||
<Sidebar className="test-sidebar">hello</Sidebar>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = await render(<CustomExcalidraw />);
|
|
||||||
|
|
||||||
// should show dock button when the sidebar fits to be docked
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, () => {
|
|
||||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
|
||||||
expect(sidebar).not.toBe(null);
|
|
||||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
|
||||||
expect(closeButton).not.toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// should not show dock button when the sidebar does not fit to be docked
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
await withExcalidrawDimensions({ width: 400, height: 1080 }, () => {
|
|
||||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
|
||||||
expect(sidebar).not.toBe(null);
|
|
||||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
|
||||||
expect(closeButton).toBe(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support controlled docking", async () => {
|
|
||||||
let _setDockable: (dockable: boolean) => void = null!;
|
|
||||||
|
|
||||||
const CustomExcalidraw = () => {
|
|
||||||
const [dockable, setDockable] = React.useState(false);
|
|
||||||
_setDockable = setDockable;
|
|
||||||
return (
|
|
||||||
<Excalidraw
|
|
||||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
|
||||||
renderSidebar={() => (
|
|
||||||
<Sidebar
|
|
||||||
className="test-sidebar"
|
|
||||||
docked={false}
|
|
||||||
dockable={dockable}
|
|
||||||
>
|
|
||||||
hello
|
|
||||||
</Sidebar>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = await render(<CustomExcalidraw />);
|
|
||||||
|
|
||||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
|
|
||||||
// should not show dock button when `dockable` is `false`
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
_setDockable(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
|
||||||
expect(sidebar).not.toBe(null);
|
|
||||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
|
||||||
expect(closeButton).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// should show dock button when `dockable` is `true`, even if `docked`
|
|
||||||
// prop is set
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
_setDockable(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
|
||||||
expect(sidebar).not.toBe(null);
|
|
||||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
|
||||||
expect(closeButton).not.toBe(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support controlled docking", async () => {
|
|
||||||
let _setDocked: (docked?: boolean) => void = null!;
|
|
||||||
|
|
||||||
const CustomExcalidraw = () => {
|
|
||||||
const [docked, setDocked] = React.useState<boolean | undefined>();
|
|
||||||
_setDocked = setDocked;
|
|
||||||
return (
|
|
||||||
<Excalidraw
|
|
||||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
|
||||||
renderSidebar={() => (
|
|
||||||
<Sidebar className="test-sidebar" docked={docked}>
|
|
||||||
hello
|
|
||||||
</Sidebar>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = await render(<CustomExcalidraw />);
|
|
||||||
|
|
||||||
const { h } = window;
|
|
||||||
|
|
||||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
|
|
||||||
const dockButton = await waitFor(() => {
|
|
||||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
|
||||||
expect(sidebar).not.toBe(null);
|
|
||||||
const dockBotton = queryByTestId(sidebar!, "sidebar-dock");
|
|
||||||
expect(dockBotton).not.toBe(null);
|
|
||||||
return dockBotton!;
|
|
||||||
});
|
|
||||||
|
|
||||||
const dockButtonInput = dockButton.querySelector("input")!;
|
|
||||||
|
|
||||||
// should not show dock button when `dockable` is `false`
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
expect(h.state.isSidebarDocked).toBe(false);
|
|
||||||
|
|
||||||
fireEvent.click(dockButtonInput);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(h.state.isSidebarDocked).toBe(true);
|
|
||||||
expect(dockButtonInput).toBeChecked();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(dockButtonInput);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(h.state.isSidebarDocked).toBe(false);
|
|
||||||
expect(dockButtonInput).not.toBeChecked();
|
|
||||||
});
|
|
||||||
|
|
||||||
// shouldn't update `appState.isSidebarDocked` when the sidebar
|
|
||||||
// is controlled (`docked` prop is set), as host apps should handle
|
|
||||||
// the state themselves
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
_setDocked(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(dockButtonInput).toBeChecked();
|
|
||||||
expect(h.state.isSidebarDocked).toBe(false);
|
|
||||||
expect(dockButtonInput).toBeChecked();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(dockButtonInput);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(h.state.isSidebarDocked).toBe(false);
|
|
||||||
expect(dockButtonInput).toBeChecked();
|
|
||||||
});
|
|
||||||
|
|
||||||
// the `appState.isSidebarDocked` should remain untouched when
|
|
||||||
// `props.docked` is set to `false`, and user toggles
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
_setDocked(false);
|
|
||||||
h.setState({ isSidebarDocked: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(h.state.isSidebarDocked).toBe(true);
|
|
||||||
expect(dockButtonInput).not.toBeChecked();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(dockButtonInput);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(dockButtonInput).not.toBeChecked();
|
|
||||||
expect(h.state.isSidebarDocked).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should toggle sidebar using props.toggleMenu()", async () => {
|
it("should toggle sidebar using props.toggleMenu()", async () => {
|
||||||
const { container } = await render(
|
const { container } = await render(
|
||||||
<Excalidraw
|
<Excalidraw>
|
||||||
renderSidebar={() => (
|
<Sidebar name="customSidebar">
|
||||||
<Sidebar>
|
|
||||||
<div id="test-sidebar-content">42</div>
|
<div id="test-sidebar-content">42</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)}
|
</Excalidraw>,
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// sidebar isn't rendered initially
|
// sidebar isn't rendered initially
|
||||||
@ -304,7 +102,7 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// toggle sidebar on
|
// toggle sidebar on
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(window.h.app.toggleMenu("customSidebar")).toBe(true);
|
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
@ -313,7 +111,7 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// toggle sidebar off
|
// toggle sidebar off
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(window.h.app.toggleMenu("customSidebar")).toBe(false);
|
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(false);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
@ -322,7 +120,9 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// force-toggle sidebar off (=> still hidden)
|
// force-toggle sidebar off (=> still hidden)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(window.h.app.toggleMenu("customSidebar", false)).toBe(false);
|
expect(
|
||||||
|
window.h.app.toggleSidebar({ name: "customSidebar", force: false }),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
@ -331,8 +131,12 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// force-toggle sidebar on
|
// force-toggle sidebar on
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
|
expect(
|
||||||
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
|
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
@ -341,15 +145,187 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// toggle library (= hide custom sidebar)
|
// toggle library (= hide custom sidebar)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(window.h.app.toggleMenu("library")).toBe(true);
|
expect(window.h.app.toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
expect(node).toBe(null);
|
expect(node).toBe(null);
|
||||||
|
|
||||||
// make sure only one sidebar is rendered
|
// make sure only one sidebar is rendered
|
||||||
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
|
const sidebars = container.querySelectorAll(".sidebar");
|
||||||
expect(sidebars.length).toBe(1);
|
expect(sidebars.length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("<Sidebar.Header/>", () => {
|
||||||
|
it("should render custom sidebar header", async () => {
|
||||||
|
const { container } = await render(
|
||||||
|
<Excalidraw
|
||||||
|
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||||
|
>
|
||||||
|
<Sidebar name="customSidebar">
|
||||||
|
<Sidebar.Header>
|
||||||
|
<div id="test-sidebar-header-content">42</div>
|
||||||
|
</Sidebar.Header>
|
||||||
|
</Sidebar>
|
||||||
|
</Excalidraw>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const node = container.querySelector("#test-sidebar-header-content");
|
||||||
|
expect(node).not.toBe(null);
|
||||||
|
// make sure we don't render the default fallback header,
|
||||||
|
// just the custom one
|
||||||
|
expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not render <Sidebar.Header> for custom sidebars by default", async () => {
|
||||||
|
const CustomExcalidraw = () => {
|
||||||
|
return (
|
||||||
|
<Excalidraw
|
||||||
|
initialData={{
|
||||||
|
appState: { openSidebar: { name: "customSidebar" } },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sidebar name="customSidebar" className="test-sidebar">
|
||||||
|
hello
|
||||||
|
</Sidebar>
|
||||||
|
</Excalidraw>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = await render(<CustomExcalidraw />);
|
||||||
|
|
||||||
|
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||||
|
expect(sidebar).not.toBe(null);
|
||||||
|
const closeButton = queryByTestId(sidebar!, "sidebar-close");
|
||||||
|
expect(closeButton).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("<Sidebar.Header> should render close button", async () => {
|
||||||
|
const onStateChange = jest.fn();
|
||||||
|
const CustomExcalidraw = () => {
|
||||||
|
return (
|
||||||
|
<Excalidraw
|
||||||
|
initialData={{
|
||||||
|
appState: { openSidebar: { name: "customSidebar" } },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
name="customSidebar"
|
||||||
|
className="test-sidebar"
|
||||||
|
onStateChange={onStateChange}
|
||||||
|
>
|
||||||
|
<Sidebar.Header />
|
||||||
|
</Sidebar>
|
||||||
|
</Excalidraw>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = await render(<CustomExcalidraw />);
|
||||||
|
|
||||||
|
// initial open
|
||||||
|
expect(onStateChange).toHaveBeenCalledWith({ name: "customSidebar" });
|
||||||
|
|
||||||
|
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||||
|
expect(sidebar).not.toBe(null);
|
||||||
|
const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
|
||||||
|
expect(closeButton).not.toBe(null);
|
||||||
|
|
||||||
|
fireEvent.click(closeButton);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(onStateChange).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Docking behavior", () => {
|
||||||
|
it("shouldn't be user-dockable if `onDock` not supplied", async () => {
|
||||||
|
await assertExcalidrawWithSidebar(
|
||||||
|
<Sidebar name="customSidebar">
|
||||||
|
<Sidebar.Header />
|
||||||
|
</Sidebar>,
|
||||||
|
"customSidebar",
|
||||||
|
async () => {
|
||||||
|
await assertSidebarDockButton(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shouldn't be user-dockable if `onDock` not supplied & `docked={true}`", async () => {
|
||||||
|
await assertExcalidrawWithSidebar(
|
||||||
|
<Sidebar name="customSidebar" docked={true}>
|
||||||
|
<Sidebar.Header />
|
||||||
|
</Sidebar>,
|
||||||
|
"customSidebar",
|
||||||
|
async () => {
|
||||||
|
await assertSidebarDockButton(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shouldn't be user-dockable if `onDock` not supplied & docked={false}`", async () => {
|
||||||
|
await assertExcalidrawWithSidebar(
|
||||||
|
<Sidebar name="customSidebar" docked={false}>
|
||||||
|
<Sidebar.Header />
|
||||||
|
</Sidebar>,
|
||||||
|
"customSidebar",
|
||||||
|
async () => {
|
||||||
|
await assertSidebarDockButton(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be user-dockable when both `onDock` and `docked` supplied", async () => {
|
||||||
|
await render(
|
||||||
|
<Excalidraw
|
||||||
|
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
name="customSidebar"
|
||||||
|
className="test-sidebar"
|
||||||
|
onDock={() => {}}
|
||||||
|
docked
|
||||||
|
>
|
||||||
|
<Sidebar.Header />
|
||||||
|
</Sidebar>
|
||||||
|
</Excalidraw>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await withExcalidrawDimensions(
|
||||||
|
{ width: 1920, height: 1080 },
|
||||||
|
async () => {
|
||||||
|
await assertSidebarDockButton(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
|
||||||
|
await render(
|
||||||
|
<Excalidraw
|
||||||
|
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
name="customSidebar"
|
||||||
|
className="test-sidebar"
|
||||||
|
onDock={() => {}}
|
||||||
|
>
|
||||||
|
<Sidebar.Header />
|
||||||
|
</Sidebar>
|
||||||
|
</Excalidraw>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await withExcalidrawDimensions(
|
||||||
|
{ width: 1920, height: 1080 },
|
||||||
|
async () => {
|
||||||
|
await assertSidebarDockButton(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,151 +1,249 @@
|
|||||||
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,
|
||||||
|
useExcalidrawAppState,
|
||||||
|
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 { useExcalidrawSetAppState } from "../App";
|
|
||||||
import { updateObject } from "../../utils";
|
|
||||||
|
|
||||||
/** using a counter instead of boolean to handle race conditions where
|
// FIXME replace this with the implem from ColorPicker once it's merged
|
||||||
* the host app may render (mount/unmount) multiple different sidebar */
|
const useOnClickOutside = (
|
||||||
export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 });
|
ref: RefObject<HTMLElement>,
|
||||||
|
cb: (event: MouseEvent) => void,
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (event: MouseEvent) => {
|
||||||
|
if (!ref.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
export const Sidebar = Object.assign(
|
if (
|
||||||
forwardRef(
|
event.target instanceof Element &&
|
||||||
|
(ref.current.contains(event.target) ||
|
||||||
|
!document.body.contains(event.target))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(event);
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", listener, false);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", listener);
|
||||||
|
};
|
||||||
|
}, [ref, cb]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flags whether the currently rendered Sidebar is docked or not, for use
|
||||||
|
* in upstream components that need to act on this (e.g. LayerUI to shift the
|
||||||
|
* UI). We use an atom because of potential host app sidebars (for the default
|
||||||
|
* sidebar we could just read from appState.defaultSidebarDockedPreference).
|
||||||
|
*
|
||||||
|
* Since we can only render one Sidebar at a time, we can use a simple flag.
|
||||||
|
*/
|
||||||
|
export const isSidebarDockedAtom = atom(false);
|
||||||
|
|
||||||
|
export const SidebarInner = forwardRef(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
name,
|
||||||
children,
|
children,
|
||||||
onClose,
|
|
||||||
onDock,
|
onDock,
|
||||||
docked,
|
docked,
|
||||||
/** Undocumented, may be removed later. Generally should either be
|
|
||||||
* `props.docked` or `appState.isSidebarDocked`. Currently serves to
|
|
||||||
* prevent unwanted animation of the shadow if initially docked. */
|
|
||||||
//
|
|
||||||
// NOTE we'll want to remove this after we sort out how to subscribe to
|
|
||||||
// individual appState properties
|
|
||||||
initialDockedState = docked,
|
|
||||||
dockable = true,
|
|
||||||
className,
|
className,
|
||||||
__isInternal,
|
...rest
|
||||||
}: SidebarProps<{
|
}: SidebarProps & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">,
|
||||||
// NOTE sidebars we use internally inside the editor must have this flag set.
|
|
||||||
// It indicates that this sidebar should have lower precedence over host
|
|
||||||
// sidebars, if both are open.
|
|
||||||
/** @private internal */
|
|
||||||
__isInternal?: boolean;
|
|
||||||
}>,
|
|
||||||
ref: React.ForwardedRef<HTMLDivElement>,
|
ref: React.ForwardedRef<HTMLDivElement>,
|
||||||
) => {
|
) => {
|
||||||
const [hostSidebarCounters, setHostSidebarCounters] = useAtom(
|
if (process.env.NODE_ENV === "development" && onDock && docked == null) {
|
||||||
hostSidebarCountersAtom,
|
console.warn(
|
||||||
jotaiScope,
|
"Sidebar: `docked` must be set when `onDock` is supplied for the sidebar to be user-dockable. To hide this message, either pass `docked` or remove `onDock`",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
const [isDockedFallback, setIsDockedFallback] = useState(
|
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
|
||||||
docked ?? initialDockedState ?? false,
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setIsSidebarDockedAtom(!!docked);
|
||||||
|
return () => {
|
||||||
|
setIsSidebarDockedAtom(false);
|
||||||
|
};
|
||||||
|
}, [setIsSidebarDockedAtom, docked]);
|
||||||
|
|
||||||
|
const headerPropsRef = useRef<SidebarPropsContextValue>(
|
||||||
|
{} as SidebarPropsContextValue,
|
||||||
);
|
);
|
||||||
|
headerPropsRef.current.onCloseRequest = () => {
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (docked === undefined) {
|
|
||||||
// ugly hack to get initial state out of AppState without subscribing
|
|
||||||
// to it as a whole (once we have granular subscriptions, we'll move
|
|
||||||
// to that)
|
|
||||||
//
|
|
||||||
// NOTE this means that is updated `state.isSidebarDocked` changes outside
|
|
||||||
// of this compoent, it won't be reflected here. Currently doesn't happen.
|
|
||||||
setAppState((state) => {
|
|
||||||
setIsDockedFallback(state.isSidebarDocked);
|
|
||||||
// bail from update
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [setAppState, docked]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!__isInternal) {
|
|
||||||
setHostSidebarCounters((s) => ({
|
|
||||||
rendered: s.rendered + 1,
|
|
||||||
docked: isDockedFallback ? s.docked + 1 : s.docked,
|
|
||||||
}));
|
|
||||||
return () => {
|
|
||||||
setHostSidebarCounters((s) => ({
|
|
||||||
rendered: s.rendered - 1,
|
|
||||||
docked: isDockedFallback ? s.docked - 1 : s.docked,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [__isInternal, setHostSidebarCounters, isDockedFallback]);
|
|
||||||
|
|
||||||
const onCloseRef = useRef(onClose);
|
|
||||||
onCloseRef.current = onClose;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
onCloseRef.current?.();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const headerPropsRef = useRef<SidebarPropsContextValue>({});
|
|
||||||
headerPropsRef.current.onClose = () => {
|
|
||||||
setAppState({ openSidebar: null });
|
setAppState({ openSidebar: null });
|
||||||
};
|
};
|
||||||
headerPropsRef.current.onDock = (isDocked) => {
|
headerPropsRef.current.onDock = (isDocked) => onDock?.(isDocked);
|
||||||
if (docked === undefined) {
|
|
||||||
setAppState({ isSidebarDocked: isDocked });
|
|
||||||
setIsDockedFallback(isDocked);
|
|
||||||
}
|
|
||||||
onDock?.(isDocked);
|
|
||||||
};
|
|
||||||
// renew the ref object if the following props change since we want to
|
// renew the ref object if the following props change since we want to
|
||||||
// rerender. We can't pass down as component props manually because
|
// rerender. We can't pass down as component props manually because
|
||||||
// the <Sidebar.Header/> can be rendered upsream.
|
// the <Sidebar.Header/> can be rendered upstream.
|
||||||
headerPropsRef.current = updateObject(headerPropsRef.current, {
|
headerPropsRef.current = updateObject(headerPropsRef.current, {
|
||||||
docked: docked ?? isDockedFallback,
|
docked,
|
||||||
dockable,
|
// explicit prop to rerender on update
|
||||||
|
shouldRenderDockButton: !!onDock && docked != null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hostSidebarCounters.rendered > 0 && __isInternal) {
|
const islandRef = useRef<HTMLDivElement>(null);
|
||||||
return null;
|
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
return islandRef.current!;
|
||||||
|
});
|
||||||
|
|
||||||
|
const device = useDevice();
|
||||||
|
|
||||||
|
const closeLibrary = useCallback(() => {
|
||||||
|
const isDialogOpen = !!document.querySelector(".Dialog");
|
||||||
|
|
||||||
|
// Prevent closing if any dialog is open
|
||||||
|
if (isDialogOpen) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setAppState({ openSidebar: null });
|
||||||
|
}, [setAppState]);
|
||||||
|
|
||||||
|
useOnClickOutside(
|
||||||
|
islandRef,
|
||||||
|
useCallback(
|
||||||
|
(event) => {
|
||||||
|
// If click on the library icon, do nothing so that LibraryButton
|
||||||
|
// can toggle library menu
|
||||||
|
if ((event.target as Element).closest(".sidebar-trigger")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!docked || !device.canDeviceFitSidebar) {
|
||||||
|
closeLibrary();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[closeLibrary, docked, device.canDeviceFitSidebar],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === KEYS.ESCAPE &&
|
||||||
|
(!docked || !device.canDeviceFitSidebar)
|
||||||
|
) {
|
||||||
|
closeLibrary();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [closeLibrary, docked, device.canDeviceFitSidebar]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Island
|
<Island
|
||||||
className={clsx(
|
{...rest}
|
||||||
"layer-ui__sidebar",
|
className={clsx("sidebar", { "sidebar--docked": docked }, className)}
|
||||||
{ "layer-ui__sidebar--docked": isDockedFallback },
|
ref={islandRef}
|
||||||
className,
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
>
|
>
|
||||||
<SidebarPropsContext.Provider value={headerPropsRef.current}>
|
<SidebarPropsContext.Provider value={headerPropsRef.current}>
|
||||||
<SidebarHeaderComponents.Context>
|
|
||||||
<SidebarHeaderComponents.Component __isFallback />
|
|
||||||
{children}
|
{children}
|
||||||
</SidebarHeaderComponents.Context>
|
|
||||||
</SidebarPropsContext.Provider>
|
</SidebarPropsContext.Provider>
|
||||||
</Island>
|
</Island>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
|
SidebarInner.displayName = "SidebarInner";
|
||||||
|
|
||||||
|
export const Sidebar = Object.assign(
|
||||||
|
forwardRef((props: SidebarProps, ref: React.ForwardedRef<HTMLDivElement>) => {
|
||||||
|
const appState = useExcalidrawAppState();
|
||||||
|
|
||||||
|
const { onStateChange } = props;
|
||||||
|
|
||||||
|
const refPrevOpenSidebar = useRef(appState.openSidebar);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
// closing sidebar
|
||||||
|
((!appState.openSidebar &&
|
||||||
|
refPrevOpenSidebar?.current?.name === props.name) ||
|
||||||
|
// opening current sidebar
|
||||||
|
(appState.openSidebar?.name === props.name &&
|
||||||
|
refPrevOpenSidebar?.current?.name !== props.name) ||
|
||||||
|
// switching tabs or switching to a different sidebar
|
||||||
|
refPrevOpenSidebar.current?.name === props.name) &&
|
||||||
|
appState.openSidebar !== refPrevOpenSidebar.current
|
||||||
|
) {
|
||||||
|
onStateChange?.(
|
||||||
|
appState.openSidebar?.name !== props.name
|
||||||
|
? null
|
||||||
|
: appState.openSidebar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
refPrevOpenSidebar.current = appState.openSidebar;
|
||||||
|
}, [appState.openSidebar, onStateChange, props.name]);
|
||||||
|
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
return () => setMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// We want to render in the next tick (hence `mounted` flag) so that it's
|
||||||
|
// guaranteed to happen after unmount of the previous sidebar (in case the
|
||||||
|
// previous sidebar is mounted after the next one). This is necessary to
|
||||||
|
// prevent flicker of subcomponents that support fallbacks
|
||||||
|
// (e.g. SidebarHeader). This is because we're using flags to determine
|
||||||
|
// whether prefer the fallback component or not (otherwise both will render
|
||||||
|
// initially), and the flag won't be reset in time if the unmount order
|
||||||
|
// it not correct.
|
||||||
|
//
|
||||||
|
// Alternative, and more general solution would be to namespace the fallback
|
||||||
|
// HoC so that state is not shared between subcomponents when the wrapping
|
||||||
|
// component is of the same type (e.g. Sidebar -> SidebarHeader).
|
||||||
|
const shouldRender = mounted && appState.openSidebar?.name === props.name;
|
||||||
|
|
||||||
|
if (!shouldRender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SidebarInner {...props} ref={ref} key={props.name} />;
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
Header: SidebarHeaderComponents.Component,
|
Header: SidebarHeader,
|
||||||
|
TabTriggers: SidebarTabTriggers,
|
||||||
|
TabTrigger: SidebarTabTrigger,
|
||||||
|
Tabs: SidebarTabs,
|
||||||
|
Tab: SidebarTab,
|
||||||
|
Trigger: SidebarTrigger,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
Sidebar.displayName = "Sidebar";
|
||||||
|
@ -4,86 +4,54 @@ import { t } from "../../i18n";
|
|||||||
import { useDevice } from "../App";
|
import { useDevice } from "../App";
|
||||||
import { SidebarPropsContext } from "./common";
|
import { SidebarPropsContext } from "./common";
|
||||||
import { CloseIcon, PinIcon } from "../icons";
|
import { CloseIcon, PinIcon } from "../icons";
|
||||||
import { withUpstreamOverride } from "../hoc/withUpstreamOverride";
|
|
||||||
import { Tooltip } from "../Tooltip";
|
import { Tooltip } from "../Tooltip";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
|
||||||
export const SidebarDockButton = (props: {
|
export const SidebarHeader = ({
|
||||||
checked: boolean;
|
children,
|
||||||
onChange?(): void;
|
className,
|
||||||
}) => {
|
}: {
|
||||||
return (
|
|
||||||
<div className="layer-ui__sidebar-dock-button" data-testid="sidebar-dock">
|
|
||||||
<Tooltip label={t("labels.sidebarLock")}>
|
|
||||||
<label
|
|
||||||
className={clsx(
|
|
||||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
|
||||||
`ToolIcon_size_medium`,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="ToolIcon_type_checkbox"
|
|
||||||
type="checkbox"
|
|
||||||
onChange={props.onChange}
|
|
||||||
checked={props.checked}
|
|
||||||
aria-label={t("labels.sidebarLock")}
|
|
||||||
/>{" "}
|
|
||||||
<div
|
|
||||||
className={clsx("Sidebar__pin-btn", {
|
|
||||||
"Sidebar__pin-btn--pinned": props.checked,
|
|
||||||
})}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
{PinIcon}
|
|
||||||
</div>{" "}
|
|
||||||
</label>{" "}
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const _SidebarHeader: React.FC<{
|
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ children, className }) => {
|
}) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const props = useContext(SidebarPropsContext);
|
const props = useContext(SidebarPropsContext);
|
||||||
|
|
||||||
const renderDockButton = !!(device.canDeviceFitSidebar && props.dockable);
|
const renderDockButton = !!(
|
||||||
const renderCloseButton = !!props.onClose;
|
device.canDeviceFitSidebar && props.shouldRenderDockButton
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("layer-ui__sidebar__header", className)}
|
className={clsx("sidebar__header", className)}
|
||||||
data-testid="sidebar-header"
|
data-testid="sidebar-header"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{(renderDockButton || renderCloseButton) && (
|
<div className="sidebar__header__buttons">
|
||||||
<div className="layer-ui__sidebar__header__buttons">
|
|
||||||
{renderDockButton && (
|
{renderDockButton && (
|
||||||
<SidebarDockButton
|
<Tooltip label={t("labels.sidebarLock")}>
|
||||||
checked={!!props.docked}
|
<Button
|
||||||
onChange={() => {
|
onSelect={() => props.onDock?.(!props.docked)}
|
||||||
props.onDock?.(!props.docked);
|
selected={!!props.docked}
|
||||||
}}
|
className="sidebar__dock"
|
||||||
/>
|
data-testid="sidebar-dock"
|
||||||
|
aria-label={t("labels.sidebarLock")}
|
||||||
|
>
|
||||||
|
{PinIcon}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{renderCloseButton && (
|
<Button
|
||||||
<button
|
|
||||||
data-testid="sidebar-close"
|
data-testid="sidebar-close"
|
||||||
className="Sidebar__close-btn"
|
className="sidebar__close"
|
||||||
onClick={props.onClose}
|
onSelect={props.onCloseRequest}
|
||||||
aria-label={t("buttons.close")}
|
aria-label={t("buttons.close")}
|
||||||
>
|
>
|
||||||
{CloseIcon}
|
{CloseIcon}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [Context, Component] = withUpstreamOverride(_SidebarHeader);
|
SidebarHeader.displayName = "SidebarHeader";
|
||||||
|
|
||||||
/** @private */
|
|
||||||
export const SidebarHeaderComponents = { Context, Component };
|
|
||||||
|
18
src/components/Sidebar/SidebarTab.tsx
Normal file
18
src/components/Sidebar/SidebarTab.tsx
Normal 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";
|
26
src/components/Sidebar/SidebarTabTrigger.tsx
Normal file
26
src/components/Sidebar/SidebarTabTrigger.tsx
Normal 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";
|
16
src/components/Sidebar/SidebarTabTriggers.tsx
Normal file
16
src/components/Sidebar/SidebarTabTriggers.tsx
Normal 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";
|
36
src/components/Sidebar/SidebarTabs.tsx
Normal file
36
src/components/Sidebar/SidebarTabs.tsx
Normal 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";
|
34
src/components/Sidebar/SidebarTrigger.scss
Normal file
34
src/components/Sidebar/SidebarTrigger.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
45
src/components/Sidebar/SidebarTrigger.tsx
Normal file
45
src/components/Sidebar/SidebarTrigger.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { useExcalidrawSetAppState, useExcalidrawAppState } from "../App";
|
||||||
|
import { SidebarTriggerProps } from "./common";
|
||||||
|
|
||||||
|
import "./SidebarTrigger.scss";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
export const SidebarTrigger = ({
|
||||||
|
name,
|
||||||
|
tab,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onToggle,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: SidebarTriggerProps) => {
|
||||||
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
// TODO replace with sidebar context
|
||||||
|
const appState = useExcalidrawAppState();
|
||||||
|
|
||||||
|
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";
|
@ -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);
|
||||||
|
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
// container in body where the actual tooltip is appended to
|
// container in body where the actual tooltip is appended to
|
||||||
.excalidraw-tooltip {
|
.excalidraw-tooltip {
|
||||||
|
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||||
|
Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
font-family: var(--ui-font);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
|
||||||
|
67
src/components/Trans.test.tsx
Normal file
67
src/components/Trans.test.tsx
Normal 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
169
src/components/Trans.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { useI18n } from "../i18n";
|
||||||
|
|
||||||
|
// Used for splitting i18nKey into tokens in Trans component
|
||||||
|
// Example:
|
||||||
|
// "Please <link>click {{location}}</link> to continue.".split(SPLIT_REGEX).filter(Boolean)
|
||||||
|
// produces
|
||||||
|
// ["Please ", "<link>", "click ", "{{location}}", "</link>", " to continue."]
|
||||||
|
const SPLIT_REGEX = /({{[\w-]+}})|(<[\w-]+>)|(<\/[\w-]+>)/g;
|
||||||
|
// Used for extracting "location" from "{{location}}"
|
||||||
|
const KEY_REGEXP = /{{([\w-]+)}}/;
|
||||||
|
// Used for extracting "link" from "<link>"
|
||||||
|
const TAG_START_REGEXP = /<([\w-]+)>/;
|
||||||
|
// Used for extracting "link" from "</link>"
|
||||||
|
const TAG_END_REGEXP = /<\/([\w-]+)>/;
|
||||||
|
|
||||||
|
const getTransChildren = (
|
||||||
|
format: string,
|
||||||
|
props: {
|
||||||
|
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
|
||||||
|
},
|
||||||
|
): React.ReactNode[] => {
|
||||||
|
const stack: { name: string; children: React.ReactNode[] }[] = [
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
format
|
||||||
|
.split(SPLIT_REGEX)
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((match) => {
|
||||||
|
const tagStartMatch = match.match(TAG_START_REGEXP);
|
||||||
|
const tagEndMatch = match.match(TAG_END_REGEXP);
|
||||||
|
const keyMatch = match.match(KEY_REGEXP);
|
||||||
|
|
||||||
|
if (tagStartMatch !== null) {
|
||||||
|
// The match is <tag>. Set the tag name as the name if it's one of the
|
||||||
|
// props, e.g. for "Please <link>click the button</link> to continue"
|
||||||
|
// tagStartMatch[1] = "link" and props contain "link" then it will be
|
||||||
|
// pushed to stack.
|
||||||
|
const name = tagStartMatch[1];
|
||||||
|
if (props.hasOwnProperty(name)) {
|
||||||
|
stack.push({
|
||||||
|
name,
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`Trans: missed to pass in prop ${name} for interpolating ${format}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (tagEndMatch !== null) {
|
||||||
|
// If tag end match is found, this means we need to replace the content with
|
||||||
|
// its actual value in prop e.g. format = "Please <link>click the
|
||||||
|
// button</link> to continue", tagEndMatch is for "</link>", stack last item name =
|
||||||
|
// "link" and props.link = (el) => <a
|
||||||
|
// href="https://example.com">{el}</a> then its prop value will be
|
||||||
|
// pushed to "link"'s children so on DOM when rendering it's rendered as
|
||||||
|
// <a href="https://example.com">click the button</a>
|
||||||
|
const name = tagEndMatch[1];
|
||||||
|
if (name === stack[stack.length - 1].name) {
|
||||||
|
const item = stack.pop()!;
|
||||||
|
const itemChildren = React.createElement(
|
||||||
|
React.Fragment,
|
||||||
|
{},
|
||||||
|
...item.children,
|
||||||
|
);
|
||||||
|
const fn = props[item.name];
|
||||||
|
if (typeof fn === "function") {
|
||||||
|
stack[stack.length - 1].children.push(fn(itemChildren));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`Trans: unexpected end tag ${match} for interpolating ${format}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (keyMatch !== null) {
|
||||||
|
// The match is for {{key}}. Check if the key is present in props and set
|
||||||
|
// the prop value as children of last stack item e.g. format = "Hello
|
||||||
|
// {{name}}", key = "name" and props.name = "Excalidraw" then its prop
|
||||||
|
// value will be pushed to "name"'s children so it's rendered on DOM as
|
||||||
|
// "Hello Excalidraw"
|
||||||
|
const name = keyMatch[1];
|
||||||
|
if (props.hasOwnProperty(name)) {
|
||||||
|
stack[stack.length - 1].children.push(props[name] as React.ReactNode);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`Trans: key ${name} not in props for interpolating ${format}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If none of cases match means we just need to push the string
|
||||||
|
// to stack eg - "Hello {{name}} Whats up?" "Hello", "Whats up" will be pushed
|
||||||
|
stack[stack.length - 1].children.push(match);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stack.length !== 1) {
|
||||||
|
console.warn(`Trans: stack not empty for interpolating ${format}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stack[0].children;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Trans component is used for translating JSX.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"example1": "Hello {{audience}}",
|
||||||
|
"example2": "Please <link>click the button</link> to continue.",
|
||||||
|
"example3": "Please <link>click {{location}}</link> to continue.",
|
||||||
|
"example4": "Please <link>click <bold>{{location}}</bold></link> to continue.",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Trans i18nKey="example1" audience="world" />
|
||||||
|
|
||||||
|
<Trans
|
||||||
|
i18nKey="example2"
|
||||||
|
connectLink={(el) => <a href="https://example.com">{el}</a>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Trans
|
||||||
|
i18nKey="example3"
|
||||||
|
connectLink={(el) => <a href="https://example.com">{el}</a>}
|
||||||
|
location="the button"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Trans
|
||||||
|
i18nKey="example4"
|
||||||
|
connectLink={(el) => <a href="https://example.com">{el}</a>}
|
||||||
|
location="the button"
|
||||||
|
bold={(el) => <strong>{el}</strong>}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```html
|
||||||
|
Hello world
|
||||||
|
Please <a href="https://example.com">click the button</a> to continue.
|
||||||
|
Please <a href="https://example.com">click the button</a> to continue.
|
||||||
|
Please <a href="https://example.com">click <strong>the button</strong></a> to continue.
|
||||||
|
```
|
||||||
|
*/
|
||||||
|
const Trans = ({
|
||||||
|
i18nKey,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
i18nKey: string;
|
||||||
|
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// This is needed to avoid unique key error in list which gets rendered from getTransChildren
|
||||||
|
return React.createElement(
|
||||||
|
React.Fragment,
|
||||||
|
{},
|
||||||
|
...getTransChildren(t(i18nKey), props),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Trans;
|
@ -6,58 +6,45 @@ exports[`Test <App/> should show error modal when using brave and measureText AP
|
|||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Looks like you are using Brave browser with the
|
Looks like you are using Brave browser with the
|
||||||
|
|
||||||
<span
|
<span
|
||||||
style="font-weight: 600;"
|
style="font-weight: 600;"
|
||||||
>
|
>
|
||||||
Aggressively Block Fingerprinting
|
Aggressively Block Fingerprinting
|
||||||
</span>
|
</span>
|
||||||
|
setting enabled.
|
||||||
setting enabled
|
</p>
|
||||||
.
|
<p>
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
This could result in breaking the
|
This could result in breaking the
|
||||||
|
|
||||||
<span
|
<span
|
||||||
style="font-weight: 600;"
|
style="font-weight: 600;"
|
||||||
>
|
>
|
||||||
Text Elements
|
Text Elements
|
||||||
</span>
|
</span>
|
||||||
|
in your drawings.
|
||||||
in your drawings
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
We strongly recommend disabling this setting. You can follow
|
We strongly recommend disabling this setting. You can follow
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"
|
href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"
|
||||||
>
|
>
|
||||||
|
|
||||||
these steps
|
these steps
|
||||||
</a>
|
</a>
|
||||||
|
on how to do so.
|
||||||
on how to do so
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
If disabling this setting doesn't fix the display of text elements, please open an
|
If disabling this setting doesn't fix the display of text elements, please open an
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="https://github.com/excalidraw/excalidraw/issues/new"
|
href="https://github.com/excalidraw/excalidraw/issues/new"
|
||||||
>
|
>
|
||||||
issue
|
issue
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
on our GitHub, or write us on
|
on our GitHub, or write us on
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="https://discord.gg/UexuTaE"
|
href="https://discord.gg/UexuTaE"
|
||||||
>
|
>
|
||||||
Discord
|
Discord
|
||||||
</a>
|
|
||||||
.
|
.
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import tunnel from "@dwelle/tunnel-rat";
|
|
||||||
|
|
||||||
type Tunnel = ReturnType<typeof tunnel>;
|
|
||||||
|
|
||||||
type TunnelsContextValue = {
|
|
||||||
mainMenuTunnel: Tunnel;
|
|
||||||
welcomeScreenMenuHintTunnel: Tunnel;
|
|
||||||
welcomeScreenToolbarHintTunnel: Tunnel;
|
|
||||||
welcomeScreenHelpHintTunnel: Tunnel;
|
|
||||||
welcomeScreenCenterTunnel: Tunnel;
|
|
||||||
footerCenterTunnel: Tunnel;
|
|
||||||
jotaiScope: symbol;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
|
|
||||||
|
|
||||||
export const useTunnels = () => React.useContext(TunnelsContext);
|
|
||||||
|
|
||||||
export const useInitializeTunnels = () => {
|
|
||||||
return React.useMemo((): TunnelsContextValue => {
|
|
||||||
return {
|
|
||||||
mainMenuTunnel: tunnel(),
|
|
||||||
welcomeScreenMenuHintTunnel: tunnel(),
|
|
||||||
welcomeScreenToolbarHintTunnel: tunnel(),
|
|
||||||
welcomeScreenHelpHintTunnel: tunnel(),
|
|
||||||
welcomeScreenCenterTunnel: tunnel(),
|
|
||||||
footerCenterTunnel: tunnel(),
|
|
||||||
jotaiScope: Symbol(),
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
};
|
|
@ -1,4 +1,4 @@
|
|||||||
import { useOutsideClickHook } from "../../hooks/useOutsideClick";
|
import { useOutsideClick } from "../../hooks/useOutsideClick";
|
||||||
import { Island } from "../Island";
|
import { Island } from "../Island";
|
||||||
|
|
||||||
import { useDevice } from "../App";
|
import { useDevice } from "../App";
|
||||||
@ -24,7 +24,7 @@ const MenuContent = ({
|
|||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}) => {
|
}) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const menuRef = useOutsideClickHook(() => {
|
const menuRef = useOutsideClick(() => {
|
||||||
onClickOutside?.();
|
onClickOutside?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ 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";
|
||||||
@ -25,7 +25,7 @@ const Footer = ({
|
|||||||
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)}
|
||||||
/>
|
/>
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useExcalidrawAppState } from "../App";
|
import { useExcalidrawAppState } from "../App";
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../../context/tunnels";
|
||||||
import "./FooterCenter.scss";
|
import "./FooterCenter.scss";
|
||||||
|
|
||||||
const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
|
const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
|
||||||
const { footerCenterTunnel } = useTunnels();
|
const { FooterCenterTunnel } = useTunnels();
|
||||||
const appState = useExcalidrawAppState();
|
const appState = useExcalidrawAppState();
|
||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,32 +1,46 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import React, { useLayoutEffect } from "react";
|
import React, { useLayoutEffect } from "react";
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../../context/tunnels";
|
||||||
|
|
||||||
export const withInternalFallback = <P,>(
|
export const withInternalFallback = <P,>(
|
||||||
componentName: string,
|
componentName: string,
|
||||||
Component: React.FC<P>,
|
Component: React.FC<P>,
|
||||||
) => {
|
) => {
|
||||||
const counterAtom = atom(0);
|
const renderAtom = atom(0);
|
||||||
// flag set on initial render to tell the fallback component to skip the
|
// flag set on initial render to tell the fallback component to skip the
|
||||||
// render until mount counter are initialized. This is because the counter
|
// render until mount counter are initialized. This is because the counter
|
||||||
// is initialized in an effect, and thus we could end rendering both
|
// is initialized in an effect, and thus we could end rendering both
|
||||||
// components at the same time until counter is initialized.
|
// components at the same time until counter is initialized.
|
||||||
let preferHost = false;
|
let preferHost = false;
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
const WrapperComponent: React.FC<
|
const WrapperComponent: React.FC<
|
||||||
P & {
|
P & {
|
||||||
__fallback?: boolean;
|
__fallback?: boolean;
|
||||||
}
|
}
|
||||||
> = (props) => {
|
> = (props) => {
|
||||||
const { jotaiScope } = useTunnels();
|
const { jotaiScope } = useTunnels();
|
||||||
const [counter, setCounter] = useAtom(counterAtom, jotaiScope);
|
const [, setRender] = useAtom(renderAtom, jotaiScope);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setCounter((counter) => counter + 1);
|
setRender((c) => {
|
||||||
|
const next = c + 1;
|
||||||
|
counter = next;
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
setCounter((counter) => counter - 1);
|
setRender((c) => {
|
||||||
|
const next = c - 1;
|
||||||
|
counter = next;
|
||||||
|
if (!next) {
|
||||||
|
preferHost = false;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}, [setCounter]);
|
}, [setRender]);
|
||||||
|
|
||||||
if (!props.__fallback) {
|
if (!props.__fallback) {
|
||||||
preferHost = true;
|
preferHost = true;
|
||||||
|
@ -1,63 +0,0 @@
|
|||||||
import React, {
|
|
||||||
useMemo,
|
|
||||||
useContext,
|
|
||||||
useLayoutEffect,
|
|
||||||
useState,
|
|
||||||
createContext,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
export const withUpstreamOverride = <P,>(Component: React.ComponentType<P>) => {
|
|
||||||
type ContextValue = [boolean, React.Dispatch<React.SetStateAction<boolean>>];
|
|
||||||
|
|
||||||
const DefaultComponentContext = createContext<ContextValue>([
|
|
||||||
false,
|
|
||||||
() => {},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const ComponentContext: React.FC<{ children: React.ReactNode }> = ({
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const [isRenderedUpstream, setIsRenderedUpstream] = useState(false);
|
|
||||||
const contextValue: ContextValue = useMemo(
|
|
||||||
() => [isRenderedUpstream, setIsRenderedUpstream],
|
|
||||||
[isRenderedUpstream],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DefaultComponentContext.Provider value={contextValue}>
|
|
||||||
{children}
|
|
||||||
</DefaultComponentContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DefaultComponent = (
|
|
||||||
props: P & {
|
|
||||||
// indicates whether component should render when not rendered upstream
|
|
||||||
/** @private internal */
|
|
||||||
__isFallback?: boolean;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const [isRenderedUpstream, setIsRenderedUpstream] = useContext(
|
|
||||||
DefaultComponentContext,
|
|
||||||
);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!props.__isFallback) {
|
|
||||||
setIsRenderedUpstream(true);
|
|
||||||
return () => setIsRenderedUpstream(false);
|
|
||||||
}
|
|
||||||
}, [props.__isFallback, setIsRenderedUpstream]);
|
|
||||||
|
|
||||||
if (props.__isFallback && isRenderedUpstream) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Component {...props} />;
|
|
||||||
};
|
|
||||||
if (Component.name) {
|
|
||||||
DefaultComponent.displayName = `${Component.name}_upstreamOverrideWrapper`;
|
|
||||||
ComponentContext.displayName = `${Component.name}_upstreamOverrideContextWrapper`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [ComponentContext, DefaultComponent] as const;
|
|
||||||
};
|
|
@ -13,7 +13,7 @@ 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";
|
||||||
|
|
||||||
const MainMenu = Object.assign(
|
const MainMenu = Object.assign(
|
||||||
withInternalFallback(
|
withInternalFallback(
|
||||||
@ -28,7 +28,7 @@ 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 = useExcalidrawAppState();
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
@ -37,7 +37,7 @@ const MainMenu = Object.assign(
|
|||||||
: () => 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 +66,7 @@ const MainMenu = Object.assign(
|
|||||||
)}
|
)}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</mainMenuTunnel.In>
|
</MainMenuTunnel.In>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -6,7 +6,7 @@ import {
|
|||||||
useExcalidrawActionManager,
|
useExcalidrawActionManager,
|
||||||
useExcalidrawAppState,
|
useExcalidrawAppState,
|
||||||
} from "../App";
|
} from "../App";
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../../context/tunnels";
|
||||||
import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons";
|
import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons";
|
||||||
|
|
||||||
const WelcomeScreenMenuItemContent = ({
|
const WelcomeScreenMenuItemContent = ({
|
||||||
@ -89,9 +89,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 +104,7 @@ const Center = ({ children }: { children?: React.ReactNode }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</welcomeScreenCenterTunnel.In>
|
</WelcomeScreenCenterTunnel.In>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
Center.displayName = "Center";
|
Center.displayName = "Center";
|
||||||
|
@ -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";
|
||||||
|
@ -109,20 +109,30 @@ export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
|||||||
|
|
||||||
export const GRID_SIZE = 20; // TODO make it configurable?
|
export const GRID_SIZE = 20; // TODO make it configurable?
|
||||||
|
|
||||||
export const MIME_TYPES = {
|
export const IMAGE_MIME_TYPES = {
|
||||||
excalidraw: "application/vnd.excalidraw+json",
|
|
||||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
|
||||||
json: "application/json",
|
|
||||||
svg: "image/svg+xml",
|
svg: "image/svg+xml",
|
||||||
"excalidraw.svg": "image/svg+xml",
|
|
||||||
png: "image/png",
|
png: "image/png",
|
||||||
"excalidraw.png": "image/png",
|
|
||||||
jpg: "image/jpeg",
|
jpg: "image/jpeg",
|
||||||
gif: "image/gif",
|
gif: "image/gif",
|
||||||
webp: "image/webp",
|
webp: "image/webp",
|
||||||
bmp: "image/bmp",
|
bmp: "image/bmp",
|
||||||
ico: "image/x-icon",
|
ico: "image/x-icon",
|
||||||
|
avif: "image/avif",
|
||||||
|
jfif: "image/jfif",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const MIME_TYPES = {
|
||||||
|
json: "application/json",
|
||||||
|
// excalidraw data
|
||||||
|
excalidraw: "application/vnd.excalidraw+json",
|
||||||
|
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||||
|
// image-encoded excalidraw data
|
||||||
|
"excalidraw.svg": "image/svg+xml",
|
||||||
|
"excalidraw.png": "image/png",
|
||||||
|
// binary
|
||||||
binary: "application/octet-stream",
|
binary: "application/octet-stream",
|
||||||
|
// image
|
||||||
|
...IMAGE_MIME_TYPES,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const EXPORT_DATA_TYPES = {
|
export const EXPORT_DATA_TYPES = {
|
||||||
@ -193,16 +203,6 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
|
|||||||
|
|
||||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||||
|
|
||||||
export const ALLOWED_IMAGE_MIME_TYPES = [
|
|
||||||
MIME_TYPES.png,
|
|
||||||
MIME_TYPES.jpg,
|
|
||||||
MIME_TYPES.svg,
|
|
||||||
MIME_TYPES.gif,
|
|
||||||
MIME_TYPES.webp,
|
|
||||||
MIME_TYPES.bmp,
|
|
||||||
MIME_TYPES.ico,
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
@ -279,3 +279,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
36
src/context/tunnels.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
import tunnel from "tunnel-rat";
|
||||||
|
|
||||||
|
export type Tunnel = ReturnType<typeof tunnel>;
|
||||||
|
|
||||||
|
type TunnelsContextValue = {
|
||||||
|
MainMenuTunnel: Tunnel;
|
||||||
|
WelcomeScreenMenuHintTunnel: Tunnel;
|
||||||
|
WelcomeScreenToolbarHintTunnel: Tunnel;
|
||||||
|
WelcomeScreenHelpHintTunnel: Tunnel;
|
||||||
|
WelcomeScreenCenterTunnel: Tunnel;
|
||||||
|
FooterCenterTunnel: Tunnel;
|
||||||
|
DefaultSidebarTriggerTunnel: Tunnel;
|
||||||
|
DefaultSidebarTabTriggersTunnel: Tunnel;
|
||||||
|
jotaiScope: symbol;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
|
||||||
|
|
||||||
|
export const useTunnels = () => React.useContext(TunnelsContext);
|
||||||
|
|
||||||
|
export const useInitializeTunnels = () => {
|
||||||
|
return React.useMemo((): TunnelsContextValue => {
|
||||||
|
return {
|
||||||
|
MainMenuTunnel: tunnel(),
|
||||||
|
WelcomeScreenMenuHintTunnel: tunnel(),
|
||||||
|
WelcomeScreenToolbarHintTunnel: tunnel(),
|
||||||
|
WelcomeScreenHelpHintTunnel: tunnel(),
|
||||||
|
WelcomeScreenCenterTunnel: tunnel(),
|
||||||
|
FooterCenterTunnel: tunnel(),
|
||||||
|
DefaultSidebarTriggerTunnel: tunnel(),
|
||||||
|
DefaultSidebarTabTriggersTunnel: tunnel(),
|
||||||
|
jotaiScope: Symbol(),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
};
|
5
src/context/ui-appState.ts
Normal file
5
src/context/ui-appState.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
|
||||||
|
export const UIAppStateContext = React.createContext<AppState>(null!);
|
||||||
|
export const useUIAppState = () => React.useContext(UIAppStateContext);
|
@ -354,6 +354,7 @@
|
|||||||
border-radius: var(--space-factor);
|
border-radius: var(--space-factor);
|
||||||
border: 1px solid var(--button-gray-2);
|
border: 1px solid var(--button-gray-2);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background-image: var(--dropdown-icon);
|
background-image: var(--dropdown-icon);
|
||||||
@ -413,6 +414,7 @@
|
|||||||
bottom: 30px;
|
bottom: 30px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--button-hover-bg);
|
background-color: var(--button-hover-bg);
|
||||||
@ -565,7 +567,7 @@
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-button {
|
.default-sidebar-trigger {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { cleanAppStateForExport } from "../appState";
|
import { cleanAppStateForExport } from "../appState";
|
||||||
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
||||||
import { clearElementsForExport } from "../element";
|
import { clearElementsForExport } from "../element";
|
||||||
import { ExcalidrawElement, FileId } from "../element/types";
|
import { ExcalidrawElement, FileId } from "../element/types";
|
||||||
import { CanvasError } from "../errors";
|
import { CanvasError } from "../errors";
|
||||||
@ -117,11 +117,9 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
|
|||||||
|
|
||||||
export const isSupportedImageFile = (
|
export const isSupportedImageFile = (
|
||||||
blob: Blob | null | undefined,
|
blob: Blob | null | undefined,
|
||||||
): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => {
|
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
|
||||||
const { type } = blob || {};
|
const { type } = blob || {};
|
||||||
return (
|
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
|
||||||
!!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadSceneOrLibraryFromBlob = async (
|
export const loadSceneOrLibraryFromBlob = async (
|
||||||
@ -157,7 +155,7 @@ export const loadSceneOrLibraryFromBlob = async (
|
|||||||
},
|
},
|
||||||
localAppState,
|
localAppState,
|
||||||
localElements,
|
localElements,
|
||||||
{ repairBindings: true, refreshDimensions: true },
|
{ repairBindings: true, refreshDimensions: false },
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} else if (isValidLibrary(data)) {
|
} else if (isValidLibrary(data)) {
|
||||||
|
@ -8,16 +8,7 @@ import { EVENT, MIME_TYPES } from "../constants";
|
|||||||
import { AbortError } from "../errors";
|
import { AbortError } from "../errors";
|
||||||
import { debounce } from "../utils";
|
import { debounce } from "../utils";
|
||||||
|
|
||||||
type FILE_EXTENSION =
|
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||||
| "gif"
|
|
||||||
| "jpg"
|
|
||||||
| "png"
|
|
||||||
| "excalidraw.png"
|
|
||||||
| "svg"
|
|
||||||
| "excalidraw.svg"
|
|
||||||
| "json"
|
|
||||||
| "excalidraw"
|
|
||||||
| "excalidrawlib";
|
|
||||||
|
|
||||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||||
|
|
||||||
|
@ -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();
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -27,6 +27,7 @@ import {
|
|||||||
PRECEDING_ELEMENT_KEY,
|
PRECEDING_ELEMENT_KEY,
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
ROUNDNESS,
|
ROUNDNESS,
|
||||||
|
DEFAULT_SIDEBAR,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
@ -431,21 +432,15 @@ const LegacyAppStateMigrations: {
|
|||||||
defaultAppState: ReturnType<typeof getDefaultAppState>,
|
defaultAppState: ReturnType<typeof getDefaultAppState>,
|
||||||
) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
|
) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
|
||||||
} = {
|
} = {
|
||||||
isLibraryOpen: (appState, defaultAppState) => {
|
isSidebarDocked: (appState, defaultAppState) => {
|
||||||
return [
|
return [
|
||||||
"openSidebar",
|
"defaultSidebarDockedPreference",
|
||||||
"isLibraryOpen" in appState
|
appState.isSidebarDocked ??
|
||||||
? appState.isLibraryOpen
|
coalesceAppStateValue(
|
||||||
? "library"
|
"defaultSidebarDockedPreference",
|
||||||
: null
|
appState,
|
||||||
: coalesceAppStateValue("openSidebar", appState, defaultAppState),
|
defaultAppState,
|
||||||
];
|
),
|
||||||
},
|
|
||||||
isLibraryMenuDocked: (appState, defaultAppState) => {
|
|
||||||
return [
|
|
||||||
"isSidebarDocked",
|
|
||||||
appState.isLibraryMenuDocked ??
|
|
||||||
coalesceAppStateValue("isSidebarDocked", appState, defaultAppState),
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -517,13 +512,10 @@ export const restoreAppState = (
|
|||||||
: appState.zoom?.value
|
: appState.zoom?.value
|
||||||
? appState.zoom
|
? appState.zoom
|
||||||
: defaultAppState.zoom,
|
: defaultAppState.zoom,
|
||||||
// when sidebar docked and user left it open in last session,
|
|
||||||
// keep it open. If not docked, keep it closed irrespective of last state.
|
|
||||||
openSidebar:
|
openSidebar:
|
||||||
nextAppState.openSidebar === "library"
|
// string (legacy)
|
||||||
? nextAppState.isSidebarDocked
|
typeof (appState.openSidebar as any as string) === "string"
|
||||||
? "library"
|
? { name: DEFAULT_SIDEBAR.name }
|
||||||
: null
|
|
||||||
: nextAppState.openSidebar,
|
: nextAppState.openSidebar,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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 {
|
||||||
|
@ -20,7 +20,7 @@ import {
|
|||||||
isTestEnv,
|
isTestEnv,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import { randomInteger, randomId } from "../random";
|
import { randomInteger, randomId } from "../random";
|
||||||
import { mutateElement, newElementWith } from "./mutateElement";
|
import { bumpVersion, mutateElement, newElementWith } from "./mutateElement";
|
||||||
import { getNewGroupIdsForDuplication } from "../groups";
|
import { getNewGroupIdsForDuplication } from "../groups";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getElementAbsoluteCoords } from ".";
|
import { getElementAbsoluteCoords } from ".";
|
||||||
@ -33,7 +33,7 @@ import {
|
|||||||
measureText,
|
measureText,
|
||||||
normalizeText,
|
normalizeText,
|
||||||
wrapText,
|
wrapText,
|
||||||
getMaxContainerWidth,
|
getBoundTextMaxWidth,
|
||||||
getDefaultLineHeight,
|
getDefaultLineHeight,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import {
|
import {
|
||||||
@ -310,7 +310,7 @@ export const refreshTextDimensions = (
|
|||||||
text = wrapText(
|
text = wrapText(
|
||||||
text,
|
text,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
getMaxContainerWidth(container),
|
getBoundTextMaxWidth(container),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const dimensions = getAdjustedDimensions(textElement, text);
|
const dimensions = getAdjustedDimensions(textElement, text);
|
||||||
@ -539,8 +539,16 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
|||||||
* it's advised to supply the whole elements array, or sets of elements that
|
* it's advised to supply the whole elements array, or sets of elements that
|
||||||
* are encapsulated (such as library items), if the purpose is to retain
|
* are encapsulated (such as library items), if the purpose is to retain
|
||||||
* bindings to the cloned elements intact.
|
* bindings to the cloned elements intact.
|
||||||
|
*
|
||||||
|
* NOTE by default does not randomize or regenerate anything except the id.
|
||||||
*/
|
*/
|
||||||
export const duplicateElements = (elements: readonly ExcalidrawElement[]) => {
|
export const duplicateElements = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
opts?: {
|
||||||
|
/** NOTE also updates version flags and `updated` */
|
||||||
|
randomizeSeed: boolean;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
const clonedElements: ExcalidrawElement[] = [];
|
const clonedElements: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
const origElementsMap = arrayToMap(elements);
|
const origElementsMap = arrayToMap(elements);
|
||||||
@ -574,6 +582,11 @@ export const duplicateElements = (elements: readonly ExcalidrawElement[]) => {
|
|||||||
|
|
||||||
clonedElement.id = maybeGetNewId(element.id)!;
|
clonedElement.id = maybeGetNewId(element.id)!;
|
||||||
|
|
||||||
|
if (opts?.randomizeSeed) {
|
||||||
|
clonedElement.seed = randomInteger();
|
||||||
|
bumpVersion(clonedElement);
|
||||||
|
}
|
||||||
|
|
||||||
if (clonedElement.groupIds) {
|
if (clonedElement.groupIds) {
|
||||||
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
||||||
if (!groupNewIdsMap.has(groupId)) {
|
if (!groupNewIdsMap.has(groupId)) {
|
||||||
|
@ -44,10 +44,10 @@ import {
|
|||||||
getBoundTextElementId,
|
getBoundTextElementId,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
getMaxContainerWidth,
|
getBoundTextMaxWidth,
|
||||||
getApproxMinLineHeight,
|
getApproxMinLineHeight,
|
||||||
measureText,
|
measureText,
|
||||||
getMaxContainerHeight,
|
getBoundTextMaxHeight,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
|
|
||||||
export const normalizeAngle = (angle: number): number => {
|
export const normalizeAngle = (angle: number): number => {
|
||||||
@ -204,7 +204,7 @@ const measureFontSizeFromWidth = (
|
|||||||
if (hasContainer) {
|
if (hasContainer) {
|
||||||
const container = getContainerElement(element);
|
const container = getContainerElement(element);
|
||||||
if (container) {
|
if (container) {
|
||||||
width = getMaxContainerWidth(container);
|
width = getBoundTextMaxWidth(container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const nextFontSize = element.fontSize * (nextWidth / width);
|
const nextFontSize = element.fontSize * (nextWidth / width);
|
||||||
@ -435,8 +435,8 @@ export const resizeSingleElement = (
|
|||||||
|
|
||||||
const nextFont = measureFontSizeFromWidth(
|
const nextFont = measureFontSizeFromWidth(
|
||||||
boundTextElement,
|
boundTextElement,
|
||||||
getMaxContainerWidth(updatedElement),
|
getBoundTextMaxWidth(updatedElement),
|
||||||
getMaxContainerHeight(updatedElement),
|
getBoundTextMaxHeight(updatedElement, boundTextElement),
|
||||||
);
|
);
|
||||||
if (nextFont === null) {
|
if (nextFont === null) {
|
||||||
return;
|
return;
|
||||||
@ -718,10 +718,10 @@ const resizeMultipleElements = (
|
|||||||
const metrics = measureFontSizeFromWidth(
|
const metrics = measureFontSizeFromWidth(
|
||||||
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||||
boundTextElement
|
boundTextElement
|
||||||
? getMaxContainerWidth(updatedElement)
|
? getBoundTextMaxWidth(updatedElement)
|
||||||
: updatedElement.width,
|
: updatedElement.width,
|
||||||
boundTextElement
|
boundTextElement
|
||||||
? getMaxContainerHeight(updatedElement)
|
? getBoundTextMaxHeight(updatedElement, boundTextElement)
|
||||||
: updatedElement.height,
|
: updatedElement.height,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -3,14 +3,15 @@ import { API } from "../tests/helpers/api";
|
|||||||
import {
|
import {
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
getContainerCoords,
|
getContainerCoords,
|
||||||
getMaxContainerWidth,
|
getBoundTextMaxWidth,
|
||||||
getMaxContainerHeight,
|
getBoundTextMaxHeight,
|
||||||
wrapText,
|
wrapText,
|
||||||
detectLineHeight,
|
detectLineHeight,
|
||||||
getLineHeightInPx,
|
getLineHeightInPx,
|
||||||
getDefaultLineHeight,
|
getDefaultLineHeight,
|
||||||
|
parseTokens,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import { FontString } from "./types";
|
import { ExcalidrawTextElementWithContainer, FontString } from "./types";
|
||||||
|
|
||||||
describe("Test wrapText", () => {
|
describe("Test wrapText", () => {
|
||||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||||
@ -183,6 +184,56 @@ now`,
|
|||||||
expect(wrapText(text, font, -1)).toEqual(text);
|
expect(wrapText(text, font, -1)).toEqual(text);
|
||||||
expect(wrapText(text, font, Infinity)).toEqual(text);
|
expect(wrapText(text, font, Infinity)).toEqual(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should wrap the text correctly when text contains hyphen", () => {
|
||||||
|
let text =
|
||||||
|
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
|
||||||
|
const res = wrapText(text, font, 110);
|
||||||
|
expect(res).toBe(
|
||||||
|
`Wikipedia \nis hosted \nby \nWikimedia-\nFoundation,\na non-\nprofit \norganizati\non that \nalso hosts\na range-of\nother \nprojects`,
|
||||||
|
);
|
||||||
|
|
||||||
|
text = "Hello thereusing-now";
|
||||||
|
expect(wrapText(text, font, 100)).toEqual("Hello \nthereusin\ng-now");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Test parseTokens", () => {
|
||||||
|
it("should split into tokens correctly", () => {
|
||||||
|
let text = "Excalidraw is a virtual collaborative whiteboard";
|
||||||
|
expect(parseTokens(text)).toEqual([
|
||||||
|
"Excalidraw",
|
||||||
|
"is",
|
||||||
|
"a",
|
||||||
|
"virtual",
|
||||||
|
"collaborative",
|
||||||
|
"whiteboard",
|
||||||
|
]);
|
||||||
|
|
||||||
|
text =
|
||||||
|
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
|
||||||
|
expect(parseTokens(text)).toEqual([
|
||||||
|
"Wikipedia",
|
||||||
|
"is",
|
||||||
|
"hosted",
|
||||||
|
"by",
|
||||||
|
"Wikimedia-",
|
||||||
|
"",
|
||||||
|
"Foundation,",
|
||||||
|
"a",
|
||||||
|
"non-",
|
||||||
|
"profit",
|
||||||
|
"organization",
|
||||||
|
"that",
|
||||||
|
"also",
|
||||||
|
"hosts",
|
||||||
|
"a",
|
||||||
|
"range-",
|
||||||
|
"of",
|
||||||
|
"other",
|
||||||
|
"projects",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Test measureText", () => {
|
describe("Test measureText", () => {
|
||||||
@ -260,7 +311,7 @@ describe("Test measureText", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Test getMaxContainerWidth", () => {
|
describe("Test getBoundTextMaxWidth", () => {
|
||||||
const params = {
|
const params = {
|
||||||
width: 178,
|
width: 178,
|
||||||
height: 194,
|
height: 194,
|
||||||
@ -268,39 +319,76 @@ describe("Test measureText", () => {
|
|||||||
|
|
||||||
it("should return max width when container is rectangle", () => {
|
it("should return max width when container is rectangle", () => {
|
||||||
const container = API.createElement({ type: "rectangle", ...params });
|
const container = API.createElement({ type: "rectangle", ...params });
|
||||||
expect(getMaxContainerWidth(container)).toBe(168);
|
expect(getBoundTextMaxWidth(container)).toBe(168);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return max width when container is ellipse", () => {
|
it("should return max width when container is ellipse", () => {
|
||||||
const container = API.createElement({ type: "ellipse", ...params });
|
const container = API.createElement({ type: "ellipse", ...params });
|
||||||
expect(getMaxContainerWidth(container)).toBe(116);
|
expect(getBoundTextMaxWidth(container)).toBe(116);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return max width when container is diamond", () => {
|
it("should return max width when container is diamond", () => {
|
||||||
const container = API.createElement({ type: "diamond", ...params });
|
const container = API.createElement({ type: "diamond", ...params });
|
||||||
expect(getMaxContainerWidth(container)).toBe(79);
|
expect(getBoundTextMaxWidth(container)).toBe(79);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Test getMaxContainerHeight", () => {
|
describe("Test getBoundTextMaxHeight", () => {
|
||||||
const params = {
|
const params = {
|
||||||
width: 178,
|
width: 178,
|
||||||
height: 194,
|
height: 194,
|
||||||
|
id: '"container-id',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const boundTextElement = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
id: "text-id",
|
||||||
|
x: 560.51171875,
|
||||||
|
y: 202.033203125,
|
||||||
|
width: 154,
|
||||||
|
height: 175,
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 1,
|
||||||
|
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||||
|
textAlign: "center",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
containerId: params.id,
|
||||||
|
}) as ExcalidrawTextElementWithContainer;
|
||||||
|
|
||||||
it("should return max height when container is rectangle", () => {
|
it("should return max height when container is rectangle", () => {
|
||||||
const container = API.createElement({ type: "rectangle", ...params });
|
const container = API.createElement({ type: "rectangle", ...params });
|
||||||
expect(getMaxContainerHeight(container)).toBe(184);
|
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return max height when container is ellipse", () => {
|
it("should return max height when container is ellipse", () => {
|
||||||
const container = API.createElement({ type: "ellipse", ...params });
|
const container = API.createElement({ type: "ellipse", ...params });
|
||||||
expect(getMaxContainerHeight(container)).toBe(127);
|
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return max height when container is diamond", () => {
|
it("should return max height when container is diamond", () => {
|
||||||
const container = API.createElement({ type: "diamond", ...params });
|
const container = API.createElement({ type: "diamond", ...params });
|
||||||
expect(getMaxContainerHeight(container)).toBe(87);
|
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return max height when container is arrow", () => {
|
||||||
|
const container = API.createElement({
|
||||||
|
type: "arrow",
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return max height when container is arrow and height is less than threshold", () => {
|
||||||
|
const container = API.createElement({
|
||||||
|
type: "arrow",
|
||||||
|
...params,
|
||||||
|
height: 70,
|
||||||
|
boundElements: [{ type: "text", id: "text-id" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(
|
||||||
|
boundTextElement.height,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -65,7 +65,7 @@ export const redrawTextBoundingBox = (
|
|||||||
boundTextUpdates.text = textElement.text;
|
boundTextUpdates.text = textElement.text;
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
maxWidth = getMaxContainerWidth(container);
|
maxWidth = getBoundTextMaxWidth(container);
|
||||||
boundTextUpdates.text = wrapText(
|
boundTextUpdates.text = wrapText(
|
||||||
textElement.originalText,
|
textElement.originalText,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
@ -83,16 +83,11 @@ export const redrawTextBoundingBox = (
|
|||||||
boundTextUpdates.baseline = metrics.baseline;
|
boundTextUpdates.baseline = metrics.baseline;
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
if (isArrowElement(container)) {
|
|
||||||
const centerX = textElement.x + textElement.width / 2;
|
|
||||||
const centerY = textElement.y + textElement.height / 2;
|
|
||||||
const diffWidth = metrics.width - textElement.width;
|
|
||||||
const diffHeight = metrics.height - textElement.height;
|
|
||||||
boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2;
|
|
||||||
boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2;
|
|
||||||
} else {
|
|
||||||
const containerDims = getContainerDims(container);
|
const containerDims = getContainerDims(container);
|
||||||
let maxContainerHeight = getMaxContainerHeight(container);
|
const maxContainerHeight = getBoundTextMaxHeight(
|
||||||
|
container,
|
||||||
|
textElement as ExcalidrawTextElementWithContainer,
|
||||||
|
);
|
||||||
|
|
||||||
let nextHeight = containerDims.height;
|
let nextHeight = containerDims.height;
|
||||||
if (metrics.height > maxContainerHeight) {
|
if (metrics.height > maxContainerHeight) {
|
||||||
@ -101,7 +96,6 @@ export const redrawTextBoundingBox = (
|
|||||||
container.type,
|
container.type,
|
||||||
);
|
);
|
||||||
mutateElement(container, { height: nextHeight });
|
mutateElement(container, { height: nextHeight });
|
||||||
maxContainerHeight = getMaxContainerHeight(container);
|
|
||||||
updateOriginalContainerCache(container.id, nextHeight);
|
updateOriginalContainerCache(container.id, nextHeight);
|
||||||
}
|
}
|
||||||
const updatedTextElement = {
|
const updatedTextElement = {
|
||||||
@ -112,7 +106,6 @@ export const redrawTextBoundingBox = (
|
|||||||
boundTextUpdates.x = x;
|
boundTextUpdates.x = x;
|
||||||
boundTextUpdates.y = y;
|
boundTextUpdates.y = y;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
mutateElement(textElement, boundTextUpdates);
|
mutateElement(textElement, boundTextUpdates);
|
||||||
};
|
};
|
||||||
@ -183,8 +176,11 @@ export const handleBindTextResize = (
|
|||||||
let nextHeight = textElement.height;
|
let nextHeight = textElement.height;
|
||||||
let nextWidth = textElement.width;
|
let nextWidth = textElement.width;
|
||||||
const containerDims = getContainerDims(container);
|
const containerDims = getContainerDims(container);
|
||||||
const maxWidth = getMaxContainerWidth(container);
|
const maxWidth = getBoundTextMaxWidth(container);
|
||||||
const maxHeight = getMaxContainerHeight(container);
|
const maxHeight = getBoundTextMaxHeight(
|
||||||
|
container,
|
||||||
|
textElement as ExcalidrawTextElementWithContainer,
|
||||||
|
);
|
||||||
let containerHeight = containerDims.height;
|
let containerHeight = containerDims.height;
|
||||||
let nextBaseLine = textElement.baseline;
|
let nextBaseLine = textElement.baseline;
|
||||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||||
@ -256,8 +252,8 @@ export const computeBoundTextPosition = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const containerCoords = getContainerCoords(container);
|
const containerCoords = getContainerCoords(container);
|
||||||
const maxContainerHeight = getMaxContainerHeight(container);
|
const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
|
||||||
const maxContainerWidth = getMaxContainerWidth(container);
|
const maxContainerWidth = getBoundTextMaxWidth(container);
|
||||||
|
|
||||||
let x;
|
let x;
|
||||||
let y;
|
let y;
|
||||||
@ -419,6 +415,24 @@ export const getTextHeight = (
|
|||||||
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const parseTokens = (text: string) => {
|
||||||
|
// Splitting words containing "-" as those are treated as separate words
|
||||||
|
// by css wrapping algorithm eg non-profit => non-, profit
|
||||||
|
const words = text.split("-");
|
||||||
|
if (words.length > 1) {
|
||||||
|
// non-proft org => ['non-', 'profit org']
|
||||||
|
words.forEach((word, index) => {
|
||||||
|
if (index !== words.length - 1) {
|
||||||
|
words[index] = word += "-";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Joining the words with space and splitting them again with space to get the
|
||||||
|
// final list of tokens
|
||||||
|
// ['non-', 'profit org'] =>,'non- proft org' => ['non-','profit','org']
|
||||||
|
return words.join(" ").split(" ");
|
||||||
|
};
|
||||||
|
|
||||||
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||||
// if maxWidth is not finite or NaN which can happen in case of bugs in
|
// if maxWidth is not finite or NaN which can happen in case of bugs in
|
||||||
// computation, we need to make sure we don't continue as we'll end up
|
// computation, we need to make sure we don't continue as we'll end up
|
||||||
@ -444,17 +458,16 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
|||||||
currentLine = "";
|
currentLine = "";
|
||||||
currentLineWidthTillNow = 0;
|
currentLineWidthTillNow = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
originalLines.forEach((originalLine) => {
|
originalLines.forEach((originalLine) => {
|
||||||
const currentLineWidth = getTextWidth(originalLine, font);
|
const currentLineWidth = getTextWidth(originalLine, font);
|
||||||
|
|
||||||
//Push the line if its <= maxWidth
|
// Push the line if its <= maxWidth
|
||||||
if (currentLineWidth <= maxWidth) {
|
if (currentLineWidth <= maxWidth) {
|
||||||
lines.push(originalLine);
|
lines.push(originalLine);
|
||||||
return; // continue
|
return; // continue
|
||||||
}
|
}
|
||||||
const words = originalLine.split(" ");
|
|
||||||
|
|
||||||
|
const words = parseTokens(originalLine);
|
||||||
resetParams();
|
resetParams();
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@ -472,6 +485,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
|||||||
else if (currentWordWidth > maxWidth) {
|
else if (currentWordWidth > maxWidth) {
|
||||||
// push current line since the current word exceeds the max width
|
// push current line since the current word exceeds the max width
|
||||||
// so will be appended in next line
|
// so will be appended in next line
|
||||||
|
|
||||||
push(currentLine);
|
push(currentLine);
|
||||||
|
|
||||||
resetParams();
|
resetParams();
|
||||||
@ -492,15 +506,15 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
|||||||
currentLine += currentChar;
|
currentLine += currentChar;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// push current line if appending space exceeds max width
|
// push current line if appending space exceeds max width
|
||||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||||
push(currentLine);
|
push(currentLine);
|
||||||
resetParams();
|
resetParams();
|
||||||
} else {
|
|
||||||
// space needs to be appended before next word
|
// space needs to be appended before next word
|
||||||
// as currentLine contains chars which couldn't be appended
|
// as currentLine contains chars which couldn't be appended
|
||||||
// to previous line
|
// to previous line unless the line ends with hyphen to sync
|
||||||
|
// with css word-wrap
|
||||||
|
} else if (!currentLine.endsWith("-")) {
|
||||||
currentLine += " ";
|
currentLine += " ";
|
||||||
currentLineWidthTillNow += spaceWidth;
|
currentLineWidthTillNow += spaceWidth;
|
||||||
}
|
}
|
||||||
@ -518,12 +532,23 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
index++;
|
index++;
|
||||||
currentLine += `${word} `;
|
|
||||||
|
// if word ends with "-" then we don't need to add space
|
||||||
|
// to sync with css word-wrap
|
||||||
|
const shouldAppendSpace = !word.endsWith("-");
|
||||||
|
currentLine += word;
|
||||||
|
|
||||||
|
if (shouldAppendSpace) {
|
||||||
|
currentLine += " ";
|
||||||
|
}
|
||||||
|
|
||||||
// Push the word if appending space exceeds max width
|
// Push the word if appending space exceeds max width
|
||||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||||
const word = currentLine.slice(0, -1);
|
if (shouldAppendSpace) {
|
||||||
push(word);
|
lines.push(currentLine.slice(0, -1));
|
||||||
|
} else {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
resetParams();
|
resetParams();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -861,18 +886,10 @@ export const computeContainerDimensionForBoundText = (
|
|||||||
return dimension + padding;
|
return dimension + padding;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
export const getBoundTextMaxWidth = (container: ExcalidrawElement) => {
|
||||||
const width = getContainerDims(container).width;
|
const width = getContainerDims(container).width;
|
||||||
if (isArrowElement(container)) {
|
if (isArrowElement(container)) {
|
||||||
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
|
return width - BOUND_TEXT_PADDING * 8 * 2;
|
||||||
if (containerWidth <= 0) {
|
|
||||||
const boundText = getBoundTextElement(container);
|
|
||||||
if (boundText) {
|
|
||||||
return boundText.width;
|
|
||||||
}
|
|
||||||
return BOUND_TEXT_PADDING * 8 * 2;
|
|
||||||
}
|
|
||||||
return containerWidth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (container.type === "ellipse") {
|
if (container.type === "ellipse") {
|
||||||
@ -889,16 +906,15 @@ export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
|||||||
return width - BOUND_TEXT_PADDING * 2;
|
return width - BOUND_TEXT_PADDING * 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
export const getBoundTextMaxHeight = (
|
||||||
|
container: ExcalidrawElement,
|
||||||
|
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||||
|
) => {
|
||||||
const height = getContainerDims(container).height;
|
const height = getContainerDims(container).height;
|
||||||
if (isArrowElement(container)) {
|
if (isArrowElement(container)) {
|
||||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||||
if (containerHeight <= 0) {
|
if (containerHeight <= 0) {
|
||||||
const boundText = getBoundTextElement(container);
|
return boundTextElement.height;
|
||||||
if (boundText) {
|
|
||||||
return boundText.height;
|
|
||||||
}
|
|
||||||
return BOUND_TEXT_PADDING * 8 * 2;
|
|
||||||
}
|
}
|
||||||
return height;
|
return height;
|
||||||
}
|
}
|
||||||
|
@ -526,6 +526,36 @@ describe("textWysiwyg", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should set the text element angle to same as container angle when binding to rotated container", async () => {
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 90,
|
||||||
|
height: 75,
|
||||||
|
angle: 45,
|
||||||
|
});
|
||||||
|
h.elements = [rectangle];
|
||||||
|
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
|
||||||
|
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
|
expect(text.type).toBe("text");
|
||||||
|
expect(text.containerId).toBe(rectangle.id);
|
||||||
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
|
{ id: text.id, type: "text" },
|
||||||
|
]);
|
||||||
|
expect(text.angle).toBe(rectangle.angle);
|
||||||
|
mouse.down();
|
||||||
|
const editor = document.querySelector(
|
||||||
|
".excalidraw-textEditorContainer > textarea",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
editor.blur();
|
||||||
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
|
{ id: text.id, type: "text" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("should compute the container height correctly and not throw error when height is updated while editing the text", async () => {
|
it("should compute the container height correctly and not throw error when height is updated while editing the text", async () => {
|
||||||
const diamond = API.createElement({
|
const diamond = API.createElement({
|
||||||
type: "diamond",
|
type: "diamond",
|
||||||
|
@ -32,8 +32,8 @@ import {
|
|||||||
normalizeText,
|
normalizeText,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
wrapText,
|
wrapText,
|
||||||
getMaxContainerHeight,
|
getBoundTextMaxHeight,
|
||||||
getMaxContainerWidth,
|
getBoundTextMaxWidth,
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
detectLineHeight,
|
detectLineHeight,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
@ -205,8 +205,11 @@ export const textWysiwyg = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
maxWidth = getMaxContainerWidth(container);
|
maxWidth = getBoundTextMaxWidth(container);
|
||||||
maxHeight = getMaxContainerHeight(container);
|
maxHeight = getBoundTextMaxHeight(
|
||||||
|
container,
|
||||||
|
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||||
|
);
|
||||||
|
|
||||||
// autogrow container height if text exceeds
|
// autogrow container height if text exceeds
|
||||||
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
||||||
@ -377,7 +380,7 @@ export const textWysiwyg = ({
|
|||||||
const wrappedText = wrapText(
|
const wrappedText = wrapText(
|
||||||
`${editable.value}${data}`,
|
`${editable.value}${data}`,
|
||||||
font,
|
font,
|
||||||
getMaxContainerWidth(container),
|
getBoundTextMaxWidth(container),
|
||||||
);
|
);
|
||||||
const width = getTextWidth(wrappedText, font);
|
const width = getTextWidth(wrappedText, font);
|
||||||
editable.style.width = `${width}px`;
|
editable.style.width = `${width}px`;
|
||||||
@ -394,7 +397,7 @@ export const textWysiwyg = ({
|
|||||||
const wrappedText = wrapText(
|
const wrappedText = wrapText(
|
||||||
normalizeText(editable.value),
|
normalizeText(editable.value),
|
||||||
font,
|
font,
|
||||||
getMaxContainerWidth(container!),
|
getBoundTextMaxWidth(container!),
|
||||||
);
|
);
|
||||||
const { width, height } = measureText(
|
const { width, height } = measureText(
|
||||||
wrappedText,
|
wrappedText,
|
||||||
|
@ -65,7 +65,7 @@ export const reconcileElements = (
|
|||||||
|
|
||||||
// Mark duplicate for removal as it'll be replaced with the remote element
|
// Mark duplicate for removal as it'll be replaced with the remote element
|
||||||
if (local) {
|
if (local) {
|
||||||
// Unless the ramote and local elements are the same element in which case
|
// Unless the remote and local elements are the same element in which case
|
||||||
// we need to keep it as we'd otherwise discard it from the resulting
|
// we need to keep it as we'd otherwise discard it from the resulting
|
||||||
// array.
|
// array.
|
||||||
if (local[0] === remoteElement) {
|
if (local[0] === remoteElement) {
|
||||||
|
@ -263,7 +263,7 @@ export const loadScene = async (
|
|||||||
await importFromBackend(id, privateKey),
|
await importFromBackend(id, privateKey),
|
||||||
localDataState?.appState,
|
localDataState?.appState,
|
||||||
localDataState?.elements,
|
localDataState?.elements,
|
||||||
{ repairBindings: true, refreshDimensions: true },
|
{ repairBindings: true, refreshDimensions: false },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
data = restore(localDataState || null, null, null, {
|
data = restore(localDataState || null, null, null, {
|
||||||
|
4
src/global.d.ts
vendored
4
src/global.d.ts
vendored
@ -18,8 +18,8 @@ interface Window {
|
|||||||
EXCALIDRAW_EXPORT_SOURCE: string;
|
EXCALIDRAW_EXPORT_SOURCE: string;
|
||||||
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
|
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
|
||||||
gtag: Function;
|
gtag: Function;
|
||||||
_paq: any[];
|
sa_event: Function;
|
||||||
_mtm: any[];
|
fathom: { trackEvent: Function };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CanvasRenderingContext2D {
|
interface CanvasRenderingContext2D {
|
||||||
|
@ -1,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(
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "كبير جدا",
|
"veryLarge": "كبير جدا",
|
||||||
"solid": "كامل",
|
"solid": "كامل",
|
||||||
"hachure": "خطوط",
|
"hachure": "خطوط",
|
||||||
|
"zigzag": "",
|
||||||
"crossHatch": "خطوط متقطعة",
|
"crossHatch": "خطوط متقطعة",
|
||||||
"thin": "نحيف",
|
"thin": "نحيف",
|
||||||
"bold": "داكن",
|
"bold": "داكن",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "Много голям",
|
"veryLarge": "Много голям",
|
||||||
"solid": "Солиден",
|
"solid": "Солиден",
|
||||||
"hachure": "Хералдика",
|
"hachure": "Хералдика",
|
||||||
|
"zigzag": "",
|
||||||
"crossHatch": "Двойно-пресечено",
|
"crossHatch": "Двойно-пресечено",
|
||||||
"thin": "Тънък",
|
"thin": "Тънък",
|
||||||
"bold": "Ясно очертан",
|
"bold": "Ясно очертан",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "অনেক বড়",
|
"veryLarge": "অনেক বড়",
|
||||||
"solid": "দৃঢ়",
|
"solid": "দৃঢ়",
|
||||||
"hachure": "ভ্রুলেখা",
|
"hachure": "ভ্রুলেখা",
|
||||||
|
"zigzag": "",
|
||||||
"crossHatch": "ক্রস হ্যাচ",
|
"crossHatch": "ক্রস হ্যাচ",
|
||||||
"thin": "পাতলা",
|
"thin": "পাতলা",
|
||||||
"bold": "পুরু",
|
"bold": "পুরু",
|
||||||
|
@ -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",
|
||||||
|
@ -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ý",
|
||||||
|
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "Πολύ μεγάλο",
|
"veryLarge": "Πολύ μεγάλο",
|
||||||
"solid": "Συμπαγής",
|
"solid": "Συμπαγής",
|
||||||
"hachure": "Εκκόλαψη",
|
"hachure": "Εκκόλαψη",
|
||||||
|
"zigzag": "",
|
||||||
"crossHatch": "Διασταυρούμενη εκκόλαψη",
|
"crossHatch": "Διασταυρούμενη εκκόλαψη",
|
||||||
"thin": "Λεπτή",
|
"thin": "Λεπτή",
|
||||||
"bold": "Έντονη",
|
"bold": "Έντονη",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "Very large",
|
"veryLarge": "Very large",
|
||||||
"solid": "Solid",
|
"solid": "Solid",
|
||||||
"hachure": "Hachure",
|
"hachure": "Hachure",
|
||||||
|
"zigzag": "Zigzag",
|
||||||
"crossHatch": "Cross-hatch",
|
"crossHatch": "Cross-hatch",
|
||||||
"thin": "Thin",
|
"thin": "Thin",
|
||||||
"bold": "Bold",
|
"bold": "Bold",
|
||||||
@ -207,19 +208,10 @@
|
|||||||
"collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.",
|
"collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.",
|
||||||
"collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.",
|
"collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.",
|
||||||
"brave_measure_text_error": {
|
"brave_measure_text_error": {
|
||||||
"start": "Looks like you are using Brave browser with the",
|
"line1": "Looks like you are using Brave browser with the <bold>Aggressively Block Fingerprinting</bold> setting enabled.",
|
||||||
"aggressive_block_fingerprint": "Aggressively Block Fingerprinting",
|
"line2": "This could result in breaking the <bold>Text Elements</bold> in your drawings.",
|
||||||
"setting_enabled": "setting enabled",
|
"line3": "We strongly recommend disabling this setting. You can follow <link>these steps</link> on how to do so.",
|
||||||
"break": "This could result in breaking the",
|
"line4": "If disabling this setting doesn't fix the display of text elements, please open an <issueLink>issue</issueLink> on our GitHub, or write us on <discordLink>Discord</discordLink>"
|
||||||
"text_elements": "Text Elements",
|
|
||||||
"in_your_drawings": "in your drawings",
|
|
||||||
"strongly_recommend": "We strongly recommend disabling this setting. You can follow",
|
|
||||||
"steps": "these steps",
|
|
||||||
"how": "on how to do so",
|
|
||||||
"disable_setting": " If disabling this setting doesn't fix the display of text elements, please open an",
|
|
||||||
"issue": "issue",
|
|
||||||
"write": "on our GitHub, or write us on",
|
|
||||||
"discord": "Discord"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
|
@ -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,8 +208,8 @@
|
|||||||
"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": "",
|
"start": "Parece que estás usando el navegador Brave",
|
||||||
"aggressive_block_fingerprint": "",
|
"aggressive_block_fingerprint": "Bloquear huellas dactilares agresivamente",
|
||||||
"setting_enabled": "ajuste activado",
|
"setting_enabled": "ajuste activado",
|
||||||
"break": "Esto podría resultar en romper los",
|
"break": "Esto podría resultar en romper los",
|
||||||
"text_elements": "Elementos de texto",
|
"text_elements": "Elementos de texto",
|
||||||
@ -319,8 +320,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",
|
||||||
|
@ -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",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "بسیار بزرگ",
|
"veryLarge": "بسیار بزرگ",
|
||||||
"solid": "توپر",
|
"solid": "توپر",
|
||||||
"hachure": "هاشور",
|
"hachure": "هاشور",
|
||||||
|
"zigzag": "",
|
||||||
"crossHatch": "هاشور متقاطع",
|
"crossHatch": "هاشور متقاطع",
|
||||||
"thin": "نازک",
|
"thin": "نازک",
|
||||||
"bold": "ضخیم",
|
"bold": "ضخیم",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "Erittäin suuri",
|
"veryLarge": "Erittäin suuri",
|
||||||
"solid": "Yhtenäinen",
|
"solid": "Yhtenäinen",
|
||||||
"hachure": "Vinoviivoitus",
|
"hachure": "Vinoviivoitus",
|
||||||
|
"zigzag": "",
|
||||||
"crossHatch": "Ristiviivoitus",
|
"crossHatch": "Ristiviivoitus",
|
||||||
"thin": "Ohut",
|
"thin": "Ohut",
|
||||||
"bold": "Lihavoitu",
|
"bold": "Lihavoitu",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "Très grande",
|
"veryLarge": "Très grande",
|
||||||
"solid": "Solide",
|
"solid": "Solide",
|
||||||
"hachure": "Hachures",
|
"hachure": "Hachures",
|
||||||
|
"zigzag": "",
|
||||||
"crossHatch": "Hachures croisées",
|
"crossHatch": "Hachures croisées",
|
||||||
"thin": "Fine",
|
"thin": "Fine",
|
||||||
"bold": "Épaisse",
|
"bold": "Épaisse",
|
||||||
@ -319,8 +320,8 @@
|
|||||||
"doubleClick": "double-clic",
|
"doubleClick": "double-clic",
|
||||||
"drag": "glisser",
|
"drag": "glisser",
|
||||||
"editor": "Éditeur",
|
"editor": "Éditeur",
|
||||||
"editLineArrowPoints": "",
|
"editLineArrowPoints": "Modifier les points de ligne/flèche",
|
||||||
"editText": "",
|
"editText": "Modifier le texte / ajouter un libellé",
|
||||||
"github": "Problème trouvé ? Soumettre",
|
"github": "Problème trouvé ? Soumettre",
|
||||||
"howto": "Suivez nos guides",
|
"howto": "Suivez nos guides",
|
||||||
"or": "ou",
|
"or": "ou",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "Moi grande",
|
"veryLarge": "Moi grande",
|
||||||
"solid": "Sólido",
|
"solid": "Sólido",
|
||||||
"hachure": "Folleto",
|
"hachure": "Folleto",
|
||||||
|
"zigzag": "",
|
||||||
"crossHatch": "Raiado transversal",
|
"crossHatch": "Raiado transversal",
|
||||||
"thin": "Estreito",
|
"thin": "Estreito",
|
||||||
"bold": "Groso",
|
"bold": "Groso",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "גדול מאוד",
|
"veryLarge": "גדול מאוד",
|
||||||
"solid": "מוצק",
|
"solid": "מוצק",
|
||||||
"hachure": "קווים מקבילים קצרים להצגת כיוון וחדות שיפוע במפה",
|
"hachure": "קווים מקבילים קצרים להצגת כיוון וחדות שיפוע במפה",
|
||||||
|
"zigzag": "",
|
||||||
"crossHatch": "קווים מוצלבים שתי וערב",
|
"crossHatch": "קווים מוצלבים שתי וערב",
|
||||||
"thin": "דק",
|
"thin": "דק",
|
||||||
"bold": "מודגש",
|
"bold": "מודגש",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "बहुत बड़ा",
|
"veryLarge": "बहुत बड़ा",
|
||||||
"solid": "दृढ़",
|
"solid": "दृढ़",
|
||||||
"hachure": "हैशूर",
|
"hachure": "हैशूर",
|
||||||
|
"zigzag": "तेढ़ी मेढ़ी",
|
||||||
"crossHatch": "क्रॉस हैच",
|
"crossHatch": "क्रॉस हैच",
|
||||||
"thin": "पतला",
|
"thin": "पतला",
|
||||||
"bold": "मोटा",
|
"bold": "मोटा",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "Nagyon nagy",
|
"veryLarge": "Nagyon nagy",
|
||||||
"solid": "Kitöltött",
|
"solid": "Kitöltött",
|
||||||
"hachure": "Vonalkázott",
|
"hachure": "Vonalkázott",
|
||||||
|
"zigzag": "",
|
||||||
"crossHatch": "Keresztcsíkozott",
|
"crossHatch": "Keresztcsíkozott",
|
||||||
"thin": "Vékony",
|
"thin": "Vékony",
|
||||||
"bold": "Félkövér",
|
"bold": "Félkövér",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "Sangat besar",
|
"veryLarge": "Sangat besar",
|
||||||
"solid": "Padat",
|
"solid": "Padat",
|
||||||
"hachure": "Garis-garis",
|
"hachure": "Garis-garis",
|
||||||
|
"zigzag": "",
|
||||||
"crossHatch": "Asiran silang",
|
"crossHatch": "Asiran silang",
|
||||||
"thin": "Lembut",
|
"thin": "Lembut",
|
||||||
"bold": "Tebal",
|
"bold": "Tebal",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "Molto grande",
|
"veryLarge": "Molto grande",
|
||||||
"solid": "Pieno",
|
"solid": "Pieno",
|
||||||
"hachure": "Tratteggio obliquo",
|
"hachure": "Tratteggio obliquo",
|
||||||
|
"zigzag": "Zig zag",
|
||||||
"crossHatch": "Tratteggio incrociato",
|
"crossHatch": "Tratteggio incrociato",
|
||||||
"thin": "Sottile",
|
"thin": "Sottile",
|
||||||
"bold": "Grassetto",
|
"bold": "Grassetto",
|
||||||
@ -319,7 +320,7 @@
|
|||||||
"doubleClick": "doppio-click",
|
"doubleClick": "doppio-click",
|
||||||
"drag": "trascina",
|
"drag": "trascina",
|
||||||
"editor": "Editor",
|
"editor": "Editor",
|
||||||
"editLineArrowPoints": "",
|
"editLineArrowPoints": "Modifica punti linea/freccia",
|
||||||
"editText": "Modifica testo / aggiungi etichetta",
|
"editText": "Modifica testo / aggiungi etichetta",
|
||||||
"github": "Trovato un problema? Segnalalo",
|
"github": "Trovato un problema? Segnalalo",
|
||||||
"howto": "Segui le nostre guide",
|
"howto": "Segui le nostre guide",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "特大",
|
"veryLarge": "特大",
|
||||||
"solid": "ベタ塗り",
|
"solid": "ベタ塗り",
|
||||||
"hachure": "斜線",
|
"hachure": "斜線",
|
||||||
|
"zigzag": "",
|
||||||
"crossHatch": "網掛け",
|
"crossHatch": "網掛け",
|
||||||
"thin": "細",
|
"thin": "細",
|
||||||
"bold": "太字",
|
"bold": "太字",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "Meqqer aṭas",
|
"veryLarge": "Meqqer aṭas",
|
||||||
"solid": "Aččuran",
|
"solid": "Aččuran",
|
||||||
"hachure": "Azerreg",
|
"hachure": "Azerreg",
|
||||||
|
"zigzag": "",
|
||||||
"crossHatch": "Azerreg anmidag",
|
"crossHatch": "Azerreg anmidag",
|
||||||
"thin": "Arqaq",
|
"thin": "Arqaq",
|
||||||
"bold": "Azuran",
|
"bold": "Azuran",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "Өте үлкен",
|
"veryLarge": "Өте үлкен",
|
||||||
"solid": "",
|
"solid": "",
|
||||||
"hachure": "",
|
"hachure": "",
|
||||||
|
"zigzag": "",
|
||||||
"crossHatch": "",
|
"crossHatch": "",
|
||||||
"thin": "",
|
"thin": "",
|
||||||
"bold": "",
|
"bold": "",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"veryLarge": "매우 크게",
|
"veryLarge": "매우 크게",
|
||||||
"solid": "단색",
|
"solid": "단색",
|
||||||
"hachure": "평행선",
|
"hachure": "평행선",
|
||||||
|
"zigzag": "지그재그",
|
||||||
"crossHatch": "교차선",
|
"crossHatch": "교차선",
|
||||||
"thin": "얇게",
|
"thin": "얇게",
|
||||||
"bold": "굵게",
|
"bold": "굵게",
|
||||||
@ -256,7 +257,7 @@
|
|||||||
"resize": "SHIFT 키를 누르면서 조정하면 크기의 비율이 제한됩니다.\nALT를 누르면서 조정하면 중앙을 기준으로 크기를 조정합니다.",
|
"resize": "SHIFT 키를 누르면서 조정하면 크기의 비율이 제한됩니다.\nALT를 누르면서 조정하면 중앙을 기준으로 크기를 조정합니다.",
|
||||||
"resizeImage": "SHIFT를 눌러서 자유롭게 크기를 변경하거나,\nALT를 눌러서 중앙을 고정하고 크기를 변경하기",
|
"resizeImage": "SHIFT를 눌러서 자유롭게 크기를 변경하거나,\nALT를 눌러서 중앙을 고정하고 크기를 변경하기",
|
||||||
"rotate": "SHIFT 키를 누르면서 회전하면 각도를 제한할 수 있습니다.",
|
"rotate": "SHIFT 키를 누르면서 회전하면 각도를 제한할 수 있습니다.",
|
||||||
"lineEditor_info": "포인트를 편집하려면 Ctrl/Cmd을 누르고 더블 클릭을 하거나 Ctrl/Cmd + Enter를 누르세요",
|
"lineEditor_info": "꼭짓점을 수정하려면 CtrlOrCmd 키를 누르고 더블 클릭을 하거나 CtrlOrCmd + Enter를 누르세요.",
|
||||||
"lineEditor_pointSelected": "Delete 키로 꼭짓점을 제거하거나,\nCtrlOrCmd+D 로 복제하거나, 드래그 해서 이동시키기",
|
"lineEditor_pointSelected": "Delete 키로 꼭짓점을 제거하거나,\nCtrlOrCmd+D 로 복제하거나, 드래그 해서 이동시키기",
|
||||||
"lineEditor_nothingSelected": "꼭짓점을 선택해서 수정하거나 (SHIFT를 눌러서 여러개 선택),\nAlt를 누르고 클릭해서 새로운 꼭짓점 추가하기",
|
"lineEditor_nothingSelected": "꼭짓점을 선택해서 수정하거나 (SHIFT를 눌러서 여러개 선택),\nAlt를 누르고 클릭해서 새로운 꼭짓점 추가하기",
|
||||||
"placeImage": "클릭해서 이미지를 배치하거나, 클릭하고 드래그해서 사이즈를 조정하기",
|
"placeImage": "클릭해서 이미지를 배치하거나, 클릭하고 드래그해서 사이즈를 조정하기",
|
||||||
@ -319,8 +320,8 @@
|
|||||||
"doubleClick": "더블 클릭",
|
"doubleClick": "더블 클릭",
|
||||||
"drag": "드래그",
|
"drag": "드래그",
|
||||||
"editor": "에디터",
|
"editor": "에디터",
|
||||||
"editLineArrowPoints": "",
|
"editLineArrowPoints": "직선 / 화살표 꼭짓점 수정",
|
||||||
"editText": "",
|
"editText": "텍스트 수정 / 라벨 추가",
|
||||||
"github": "문제 제보하기",
|
"github": "문제 제보하기",
|
||||||
"howto": "가이드 참고하기",
|
"howto": "가이드 참고하기",
|
||||||
"or": "또는",
|
"or": "또는",
|
||||||
@ -382,8 +383,8 @@
|
|||||||
},
|
},
|
||||||
"publishSuccessDialog": {
|
"publishSuccessDialog": {
|
||||||
"title": "라이브러리 제출됨",
|
"title": "라이브러리 제출됨",
|
||||||
"content": "{{authorName}}님 감사합니다. 당신의 라이브러리가 심사를 위해 제출되었습니다. 진행 상황을 다음의 링크에서 확인할 수 있습니다.",
|
"content": "{{authorName}}님 감사합니다. 당신의 라이브러리가 심사를 위해 제출되었습니다. 진행 상황을",
|
||||||
"link": "여기"
|
"link": "여기에서 확인하실 수 있습니다."
|
||||||
},
|
},
|
||||||
"confirmDialog": {
|
"confirmDialog": {
|
||||||
"resetLibrary": "라이브러리 리셋",
|
"resetLibrary": "라이브러리 리셋",
|
||||||
|
@ -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,22 @@
|
|||||||
"invalidSVGString": "ئێس ڤی جی نادروستە.",
|
"invalidSVGString": "ئێس ڤی جی نادروستە.",
|
||||||
"cannotResolveCollabServer": "ناتوانێت پەیوەندی بکات بە سێرڤەری کۆلاب. تکایە لاپەڕەکە دووبارە باربکەوە و دووبارە هەوڵ بدەوە.",
|
"cannotResolveCollabServer": "ناتوانێت پەیوەندی بکات بە سێرڤەری کۆلاب. تکایە لاپەڕەکە دووبارە باربکەوە و دووبارە هەوڵ بدەوە.",
|
||||||
"importLibraryError": "نەیتوانی کتێبخانە بار بکات",
|
"importLibraryError": "نەیتوانی کتێبخانە بار بکات",
|
||||||
"collabSaveFailed": "",
|
"collabSaveFailed": "نەتوانرا لە بنکەدراوەی ڕاژەدا پاشەکەوت بکرێت. ئەگەر کێشەکان بەردەوام بوون، پێویستە فایلەکەت لە ناوخۆدا هەڵبگریت بۆ ئەوەی دڵنیا بیت کە کارەکانت لەدەست نادەیت.",
|
||||||
"collabSaveFailed_sizeExceeded": "",
|
"collabSaveFailed_sizeExceeded": "نەتوانرا لە بنکەدراوەی ڕاژەدا پاشەکەوت بکرێت، پێدەچێت تابلۆکە زۆر گەورە بێت. پێویستە فایلەکە لە ناوخۆدا هەڵبگریت بۆ ئەوەی دڵنیا بیت کە کارەکانت لەدەست نادەیت.",
|
||||||
"brave_measure_text_error": {
|
"brave_measure_text_error": {
|
||||||
"start": "",
|
"start": "پێدەچێت وێبگەڕی Brave بەکاربهێنیت لەگەڵ",
|
||||||
"aggressive_block_fingerprint": "",
|
"aggressive_block_fingerprint": "بلۆککردنی Fingerprinting بەشێوەیەکی توندوتیژانە",
|
||||||
"setting_enabled": "",
|
"setting_enabled": "ڕێکخستن چالاک کراوە",
|
||||||
"break": "",
|
"break": "ئەمە دەکرێت ببێتە هۆی تێکدانی",
|
||||||
"text_elements": "",
|
"text_elements": "دانە دەقییەکان",
|
||||||
"in_your_drawings": "",
|
"in_your_drawings": "لە وێنەکێشانەکانتدا",
|
||||||
"strongly_recommend": "",
|
"strongly_recommend": "بە توندی پێشنیار دەکەین ئەم ڕێکخستنە لەکاربخەیت. دەتوانیت بڕۆیت بە دوای",
|
||||||
"steps": "",
|
"steps": "ئەم هەنگاوانەدا",
|
||||||
"how": "",
|
"how": "بۆ ئەوەی ئەنجامی بدەیت",
|
||||||
"disable_setting": "",
|
"disable_setting": " ئەگەر لەکارخستنی ئەم ڕێکخستنە پیشاندانی توخمەکانی دەق چاک نەکاتەوە، تکایە هەڵبستە بە کردنەوەی",
|
||||||
"issue": "",
|
"issue": "کێشەیەک",
|
||||||
"write": "",
|
"write": "لەسەر گیتهەبەکەمان، یان بۆمان بنوسە لە",
|
||||||
"discord": ""
|
"discord": "دیسکۆرد"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
@ -237,7 +238,7 @@
|
|||||||
"penMode": "شێوازی قەڵەم - دەست لێدان ڕابگرە",
|
"penMode": "شێوازی قەڵەم - دەست لێدان ڕابگرە",
|
||||||
"link": "زیادکردن/ نوێکردنەوەی لینک بۆ شێوەی دیاریکراو",
|
"link": "زیادکردن/ نوێکردنەوەی لینک بۆ شێوەی دیاریکراو",
|
||||||
"eraser": "سڕەر",
|
"eraser": "سڕەر",
|
||||||
"hand": ""
|
"hand": "دەست (ئامرازی پانکردن)"
|
||||||
},
|
},
|
||||||
"headings": {
|
"headings": {
|
||||||
"canvasActions": "کردارەکانی تابلۆ",
|
"canvasActions": "کردارەکانی تابلۆ",
|
||||||
@ -245,7 +246,7 @@
|
|||||||
"shapes": "شێوەکان"
|
"shapes": "شێوەکان"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
"canvasPanning": "",
|
"canvasPanning": "بۆ جوڵاندنی تابلۆ، ویلی ماوسەکەت یان دوگمەی سپەیس بگرە لەکاتی ڕاکێشاندە، یانیش ئامرازی دەستەکە بەکاربهێنە",
|
||||||
"linearElement": "کرتە بکە بۆ دەستپێکردنی چەند خاڵێک، ڕایبکێشە بۆ یەک هێڵ",
|
"linearElement": "کرتە بکە بۆ دەستپێکردنی چەند خاڵێک، ڕایبکێشە بۆ یەک هێڵ",
|
||||||
"freeDraw": "کرتە بکە و ڕایبکێشە، کاتێک تەواو بوویت دەست هەڵگرە",
|
"freeDraw": "کرتە بکە و ڕایبکێشە، کاتێک تەواو بوویت دەست هەڵگرە",
|
||||||
"text": "زانیاری: هەروەها دەتوانیت دەق زیادبکەیت بە دوو کرتەکردن لە هەر شوێنێک لەگەڵ ئامڕازی دەستنیشانکردن",
|
"text": "زانیاری: هەروەها دەتوانیت دەق زیادبکەیت بە دوو کرتەکردن لە هەر شوێنێک لەگەڵ ئامڕازی دەستنیشانکردن",
|
||||||
@ -256,7 +257,7 @@
|
|||||||
"resize": "دەتوانیت ڕێژەکان سنووردار بکەیت بە ڕاگرتنی SHIFT لەکاتی گۆڕینی قەبارەدا،\nALT ڕابگرە بۆ گۆڕینی قەبارە لە ناوەندەوە",
|
"resize": "دەتوانیت ڕێژەکان سنووردار بکەیت بە ڕاگرتنی SHIFT لەکاتی گۆڕینی قەبارەدا،\nALT ڕابگرە بۆ گۆڕینی قەبارە لە ناوەندەوە",
|
||||||
"resizeImage": "دەتوانیت بە ئازادی قەبارە بگۆڕیت بە ڕاگرتنی SHIFT،\nALT ڕابگرە بۆ گۆڕینی قەبارە لە ناوەندەوە",
|
"resizeImage": "دەتوانیت بە ئازادی قەبارە بگۆڕیت بە ڕاگرتنی SHIFT،\nALT ڕابگرە بۆ گۆڕینی قەبارە لە ناوەندەوە",
|
||||||
"rotate": "دەتوانیت گۆشەکان سنووردار بکەیت بە ڕاگرتنی SHIFT لەکاتی سوڕانەوەدا",
|
"rotate": "دەتوانیت گۆشەکان سنووردار بکەیت بە ڕاگرتنی SHIFT لەکاتی سوڕانەوەدا",
|
||||||
"lineEditor_info": "",
|
"lineEditor_info": "یان Ctrl یان Cmd بگرە و دوانە کلیک بکە یانیش پەنجە بنێ بە Ctrl یان Cmd + ئینتەر بۆ دەستکاریکردنی خاڵەکان",
|
||||||
"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 +265,7 @@
|
|||||||
"bindTextToElement": "بۆ زیادکردنی دەق enter بکە",
|
"bindTextToElement": "بۆ زیادکردنی دەق enter بکە",
|
||||||
"deepBoxSelect": "CtrlOrCmd ڕابگرە بۆ هەڵبژاردنی قووڵ، و بۆ ڕێگریکردن لە ڕاکێشان",
|
"deepBoxSelect": "CtrlOrCmd ڕابگرە بۆ هەڵبژاردنی قووڵ، و بۆ ڕێگریکردن لە ڕاکێشان",
|
||||||
"eraserRevert": "بۆ گەڕاندنەوەی ئەو توخمانەی کە بۆ سڕینەوە نیشانە کراون، Alt ڕابگرە",
|
"eraserRevert": "بۆ گەڕاندنەوەی ئەو توخمانەی کە بۆ سڕینەوە نیشانە کراون، Alt ڕابگرە",
|
||||||
"firefox_clipboard_write": ""
|
"firefox_clipboard_write": "ئەم تایبەتمەندییە بە ئەگەرێکی زۆرەوە دەتوانرێت چالاک بکرێت بە ڕێکخستنی ئاڵای \"dom.events.asyncClipboard.clipboardItem\" بۆ \"true\". بۆ گۆڕینی ئاڵاکانی وێبگەڕ لە فایەرفۆکسدا، سەردانی لاپەڕەی \"about:config\" بکە."
|
||||||
},
|
},
|
||||||
"canvasError": {
|
"canvasError": {
|
||||||
"cannotShowPreview": "ناتوانرێ پێشبینین پیشان بدرێت",
|
"cannotShowPreview": "ناتوانرێ پێشبینین پیشان بدرێت",
|
||||||
@ -319,8 +320,8 @@
|
|||||||
"doubleClick": "دوو گرتە",
|
"doubleClick": "دوو گرتە",
|
||||||
"drag": "راکێشان",
|
"drag": "راکێشان",
|
||||||
"editor": "دەستکاریکەر",
|
"editor": "دەستکاریکەر",
|
||||||
"editLineArrowPoints": "",
|
"editLineArrowPoints": "دەستکاری خاڵەکانی هێڵ/تیر بکە",
|
||||||
"editText": "",
|
"editText": "دەستکاری دەق بکە / لەیبڵێک زیاد بکە",
|
||||||
"github": "کێشەیەکت دۆزیەوە؟ پێشکەشکردن",
|
"github": "کێشەیەکت دۆزیەوە؟ پێشکەشکردن",
|
||||||
"howto": "شوێن ڕینماییەکانمان بکەوە",
|
"howto": "شوێن ڕینماییەکانمان بکەوە",
|
||||||
"or": "یان",
|
"or": "یان",
|
||||||
@ -334,8 +335,8 @@
|
|||||||
"zoomToFit": "زووم بکە بۆ ئەوەی لەگەڵ هەموو توخمەکاندا بگونجێت",
|
"zoomToFit": "زووم بکە بۆ ئەوەی لەگەڵ هەموو توخمەکاندا بگونجێت",
|
||||||
"zoomToSelection": "زووم بکە بۆ دەستنیشانکراوەکان",
|
"zoomToSelection": "زووم بکە بۆ دەستنیشانکراوەکان",
|
||||||
"toggleElementLock": "قفڵ/کردنەوەی دەستنیشانکراوەکان",
|
"toggleElementLock": "قفڵ/کردنەوەی دەستنیشانکراوەکان",
|
||||||
"movePageUpDown": "",
|
"movePageUpDown": "لاپەڕەکە بجوڵێنە بۆ سەرەوە/خوارەوە",
|
||||||
"movePageLeftRight": ""
|
"movePageLeftRight": "لاپەڕەکە بجوڵێنە بۆ چەپ/ڕاست"
|
||||||
},
|
},
|
||||||
"clearCanvasDialog": {
|
"clearCanvasDialog": {
|
||||||
"title": "تابلۆکە خاوێن بکەرەوە"
|
"title": "تابلۆکە خاوێن بکەرەوە"
|
||||||
@ -417,7 +418,7 @@
|
|||||||
"fileSavedToFilename": "هەڵگیراوە بۆ {filename}",
|
"fileSavedToFilename": "هەڵگیراوە بۆ {filename}",
|
||||||
"canvas": "تابلۆ",
|
"canvas": "تابلۆ",
|
||||||
"selection": "دەستنیشانکراوەکان",
|
"selection": "دەستنیشانکراوەکان",
|
||||||
"pasteAsSingleElement": ""
|
"pasteAsSingleElement": "بۆ دانانەوە وەکو یەک توخم یان دانانەوە بۆ نێو دەسکاریکەرێکی دەق کە بوونی هەیە {{shortcut}} بەکاربهێنە"
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"ffffff": "سپی",
|
"ffffff": "سپی",
|
||||||
@ -468,15 +469,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
Loading…
x
Reference in New Issue
Block a user