Compare commits

..

14 Commits

Author SHA1 Message Date
Arun Kumar eb206cc932 Fix name input style in dark mode 2021-03-20 16:02:15 +05:30
Aakansha Doshi 16c287c848 Give preference to name prop when initialData.appState.name is present and update specs 2021-03-20 14:25:02 +05:30
Aakansha Doshi 78024873e5 no border and on hover no background change 2021-03-20 13:52:15 +05:30
Aakansha Doshi 4e41bd9dbb Merge remote-tracking branch 'origin/master' into updatescene-name 2021-03-20 13:43:01 +05:30
Aakansha Doshi edc23b854f minor fixes 2021-03-20 13:41:12 +05:30
Arun Kumar 4843c49556 remove eempty lines 2021-03-20 01:15:01 +05:30
Arun Kumar d565413082 Add tests, update changelog and minor fixes 2021-03-20 01:07:49 +05:30
Aakansha Doshi dcda7184d0 Merge remote-tracking branch 'origin/master' into updatescene-name 2021-03-20 00:24:24 +05:30
Arun Kumar 8d413670c8 Remove redundant if statement 2021-03-19 01:32:14 +05:30
Arun Kumar f774452124 Remove customName from state 2021-03-19 01:30:27 +05:30
Arun Kumar db4ed1ecb1 Make requested changes 2021-03-18 22:49:37 +05:30
Arun Kumar 489f45b910 Make requested changes 2021-03-18 16:36:12 +05:30
Arun Kumar a17be085b0 Revert "Allow updating name on updateScene"
This reverts commit 4e07a608d3.
2021-03-18 16:06:28 +05:30
Arun Kumar 4e07a608d3 Allow updating name on updateScene 2021-03-18 01:04:02 +05:30
151 changed files with 7258 additions and 11220 deletions
-1
View File
@@ -20,4 +20,3 @@ package-lock.json
static static
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
src/packages/excalidraw/types
+15 -15
View File
@@ -19,20 +19,20 @@
] ]
}, },
"dependencies": { "dependencies": {
"@sentry/browser": "6.2.5", "@sentry/browser": "6.2.2",
"@sentry/integrations": "6.2.5", "@sentry/integrations": "6.2.1",
"@testing-library/jest-dom": "5.11.10", "@testing-library/jest-dom": "5.11.9",
"@testing-library/react": "11.2.6", "@testing-library/react": "11.2.5",
"@types/jest": "26.0.22", "@types/jest": "26.0.20",
"@types/react": "17.0.3", "@types/react": "17.0.2",
"@types/react-dom": "17.0.3", "@types/react-dom": "17.0.1",
"@types/socket.io-client": "1.4.36", "@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.16.4", "browser-fs-access": "0.14.2",
"clsx": "1.1.1", "clsx": "1.1.1",
"firebase": "8.3.2", "firebase": "8.2.10",
"i18next-browser-languagedetector": "6.1.0", "i18next-browser-languagedetector": "6.0.1",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"nanoid": "3.1.22", "nanoid": "3.1.21",
"open-color": "1.8.0", "open-color": "1.8.0",
"pako": "1.0.11", "pako": "1.0.11",
"png-chunk-text": "1.0.0", "png-chunk-text": "1.0.0",
@@ -40,13 +40,13 @@
"png-chunks-extract": "1.0.0", "png-chunks-extract": "1.0.0",
"points-on-curve": "0.2.0", "points-on-curve": "0.2.0",
"pwacompat": "2.0.17", "pwacompat": "2.0.17",
"react": "17.0.2", "react": "17.0.1",
"react-dom": "17.0.2", "react-dom": "17.0.1",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"roughjs": "4.3.1", "roughjs": "4.3.1",
"sass": "1.32.8", "sass": "1.32.8",
"socket.io-client": "2.3.1", "socket.io-client": "2.3.1",
"typescript": "4.2.4" "typescript": "4.2.3"
}, },
"devDependencies": { "devDependencies": {
"@excalidraw/eslint-config": "1.0.0", "@excalidraw/eslint-config": "1.0.0",
@@ -56,7 +56,7 @@
"@types/resize-observer-browser": "0.1.5", "@types/resize-observer-browser": "0.1.5",
"eslint-config-prettier": "8.1.0", "eslint-config-prettier": "8.1.0",
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.9.0", "firebase-tools": "9.6.1",
"husky": "4.3.8", "husky": "4.3.8",
"jest-canvas-mock": "2.3.1", "jest-canvas-mock": "2.3.1",
"lint-staged": "10.5.4", "lint-staged": "10.5.4",
+2 -6
View File
@@ -51,7 +51,8 @@
name="twitter:description" name="twitter:description"
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/> />
<!-- OG tags require absolute url for images -->
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" /> <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
<!-- Excalidraw version --> <!-- Excalidraw version -->
@@ -87,8 +88,6 @@
<link rel="stylesheet" href="fonts.css" type="text/css" /> <link rel="stylesheet" href="fonts.css" type="text/css" />
<script> <script>
window.EXCALIDRAW_ASSET_PATH = "/"; window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script> </script>
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %> <% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script <script
@@ -147,9 +146,6 @@
color: var(--popup-text-color); color: var(--popup-text-color);
font-size: 1.3em; font-size: 1.3em;
} }
#root {
height: 100%;
}
</style> </style>
</head> </head>
+1 -33
View File
@@ -39,37 +39,5 @@
} }
] ]
} }
}, }
"screenshots": [
{
"src": "/screenshots/virtual-whiteboard.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/wireframe.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/illustration.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/shapes.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/collaboration.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/export.png",
"type": "image/png",
"sizes": "462x945"
}
]
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

+1 -3
View File
@@ -8,7 +8,7 @@ import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import useIsMobile from "../is-mobile";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { getNormalizedZoom, getSelectedElements } from "../scene"; import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
@@ -33,7 +33,6 @@ export const actionChangeViewBackgroundColor = register({
type="canvasBackground" type="canvasBackground"
color={appState.viewBackgroundColor} color={appState.viewBackgroundColor}
onChange={(color) => updateData(color)} onChange={(color) => updateData(color)}
data-testid="canvas-background-picker"
/> />
</div> </div>
); );
@@ -73,7 +72,6 @@ export const actionClearCanvas = register({
updateData(null); updateData(null);
} }
}} }}
data-testid="clear-canvas-button"
/> />
), ),
}); });
+5 -9
View File
@@ -8,10 +8,9 @@ import { Tooltip } from "../components/Tooltip";
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle"; import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data"; import { loadFromJSON, saveAsJSON } from "../data";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import useIsMobile from "../is-mobile";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { supported } from "browser-fs-access";
export const actionChangeProjectName = register({ export const actionChangeProjectName = register({
name: "changeProjectName", name: "changeProjectName",
@@ -24,9 +23,7 @@ export const actionChangeProjectName = register({
label={t("labels.fileTitle")} label={t("labels.fileTitle")}
value={appState.name || "Unnamed"} value={appState.name || "Unnamed"}
onChange={(name: string) => updateData(name)} onChange={(name: string) => updateData(name)}
isNameEditable={ isNameEditable={typeof appProps.name === "undefined"}
typeof appProps.name === "undefined" && !appState.viewModeEnabled
}
/> />
), ),
}); });
@@ -136,7 +133,6 @@ export const actionSaveScene = register({
aria-label={t("buttons.save")} aria-label={t("buttons.save")}
showAriaLabel={useIsMobile()} showAriaLabel={useIsMobile()}
onClick={() => updateData(null)} onClick={() => updateData(null)}
data-testid="save-button"
/> />
), ),
}); });
@@ -166,9 +162,10 @@ export const actionSaveAsScene = register({
title={t("buttons.saveAs")} title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")} aria-label={t("buttons.saveAs")}
showAriaLabel={useIsMobile()} showAriaLabel={useIsMobile()}
hidden={!supported} hidden={
!("chooseFileSystemEntries" in window || "showOpenFilePicker" in window)
}
onClick={() => updateData(null)} onClick={() => updateData(null)}
data-testid="save-as-button"
/> />
), ),
}); });
@@ -206,7 +203,6 @@ export const actionLoadScene = register({
aria-label={t("buttons.load")} aria-label={t("buttons.load")}
showAriaLabel={useIsMobile()} showAriaLabel={useIsMobile()}
onClick={updateData} onClick={updateData}
data-testid="load-button"
/> />
), ),
}); });
-207
View File
@@ -1,207 +0,0 @@
import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getElementMap, getNonDeletedElements } from "../element";
import { mutateElement } from "../element/mutateElement";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
import { AppState } from "../types";
import { getTransformHandles } from "../element/transformHandles";
import { isLinearElement } from "../element/typeChecks";
import { updateBoundElements } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor";
const enableActionFlipHorizontal = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const eligibleElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return eligibleElements.length === 1 && eligibleElements[0].type !== "text";
};
const enableActionFlipVertical = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const eligibleElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return eligibleElements.length === 1;
};
export const actionFlipHorizontal = register({
name: "flipHorizontal",
perform: (elements, appState) => {
return {
elements: flipSelectedElements(elements, appState, "horizontal"),
appState,
commitToHistory: true,
};
},
keyTest: (event) => event.shiftKey && event.code === "KeyH",
contextItemLabel: "labels.flipHorizontal",
contextItemPredicate: (elements, appState) =>
enableActionFlipHorizontal(elements, appState),
});
export const actionFlipVertical = register({
name: "flipVertical",
perform: (elements, appState) => {
return {
elements: flipSelectedElements(elements, appState, "vertical"),
appState,
commitToHistory: true,
};
},
keyTest: (event) => event.shiftKey && event.code === "KeyV",
contextItemLabel: "labels.flipVertical",
contextItemPredicate: (elements, appState) =>
enableActionFlipVertical(elements, appState),
});
const flipSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
flipDirection: "horizontal" | "vertical",
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
// remove once we allow for groups of elements to be flipped
if (selectedElements.length > 1) {
return elements;
}
const updatedElements = flipElements(
selectedElements,
appState,
flipDirection,
);
const updatedElementsMap = getElementMap(updatedElements);
return elements.map((element) => updatedElementsMap[element.id] || element);
};
const flipElements = (
elements: NonDeleted<ExcalidrawElement>[],
appState: AppState,
flipDirection: "horizontal" | "vertical",
): ExcalidrawElement[] => {
for (let i = 0; i < elements.length; i++) {
flipElement(elements[i], appState);
// If vertical flip, rotate an extra 180
if (flipDirection === "vertical") {
rotateElement(elements[i], Math.PI);
}
}
return elements;
};
const flipElement = (
element: NonDeleted<ExcalidrawElement>,
appState: AppState,
) => {
const originalX = element.x;
const originalY = element.y;
const width = element.width;
const height = element.height;
const originalAngle = normalizeAngle(element.angle);
let finalOffsetX = 0;
if (isLinearElement(element)) {
finalOffsetX =
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
element.width;
}
// Rotate back to zero, if necessary
mutateElement(element, {
angle: normalizeAngle(0),
});
// Flip unrotated by pulling TransformHandle to opposite side
const transformHandles = getTransformHandles(element, appState.zoom);
let usingNWHandle = true;
let newNCoordsX = 0;
let nHandle = transformHandles.nw;
if (!nHandle) {
// Use ne handle instead
usingNWHandle = false;
nHandle = transformHandles.ne;
if (!nHandle) {
mutateElement(element, {
angle: originalAngle,
});
return;
}
}
if (isLinearElement(element)) {
for (let i = 1; i < element.points.length; i++) {
LinearElementEditor.movePoint(element, i, [
-element.points[i][0],
element.points[i][1],
]);
}
LinearElementEditor.normalizePoints(element);
} else {
// calculate new x-coord for transformation
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
resizeSingleElement(
element,
true,
element,
usingNWHandle ? "nw" : "ne",
false,
newNCoordsX,
nHandle[1],
);
// fix the size to account for handle sizes
mutateElement(element, {
width,
height,
});
}
// Rotate by (360 degrees - original angle)
let angle = normalizeAngle(2 * Math.PI - originalAngle);
if (angle < 0) {
// check, probably unnecessary
angle = normalizeAngle(angle + 2 * Math.PI);
}
mutateElement(element, {
angle,
});
// Move back to original spot to appear "flipped in place"
mutateElement(element, {
x: originalX + finalOffsetX,
y: originalY,
});
updateBoundElements(element);
};
const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
const originalX = element.x;
const originalY = element.y;
let angle = normalizeAngle(element.angle + rotationAngle);
if (angle < 0) {
// check, probably unnecessary
angle = normalizeAngle(2 * Math.PI + angle);
}
mutateElement(element, {
angle,
});
// Move back to original spot
mutateElement(element, {
x: originalX,
y: originalY,
});
};
+15 -64
View File
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { AppState } from "../../src/types"; import { AppState } from "../../src/types";
import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ButtonSelect } from "../components/ButtonSelect";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
import { import {
@@ -20,16 +21,6 @@ import {
StrokeStyleDottedIcon, StrokeStyleDottedIcon,
StrokeStyleSolidIcon, StrokeStyleSolidIcon,
StrokeWidthIcon, StrokeWidthIcon,
FontSizeSmallIcon,
FontSizeMediumIcon,
FontSizeLargeIcon,
FontSizeExtraLargeIcon,
FontFamilyHandDrawnIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon,
TextAlignCenterIcon,
TextAlignRightIcon,
} from "../components/icons"; } from "../components/icons";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants"; import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
import { import {
@@ -422,29 +413,13 @@ export const actionChangeFontSize = register({
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
<legend>{t("labels.fontSize")}</legend> <legend>{t("labels.fontSize")}</legend>
<ButtonIconSelect <ButtonSelect
group="font-size" group="font-size"
options={[ options={[
{ { value: 16, text: t("labels.small") },
value: 16, { value: 20, text: t("labels.medium") },
text: t("labels.small"), { value: 28, text: t("labels.large") },
icon: <FontSizeSmallIcon theme={appState.theme} />, { value: 36, text: t("labels.veryLarge") },
},
{
value: 20,
text: t("labels.medium"),
icon: <FontSizeMediumIcon theme={appState.theme} />,
},
{
value: 28,
text: t("labels.large"),
icon: <FontSizeLargeIcon theme={appState.theme} />,
},
{
value: 36,
text: t("labels.veryLarge"),
icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
},
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
@@ -481,28 +456,16 @@ export const actionChangeFontFamily = register({
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
const options: { value: FontFamily; text: string; icon: JSX.Element }[] = [ const options: { value: FontFamily; text: string }[] = [
{ { value: 1, text: t("labels.handDrawn") },
value: 1, { value: 2, text: t("labels.normal") },
text: t("labels.handDrawn"), { value: 3, text: t("labels.code") },
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
},
{
value: 2,
text: t("labels.normal"),
icon: <FontFamilyNormalIcon theme={appState.theme} />,
},
{
value: 3,
text: t("labels.code"),
icon: <FontFamilyCodeIcon theme={appState.theme} />,
},
]; ];
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.fontFamily")}</legend> <legend>{t("labels.fontFamily")}</legend>
<ButtonIconSelect<FontFamily | false> <ButtonSelect<FontFamily | false>
group="font-family" group="font-family"
options={options} options={options}
value={getFormValue( value={getFormValue(
@@ -543,24 +506,12 @@ export const actionChangeTextAlign = register({
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
<legend>{t("labels.textAlign")}</legend> <legend>{t("labels.textAlign")}</legend>
<ButtonIconSelect<TextAlign | false> <ButtonSelect<TextAlign | false>
group="text-align" group="text-align"
options={[ options={[
{ { value: "left", text: t("labels.left") },
value: "left", { value: "center", text: t("labels.center") },
text: t("labels.left"), { value: "right", text: t("labels.right") },
icon: <TextAlignLeftIcon theme={appState.theme} />,
},
{
value: "center",
text: t("labels.center"),
icon: <TextAlignCenterIcon theme={appState.theme} />,
},
{
value: "right",
text: t("labels.right"),
icon: <TextAlignRightIcon theme={appState.theme} />,
},
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
-2
View File
@@ -66,8 +66,6 @@ export {
distributeVertically, distributeVertically,
} from "./actionDistribute"; } from "./actionDistribute";
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
export { export {
actionCopy, actionCopy,
actionCut, actionCut,
+3 -15
View File
@@ -7,12 +7,12 @@ import {
ActionResult, ActionResult,
} from "./types"; } from "./types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppProps, AppState } from "../types"; import { AppState, ExcalidrawProps } from "../types";
import { MODES } from "../constants"; import { MODES } from "../constants";
// This is the <App> component, but for now we don't care about anything but its // This is the <App> component, but for now we don't care about anything but its
// `canvas` state. // `canvas` state.
type App = { canvas: HTMLCanvasElement | null; props: AppProps }; type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
export class ActionManager implements ActionsManagerInterface { export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"]; actions = {} as ActionsManagerInterface["actions"];
@@ -52,14 +52,10 @@ export class ActionManager implements ActionsManagerInterface {
} }
handleKeyDown(event: KeyboardEvent) { handleKeyDown(event: KeyboardEvent) {
const canvasActions = this.app.props.UIOptions.canvasActions;
const data = Object.values(this.actions) const data = Object.values(this.actions)
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0)) .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
.filter( .filter(
(action) => (action) =>
(action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions]
: true) &&
action.keyTest && action.keyTest &&
action.keyTest( action.keyTest(
event, event,
@@ -106,15 +102,7 @@ export class ActionManager implements ActionsManagerInterface {
// like the user list. We can use this key to extract more // like the user list. We can use this key to extract more
// data from app state. This is an alternative to generic prop hell! // data from app state. This is an alternative to generic prop hell!
renderAction = (name: ActionName, id?: string) => { renderAction = (name: ActionName, id?: string) => {
const canvasActions = this.app.props.UIOptions.canvasActions; if (this.actions[name] && "PanelComponent" in this.actions[name]) {
if (
this.actions[name] &&
"PanelComponent" in this.actions[name] &&
(name in canvasActions
? canvasActions[name as keyof typeof canvasActions]
: true)
) {
const action = this.actions[name]; const action = this.actions[name];
const PanelComponent = action.PanelComponent!; const PanelComponent = action.PanelComponent!;
const updateData = (formState?: any) => { const updateData = (formState?: any) => {
+1 -5
View File
@@ -23,9 +23,7 @@ export type ShortcutName =
| "zenMode" | "zenMode"
| "stats" | "stats"
| "addToLibrary" | "addToLibrary"
| "viewMode" | "viewMode";
| "flipHorizontal"
| "flipVertical";
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")], cut: [getShortcutKey("CtrlOrCmd+X")],
@@ -59,8 +57,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
zenMode: [getShortcutKey("Alt+Z")], zenMode: [getShortcutKey("Alt+Z")],
stats: [], stats: [],
addToLibrary: [], addToLibrary: [],
flipHorizontal: [getShortcutKey("Shift+H")],
flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")], viewMode: [getShortcutKey("Alt+R")],
}; };
+1 -6
View File
@@ -6,10 +6,7 @@ import { AppState, ExcalidrawProps } from "../types";
export type ActionResult = export type ActionResult =
| { | {
elements?: readonly ExcalidrawElement[] | null; elements?: readonly ExcalidrawElement[] | null;
appState?: MarkOptional< appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
commitToHistory: boolean; commitToHistory: boolean;
syncHistory?: boolean; syncHistory?: boolean;
} }
@@ -88,8 +85,6 @@ export type ActionName =
| "alignHorizontallyCentered" | "alignHorizontallyCentered"
| "distributeHorizontally" | "distributeHorizontally"
| "distributeVertically" | "distributeVertically"
| "flipHorizontal"
| "flipVertical"
| "viewMode" | "viewMode"
| "exportWithDarkMode"; | "exportWithDarkMode";
+3 -1
View File
@@ -10,7 +10,7 @@ import { getDateTime } from "./utils";
export const getDefaultAppState = (): Omit< export const getDefaultAppState = (): Omit<
AppState, AppState,
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft"
> => { > => {
return { return {
theme: "light", theme: "light",
@@ -43,6 +43,7 @@ export const getDefaultAppState = (): Omit<
exportWithDarkMode: false, exportWithDarkMode: false,
fileHandle: null, fileHandle: null,
gridSize: null, gridSize: null,
height: window.innerHeight,
isBindingEnabled: true, isBindingEnabled: true,
isLibraryOpen: false, isLibraryOpen: false,
isLoading: false, isLoading: false,
@@ -69,6 +70,7 @@ export const getDefaultAppState = (): Omit<
suggestedBindings: [], suggestedBindings: [],
toastMessage: null, toastMessage: null,
viewBackgroundColor: oc.white, viewBackgroundColor: oc.white,
width: window.innerWidth,
zenModeEnabled: false, zenModeEnabled: false,
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } }, zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
viewModeEnabled: false, viewModeEnabled: false,
+21 -22
View File
@@ -7,20 +7,15 @@ import { AppState } 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 { canvasToBlob } from "./data/blob"; import { canvasToBlob } from "./data/blob";
import { EXPORT_DATA_TYPES } from "./constants";
const TYPE_ELEMENTS = "excalidraw/elements";
type ElementsClipboard = { type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; type: typeof TYPE_ELEMENTS;
created: number;
elements: ExcalidrawElement[]; elements: ExcalidrawElement[];
}; };
export interface ClipboardData {
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
text?: string;
errorMessage?: string;
}
let CLIPBOARD = ""; let CLIPBOARD = "";
let PREFER_APP_CLIPBOARD = false; let PREFER_APP_CLIPBOARD = false;
@@ -36,16 +31,8 @@ export const probablySupportsClipboardBlob =
"ClipboardItem" in window && "ClipboardItem" in window &&
"toBlob" in HTMLCanvasElement.prototype; "toBlob" in HTMLCanvasElement.prototype;
const clipboardContainsElements = ( const isElementsClipboard = (contents: any): contents is ElementsClipboard => {
contents: any, if (contents?.type === TYPE_ELEMENTS) {
): contents is { elements: ExcalidrawElement[] } => {
if (
[
EXPORT_DATA_TYPES.excalidraw,
EXPORT_DATA_TYPES.excalidrawClipboard,
].includes(contents?.type) &&
Array.isArray(contents.elements)
) {
return true; return true;
} }
return false; return false;
@@ -56,7 +43,8 @@ export const copyToClipboard = async (
appState: AppState, appState: AppState,
) => { ) => {
const contents: ElementsClipboard = { const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard, type: TYPE_ELEMENTS,
created: Date.now(),
elements: getSelectedElements(elements, appState), elements: getSelectedElements(elements, appState),
}; };
const json = JSON.stringify(contents); const json = JSON.stringify(contents);
@@ -117,7 +105,12 @@ const getSystemClipboard = async (
*/ */
export const parseClipboard = async ( export const parseClipboard = async (
event: ClipboardEvent | null, event: ClipboardEvent | null,
): Promise<ClipboardData> => { ): Promise<{
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
text?: string;
errorMessage?: string;
}> => {
const systemClipboard = await getSystemClipboard(event); const systemClipboard = await getSystemClipboard(event);
// if system clipboard empty, couldn't be resolved, or contains previously // if system clipboard empty, couldn't be resolved, or contains previously
@@ -138,9 +131,15 @@ export const parseClipboard = async (
try { try {
const systemClipboardData = JSON.parse(systemClipboard); const systemClipboardData = JSON.parse(systemClipboard);
if (clipboardContainsElements(systemClipboardData)) { // system clipboard elements are newer than in-app clipboard
if (
isElementsClipboard(systemClipboardData) &&
(!appClipboardData?.created ||
appClipboardData.created < systemClipboardData.created)
) {
return { elements: systemClipboardData.elements }; return { elements: systemClipboardData.elements };
} }
// in-app clipboard is newer than system clipboard
return appClipboardData; return appClipboardData;
} catch { } catch {
// system clipboard doesn't contain excalidraw elements → return plaintext // system clipboard doesn't contain excalidraw elements → return plaintext
+1 -1
View File
@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import useIsMobile from "../is-mobile";
import { import {
canChangeSharpness, canChangeSharpness,
canHaveArrowheads, canHaveArrowheads,
+181 -320
View File
@@ -1,9 +1,8 @@
import { Point, simplify } from "points-on-curve"; import { Point, simplify } from "points-on-curve";
import React, { useContext } from "react"; import React from "react";
import { RoughCanvas } from "roughjs/bin/canvas"; import { RoughCanvas } from "roughjs/bin/canvas";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import clsx from "clsx"; import clsx from "clsx";
import { supported } from "browser-fs-access";
import { import {
actionAddToLibrary, actionAddToLibrary,
@@ -17,8 +16,6 @@ import {
actionDeleteSelected, actionDeleteSelected,
actionDuplicateSelection, actionDuplicateSelection,
actionFinalize, actionFinalize,
actionFlipHorizontal,
actionFlipVertical,
actionGroup, actionGroup,
actionPasteStyles, actionPasteStyles,
actionSelectAll, actionSelectAll,
@@ -44,9 +41,7 @@ import {
import { import {
APP_NAME, APP_NAME,
CURSOR_TYPE, CURSOR_TYPE,
DEFAULT_UI_OPTIONS,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
DETECT_POSITION_CHANGE_INTERVAL,
DRAGGING_THRESHOLD, DRAGGING_THRESHOLD,
ELEMENT_SHIFT_TRANSLATE_AMOUNT, ELEMENT_SHIFT_TRANSLATE_AMOUNT,
ELEMENT_TRANSLATE_AMOUNT, ELEMENT_TRANSLATE_AMOUNT,
@@ -55,16 +50,11 @@ import {
GRID_SIZE, GRID_SIZE,
LINE_CONFIRM_THRESHOLD, LINE_CONFIRM_THRESHOLD,
MIME_TYPES, MIME_TYPES,
MQ_MAX_HEIGHT_LANDSCAPE,
MQ_MAX_WIDTH_LANDSCAPE,
MQ_MAX_WIDTH_PORTRAIT,
POINTER_BUTTON, POINTER_BUTTON,
SCROLL_TIMEOUT, SCROLL_TIMEOUT,
TAP_TWICE_TIMEOUT, TAP_TWICE_TIMEOUT,
TEXT_TO_CENTER_SNAP_THRESHOLD, TEXT_TO_CENTER_SNAP_THRESHOLD,
TOUCH_CTX_MENU_TIMEOUT, TOUCH_CTX_MENU_TIMEOUT,
URL_HASH_KEYS,
URL_QUERY_KEYS,
ZOOM_STEP, ZOOM_STEP,
} from "../constants"; } from "../constants";
import { loadFromBlob } from "../data"; import { loadFromBlob } from "../data";
@@ -165,11 +155,16 @@ import Scene from "../scene/Scene";
import { SceneState, ScrollBars } from "../scene/types"; import { SceneState, ScrollBars } from "../scene/types";
import { getNewZoom } from "../scene/zoom"; import { getNewZoom } from "../scene/zoom";
import { findShapeByKey } from "../shapes"; import { findShapeByKey } from "../shapes";
import { AppProps, AppState, Gesture, GestureEvent, SceneData } from "../types"; import {
AppState,
ExcalidrawProps,
Gesture,
GestureEvent,
SceneData,
} from "../types";
import { import {
debounce, debounce,
distance, distance,
getNearestScrollableContainer,
isInputLike, isInputLike,
isToolIcon, isToolIcon,
isWritableElement, isWritableElement,
@@ -183,15 +178,13 @@ import {
viewportCoordsToSceneCoords, viewportCoordsToSceneCoords,
withBatchedUpdates, withBatchedUpdates,
} from "../utils"; } from "../utils";
import { isMobile } from "../is-mobile";
import ContextMenu, { ContextMenuOption } from "./ContextMenu"; import ContextMenu, { ContextMenuOption } from "./ContextMenu";
import LayerUI from "./LayerUI"; import LayerUI from "./LayerUI";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { Toast } from "./Toast"; import { Toast } from "./Toast";
import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import { actionToggleViewMode } from "../actions/actionToggleViewMode";
export const IsMobileContext = React.createContext(false);
export const useIsMobile = () => useContext(IsMobileContext);
const { history } = createHistory(); const { history } = createHistory();
let didTapTwice: boolean = false; let didTapTwice: boolean = false;
@@ -281,37 +274,30 @@ export type ExcalidrawImperativeAPI = {
setScrollToContent: InstanceType<typeof App>["setScrollToContent"]; setScrollToContent: InstanceType<typeof App>["setScrollToContent"];
getSceneElements: InstanceType<typeof App>["getSceneElements"]; getSceneElements: InstanceType<typeof App>["getSceneElements"];
getAppState: () => InstanceType<typeof App>["state"]; getAppState: () => InstanceType<typeof App>["state"];
refresh: InstanceType<typeof App>["refresh"]; setCanvasOffsets: InstanceType<typeof App>["setCanvasOffsets"];
importLibrary: InstanceType<typeof App>["importLibraryFromUrl"];
setToastMessage: InstanceType<typeof App>["setToastMessage"];
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>; readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
ready: true; ready: true;
}; };
class App extends React.Component<AppProps, AppState> { class App extends React.Component<ExcalidrawProps, AppState> {
canvas: HTMLCanvasElement | null = null; canvas: HTMLCanvasElement | null = null;
rc: RoughCanvas | null = null; rc: RoughCanvas | null = null;
unmounted: boolean = false; unmounted: boolean = false;
actionManager: ActionManager; actionManager: ActionManager;
isMobile = false;
detachIsMobileMqHandler?: () => void;
private excalidrawContainerRef = React.createRef<HTMLDivElement>(); private excalidrawContainerRef = React.createRef<HTMLDivElement>();
public static defaultProps: Partial<AppProps> = { public static defaultProps: Partial<ExcalidrawProps> = {
// needed for tests to pass since we directly render App in many tests width: window.innerWidth,
UIOptions: DEFAULT_UI_OPTIONS, height: window.innerHeight,
}; };
private scene: Scene; private scene: Scene;
private resizeObserver: ResizeObserver | undefined; constructor(props: ExcalidrawProps) {
private nearestScrollableContainer: HTMLElement | Document | undefined;
private detectPositionIntervalId: NodeJS.Timeout | undefined;
constructor(props: AppProps) {
super(props); super(props);
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
const { const {
width = window.innerWidth,
height = window.innerHeight,
excalidrawRef, excalidrawRef,
viewModeEnabled = false, viewModeEnabled = false,
zenModeEnabled = false, zenModeEnabled = false,
@@ -323,13 +309,13 @@ class App extends React.Component<AppProps, AppState> {
...defaultAppState, ...defaultAppState,
theme, theme,
isLoading: true, isLoading: true,
width,
height,
...this.getCanvasOffsets(), ...this.getCanvasOffsets(),
viewModeEnabled, viewModeEnabled,
zenModeEnabled, zenModeEnabled,
gridSize: gridModeEnabled ? GRID_SIZE : null, gridSize: gridModeEnabled ? GRID_SIZE : null,
name, name,
width: window.innerWidth,
height: window.innerHeight,
}; };
if (excalidrawRef) { if (excalidrawRef) {
const readyPromise = const readyPromise =
@@ -348,9 +334,7 @@ class App extends React.Component<AppProps, AppState> {
setScrollToContent: this.setScrollToContent, setScrollToContent: this.setScrollToContent,
getSceneElements: this.getSceneElements, getSceneElements: this.getSceneElements,
getAppState: () => this.state, getAppState: () => this.state,
refresh: this.refresh, setCanvasOffsets: this.setCanvasOffsets,
importLibrary: this.importLibraryFromUrl,
setToastMessage: this.setToastMessage,
} as const; } as const;
if (typeof excalidrawRef === "function") { if (typeof excalidrawRef === "function") {
excalidrawRef(api); excalidrawRef(api);
@@ -422,6 +406,7 @@ class App extends React.Component<AppProps, AppState> {
onPointerUp={this.removePointer} onPointerUp={this.removePointer}
onPointerCancel={this.removePointer} onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove} onTouchMove={this.handleTouchMove}
onDrop={this.handleCanvasOnDrop}
> >
{t("labels.drawingCanvas")} {t("labels.drawingCanvas")}
</canvas> </canvas>
@@ -436,12 +421,7 @@ class App extends React.Component<AppProps, AppState> {
viewModeEnabled, viewModeEnabled,
} = this.state; } = this.state;
const { const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props;
onCollabButtonClick,
onExportToBackend,
renderFooter,
renderCustomStats,
} = this.props;
const DEFAULT_PASTE_X = canvasDOMWidth / 2; const DEFAULT_PASTE_X = canvasDOMWidth / 2;
const DEFAULT_PASTE_Y = canvasDOMHeight / 2; const DEFAULT_PASTE_Y = canvasDOMHeight / 2;
@@ -450,64 +430,57 @@ class App extends React.Component<AppProps, AppState> {
<div <div
className={clsx("excalidraw", { className={clsx("excalidraw", {
"excalidraw--view-mode": viewModeEnabled, "excalidraw--view-mode": viewModeEnabled,
"excalidraw--mobile": this.isMobile,
})} })}
ref={this.excalidrawContainerRef} ref={this.excalidrawContainerRef}
onDrop={this.handleAppOnDrop} style={{
width: canvasDOMWidth,
height: canvasDOMHeight,
}}
> >
<IsMobileContext.Provider value={this.isMobile}> <LayerUI
<LayerUI canvas={this.canvas}
canvas={this.canvas} appState={this.state}
setAppState={this.setAppState}
actionManager={this.actionManager}
elements={this.scene.getElements()}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={this.toggleLock}
onInsertElements={(elements) =>
this.addElementsFromPasteOrLibrary(
elements,
DEFAULT_PASTE_X,
DEFAULT_PASTE_Y,
)
}
zenModeEnabled={zenModeEnabled}
toggleZenMode={this.toggleZenMode}
langCode={getLanguage().code}
isCollaborating={this.props.isCollaborating || false}
onExportToBackend={onExportToBackend}
renderCustomFooter={renderFooter}
viewModeEnabled={viewModeEnabled}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" && zenModeEnabled
}
showThemeBtn={typeof this.props?.theme === "undefined"}
libraryReturnUrl={this.props.libraryReturnUrl}
/>
<div className="excalidraw-textEditorContainer" />
{this.state.showStats && (
<Stats
appState={this.state} appState={this.state}
setAppState={this.setAppState} setAppState={this.setAppState}
actionManager={this.actionManager}
elements={this.scene.getElements()} elements={this.scene.getElements()}
onCollabButtonClick={onCollabButtonClick} onClose={this.toggleStats}
onLockToggle={this.toggleLock}
onInsertElements={(elements) =>
this.addElementsFromPasteOrLibrary(
elements,
DEFAULT_PASTE_X,
DEFAULT_PASTE_Y,
)
}
zenModeEnabled={zenModeEnabled}
toggleZenMode={this.toggleZenMode}
langCode={getLanguage().code}
isCollaborating={this.props.isCollaborating || false}
onExportToBackend={onExportToBackend}
renderCustomFooter={renderFooter}
viewModeEnabled={viewModeEnabled}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" &&
zenModeEnabled
}
showThemeBtn={
typeof this.props?.theme === "undefined" &&
this.props.UIOptions.canvasActions.theme
}
libraryReturnUrl={this.props.libraryReturnUrl}
UIOptions={this.props.UIOptions}
/> />
<div className="excalidraw-textEditorContainer" /> )}
<div className="excalidraw-contextMenuContainer" /> {this.state.toastMessage !== null && (
{this.state.showStats && ( <Toast
<Stats message={this.state.toastMessage}
appState={this.state} clearToast={this.clearToast}
setAppState={this.setAppState} />
elements={this.scene.getElements()} )}
onClose={this.toggleStats} <main>{this.renderCanvas()}</main>
renderCustomStats={renderCustomStats}
/>
)}
{this.state.toastMessage !== null && (
<Toast
message={this.state.toastMessage}
clearToast={this.clearToast}
/>
)}
<main>{this.renderCanvas()}</main>
</IsMobileContext.Provider>
</div> </div>
); );
} }
@@ -552,7 +525,8 @@ class App extends React.Component<AppProps, AppState> {
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false; let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
let gridSize = actionResult?.appState?.gridSize || null; let gridSize = actionResult?.appState?.gridSize || null;
let theme = actionResult?.appState?.theme || "light"; let theme = actionResult?.appState?.theme || "light";
let name = actionResult?.appState?.name ?? this.state.name; let name = actionResult?.appState?.name || this.state.name;
if (typeof this.props.viewModeEnabled !== "undefined") { if (typeof this.props.viewModeEnabled !== "undefined") {
viewModeEnabled = this.props.viewModeEnabled; viewModeEnabled = this.props.viewModeEnabled;
} }
@@ -572,6 +546,7 @@ class App extends React.Component<AppProps, AppState> {
if (typeof this.props.name !== "undefined") { if (typeof this.props.name !== "undefined") {
name = this.props.name; name = this.props.name;
} }
this.setState( this.setState(
(state) => { (state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into // using Object.assign instead of spread to fool TS 4.2.2+ into
@@ -580,6 +555,10 @@ class App extends React.Component<AppProps, AppState> {
return Object.assign(actionResult.appState || {}, { return Object.assign(actionResult.appState || {}, {
editingElement: editingElement:
editingElement || actionResult.appState?.editingElement || null, editingElement || actionResult.appState?.editingElement || null,
width: state.width,
height: state.height,
offsetTop: state.offsetTop,
offsetLeft: state.offsetLeft,
viewModeEnabled, viewModeEnabled,
zenModeEnabled, zenModeEnabled,
gridSize, gridSize,
@@ -624,17 +603,8 @@ class App extends React.Component<AppProps, AppState> {
this.onSceneUpdated(); this.onSceneUpdated();
}; };
private importLibraryFromUrl = async (url: string, token?: string | null) => { private importLibraryFromUrl = async (url: string) => {
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) { window.history.replaceState({}, APP_NAME, window.location.origin);
const hash = new URLSearchParams(window.location.hash.slice(1));
hash.delete(URL_HASH_KEYS.addLibrary);
window.history.replaceState({}, APP_NAME, `#${hash.toString()}`);
} else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) {
const query = new URLSearchParams(window.location.search);
query.delete(URL_QUERY_KEYS.addLibrary);
window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
}
try { try {
const request = await fetch(decodeURIComponent(url)); const request = await fetch(decodeURIComponent(url));
const blob = await request.blob(); const blob = await request.blob();
@@ -643,17 +613,14 @@ class App extends React.Component<AppProps, AppState> {
throw new Error(); throw new Error();
} }
if ( if (
token === Library.csrfToken ||
window.confirm( window.confirm(
t("alerts.confirmAddLibrary", { numShapes: json.library.length }), t("alerts.confirmAddLibrary", { numShapes: json.library.length }),
) )
) { ) {
await Library.importLibrary(blob); await Library.importLibrary(blob);
// hack to rerender the library items after import this.setState({
if (this.state.isLibraryOpen) { isLibraryOpen: true,
this.setState({ isLibraryOpen: false }); });
}
this.setState({ isLibraryOpen: true });
} }
} catch (error) { } catch (error) {
window.alert(t("alerts.errorLoadingLibrary")); window.alert(t("alerts.errorLoadingLibrary"));
@@ -712,6 +679,7 @@ class App extends React.Component<AppProps, AppState> {
if (!this.state.isLoading) { if (!this.state.isLoading) {
this.setState({ isLoading: true }); this.setState({ isLoading: true });
} }
let initialData = null; let initialData = null;
try { try {
initialData = (await this.props.initialData) || null; initialData = (await this.props.initialData) || null;
@@ -720,6 +688,7 @@ class App extends React.Component<AppProps, AppState> {
} }
const scene = restore(initialData, null); const scene = restore(initialData, null);
scene.appState = { scene.appState = {
...scene.appState, ...scene.appState,
isLoading: false, isLoading: false,
@@ -747,18 +716,12 @@ class App extends React.Component<AppProps, AppState> {
commitToHistory: true, commitToHistory: true,
}); });
const libraryUrl = const addToLibraryUrl = new URLSearchParams(window.location.search).get(
// current "addLibrary",
new URLSearchParams(window.location.hash.slice(1)).get( );
URL_HASH_KEYS.addLibrary,
) ||
// legacy, kept for compat reasons
new URLSearchParams(window.location.search).get(
URL_QUERY_KEYS.addLibrary,
);
if (libraryUrl) { if (addToLibraryUrl) {
await this.importLibraryFromUrl(libraryUrl); await this.importLibraryFromUrl(addToLibraryUrl);
} }
}; };
@@ -791,57 +754,23 @@ class App extends React.Component<AppProps, AppState> {
this.scene.addCallback(this.onSceneUpdated); this.scene.addCallback(this.onSceneUpdated);
this.addEventListeners(); this.addEventListeners();
if (this.props.detectPosition) {
this.detectPositionIntervalId = setInterval(
this.updateOffsetsIfChanged,
DETECT_POSITION_CHANGE_INTERVAL,
);
}
if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) {
this.resizeObserver = new ResizeObserver(() => {
// compute isMobile state
// ---------------------------------------------------------------------
const {
width,
height,
} = this.excalidrawContainerRef.current!.getBoundingClientRect();
this.isMobile =
width < MQ_MAX_WIDTH_PORTRAIT ||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE);
// refresh offsets
// ---------------------------------------------------------------------
this.updateDOMRect();
});
this.resizeObserver?.observe(this.excalidrawContainerRef.current);
} else if (window.matchMedia) {
const mediaQuery = window.matchMedia(
`(max-width: ${MQ_MAX_WIDTH_PORTRAIT}px), (max-height: ${MQ_MAX_HEIGHT_LANDSCAPE}px) and (max-width: ${MQ_MAX_WIDTH_LANDSCAPE}px)`,
);
const handler = () => (this.isMobile = mediaQuery.matches);
mediaQuery.addListener(handler);
this.detachIsMobileMqHandler = () => mediaQuery.removeListener(handler);
}
const searchParams = new URLSearchParams(window.location.search.slice(1)); const searchParams = new URLSearchParams(window.location.search.slice(1));
if (searchParams.has("web-share-target")) { if (searchParams.has("web-share-target")) {
// Obtain a file that was shared via the Web Share Target API. // Obtain a file that was shared via the Web Share Target API.
this.restoreFileFromShare(); this.restoreFileFromShare();
} else { } else {
this.updateDOMRect(this.initializeScene); this.setState(this.getCanvasOffsets(), () => {
this.initializeScene();
});
} }
} }
public componentWillUnmount() { public componentWillUnmount() {
this.resizeObserver?.disconnect();
this.unmounted = true; this.unmounted = true;
this.removeEventListeners(); this.removeEventListeners();
this.scene.destroy(); this.scene.destroy();
clearTimeout(touchTimeout); clearTimeout(touchTimeout);
if (this.detectPositionIntervalId) {
clearInterval(this.detectPositionIntervalId);
}
touchTimeout = 0; touchTimeout = 0;
} }
@@ -856,10 +785,6 @@ class App extends React.Component<AppProps, AppState> {
document.removeEventListener(EVENT.COPY, this.onCopy); document.removeEventListener(EVENT.COPY, this.onCopy);
document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard); document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
document.removeEventListener(EVENT.CUT, this.onCut); document.removeEventListener(EVENT.CUT, this.onCut);
this.nearestScrollableContainer?.removeEventListener(
EVENT.SCROLL,
this.onScroll,
);
document.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, false); document.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
document.removeEventListener( document.removeEventListener(
@@ -889,8 +814,6 @@ class App extends React.Component<AppProps, AppState> {
this.onGestureEnd as any, this.onGestureEnd as any,
false, false,
); );
this.detachIsMobileMqHandler?.();
} }
private addEventListeners() { private addEventListeners() {
@@ -926,15 +849,8 @@ class App extends React.Component<AppProps, AppState> {
document.addEventListener(EVENT.PASTE, this.pasteFromClipboard); document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
document.addEventListener(EVENT.CUT, this.onCut); document.addEventListener(EVENT.CUT, this.onCut);
if (this.props.detectScroll) { document.addEventListener(EVENT.SCROLL, this.onScroll);
this.nearestScrollableContainer = getNearestScrollableContainer(
this.excalidrawContainerRef.current!,
);
this.nearestScrollableContainer.addEventListener(
EVENT.SCROLL,
this.onScroll,
);
}
window.addEventListener(EVENT.RESIZE, this.onResize, false); window.addEventListener(EVENT.RESIZE, this.onResize, false);
window.addEventListener(EVENT.UNLOAD, this.onUnload, false); window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
window.addEventListener(EVENT.BLUR, this.onBlur, false); window.addEventListener(EVENT.BLUR, this.onBlur, false);
@@ -942,11 +858,22 @@ class App extends React.Component<AppProps, AppState> {
window.addEventListener(EVENT.DROP, this.disableEvent, false); window.addEventListener(EVENT.DROP, this.disableEvent, false);
} }
componentDidUpdate(prevProps: AppProps, prevState: AppState) { componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
if (prevProps.langCode !== this.props.langCode) { if (prevProps.langCode !== this.props.langCode) {
this.updateLanguage(); this.updateLanguage();
} }
if (
prevProps.width !== this.props.width ||
prevProps.height !== this.props.height
) {
this.setState({
width: this.props.width ?? window.innerWidth,
height: this.props.height ?? window.innerHeight,
...this.getCanvasOffsets(),
});
}
if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) { if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
this.setState( this.setState(
{ viewModeEnabled: !!this.props.viewModeEnabled }, { viewModeEnabled: !!this.props.viewModeEnabled },
@@ -1075,7 +1002,7 @@ class App extends React.Component<AppProps, AppState> {
}, },
{ {
renderOptimizations: true, renderOptimizations: true,
renderScrollbars: !this.isMobile, renderScrollbars: !isMobile(),
}, },
); );
if (scrollBars) { if (scrollBars) {
@@ -1104,7 +1031,7 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
private updateOffsetsIfChanged = () => { private onScroll = debounce(() => {
const { offsetTop, offsetLeft } = this.getCanvasOffsets(); const { offsetTop, offsetLeft } = this.getCanvasOffsets();
this.setState((state) => { this.setState((state) => {
if (state.offsetLeft === offsetLeft && state.offsetTop === offsetTop) { if (state.offsetLeft === offsetLeft && state.offsetTop === offsetTop) {
@@ -1112,9 +1039,7 @@ class App extends React.Component<AppProps, AppState> {
} }
return { offsetTop, offsetLeft }; return { offsetTop, offsetLeft };
}); });
}; }, SCROLL_TIMEOUT);
private onScroll = debounce(this.updateOffsetsIfChanged, SCROLL_TIMEOUT);
// Copy/paste // Copy/paste
@@ -1216,11 +1141,6 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const data = await parseClipboard(event); const data = await parseClipboard(event);
if (this.props.onPaste) {
if (await this.props.onPaste(data, event)) {
return;
}
}
if (data.errorMessage) { if (data.errorMessage) {
this.setState({ errorMessage: data.errorMessage }); this.setState({ errorMessage: data.errorMessage });
} else if (data.spreadsheet) { } else if (data.spreadsheet) {
@@ -1388,10 +1308,6 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ toastMessage: null }); this.setState({ toastMessage: null });
}; };
setToastMessage = (toastMessage: string) => {
this.setState({ toastMessage });
};
restoreFileFromShare = async () => { restoreFileFromShare = async () => {
try { try {
const webShareTargetCache = await caches.open("web-share-target"); const webShareTargetCache = await caches.open("web-share-target");
@@ -1489,7 +1405,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) { if (event[KEYS.CTRL_OR_CMD]) {
this.setState({ isBindingEnabled: false }); this.setState({ isBindingEnabled: false });
} }
@@ -1965,7 +1881,8 @@ class App extends React.Component<AppProps, AppState> {
} }
resetCursor(this.canvas); resetCursor(this.canvas);
if (!event[KEYS.CTRL_OR_CMD] && !this.state.viewModeEnabled) {
if (!event[KEYS.CTRL_OR_CMD]) {
this.startTextEditing({ this.startTextEditing({
sceneX, sceneX,
sceneY, sceneY,
@@ -2339,7 +2256,10 @@ class App extends React.Component<AppProps, AppState> {
touchTimeout = window.setTimeout(() => { touchTimeout = window.setTimeout(() => {
touchTimeout = 0; touchTimeout = 0;
if (!invalidateContextMenu) { if (!invalidateContextMenu) {
this.handleCanvasContextMenu(event); this.openContextMenu({
clientX: event.clientX,
clientY: event.clientY,
});
} }
}, TOUCH_CTX_MENU_TIMEOUT); }, TOUCH_CTX_MENU_TIMEOUT);
} }
@@ -3650,7 +3570,9 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private handleAppOnDrop = async (event: React.DragEvent<HTMLDivElement>) => { private handleCanvasOnDrop = async (
event: React.DragEvent<HTMLCanvasElement>,
) => {
try { try {
const file = event.dataTransfer.files[0]; const file = event.dataTransfer.files[0];
if (file?.type === "image/png" || file?.type === "image/svg+xml") { if (file?.type === "image/png" || file?.type === "image/svg+xml") {
@@ -3684,22 +3606,14 @@ class App extends React.Component<AppProps, AppState> {
const file = event.dataTransfer?.files[0]; const file = event.dataTransfer?.files[0];
if ( if (
file?.type === MIME_TYPES.excalidrawlib || file?.type === "application/json" ||
file?.name?.endsWith(".excalidrawlib") file?.name.endsWith(".excalidraw")
) { ) {
Library.importLibrary(file)
.then(() => {
// Close and then open to get the libraries updated
this.setState({ isLibraryOpen: false });
this.setState({ isLibraryOpen: true });
})
.catch((error) =>
this.setState({ isLoading: false, errorMessage: error.message }),
);
// default: assume an Excalidraw file regardless of extension/MimeType
} else {
this.setState({ isLoading: true }); this.setState({ isLoading: true });
if (supported) { if (
"chooseFileSystemEntries" in window ||
"showOpenFilePicker" in window
) {
try { try {
// This will only work as of Chrome 86, // This will only work as of Chrome 86,
// but can be safely ignored on older releases. // but can be safely ignored on older releases.
@@ -3709,7 +3623,23 @@ class App extends React.Component<AppProps, AppState> {
console.warn(error.name, error.message); console.warn(error.name, error.message);
} }
} }
await this.loadFileToCanvas(file); this.loadFileToCanvas(file);
} else if (
file?.type === MIME_TYPES.excalidrawlib ||
file?.name.endsWith(".excalidrawlib")
) {
Library.importLibrary(file)
.then(() => {
this.setState({ isLibraryOpen: false });
})
.catch((error) =>
this.setState({ isLoading: false, errorMessage: error.message }),
);
} else {
this.setState({
isLoading: false,
errorMessage: t("alerts.couldNotLoadInvalidFile"),
});
} }
}; };
@@ -3734,27 +3664,7 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
) => { ) => {
event.preventDefault(); event.preventDefault();
this.openContextMenu(event);
const { x, y } = viewportCoordsToSceneCoords(event, this.state);
const element = this.getElementAtPosition(x, y);
const type = element ? "element" : "canvas";
const container = this.excalidrawContainerRef.current!;
const {
top: offsetTop,
left: offsetLeft,
} = container.getBoundingClientRect();
const left = event.clientX - offsetLeft;
const top = event.clientY - offsetTop;
if (element && !this.state.selectedElementIds[element.id]) {
this.setState({ selectedElementIds: { [element.id]: true } }, () => {
this._openContextMenu({ top, left }, type);
});
} else {
this._openContextMenu({ top, left }, type);
}
}; };
private maybeDragNewGenericElement = ( private maybeDragNewGenericElement = (
@@ -3844,17 +3754,18 @@ class App extends React.Component<AppProps, AppState> {
return false; return false;
}; };
/** @private use this.handleCanvasContextMenu */ private openContextMenu = ({
private _openContextMenu = ( clientX,
{ clientY,
left, }: {
top, clientX: number;
}: { clientY: number;
left: number; }) => {
top: number; const { x, y } = viewportCoordsToSceneCoords(
}, { clientX, clientY },
type: "canvas" | "element", this.state,
) => { );
const maybeGroupAction = actionGroup.contextItemPredicate!( const maybeGroupAction = actionGroup.contextItemPredicate!(
this.actionManager.getElementsIncludingDeleted(), this.actionManager.getElementsIncludingDeleted(),
this.actionManager.getAppState(), this.actionManager.getAppState(),
@@ -3865,20 +3776,12 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.getAppState(), this.actionManager.getAppState(),
); );
const maybeFlipHorizontal = actionFlipHorizontal.contextItemPredicate!(
this.actionManager.getElementsIncludingDeleted(),
this.actionManager.getAppState(),
);
const maybeFlipVertical = actionFlipVertical.contextItemPredicate!(
this.actionManager.getElementsIncludingDeleted(),
this.actionManager.getAppState(),
);
const separator = "separator"; const separator = "separator";
const elements = this.scene.getElements(); const _isMobile = isMobile();
const elements = this.scene.getElements();
const element = this.getElementAtPosition(x, y);
const options: ContextMenuOption[] = []; const options: ContextMenuOption[] = [];
if (probablySupportsClipboardBlob && elements.length > 0) { if (probablySupportsClipboardBlob && elements.length > 0) {
options.push(actionCopyAsPng); options.push(actionCopyAsPng);
@@ -3887,7 +3790,7 @@ class App extends React.Component<AppProps, AppState> {
if (probablySupportsClipboardWriteText && elements.length > 0) { if (probablySupportsClipboardWriteText && elements.length > 0) {
options.push(actionCopyAsSvg); options.push(actionCopyAsSvg);
} }
if (type === "canvas") { if (!element) {
const viewModeOptions = [ const viewModeOptions = [
...options, ...options,
typeof this.props.gridModeEnabled === "undefined" && typeof this.props.gridModeEnabled === "undefined" &&
@@ -3900,11 +3803,10 @@ class App extends React.Component<AppProps, AppState> {
ContextMenu.push({ ContextMenu.push({
options: viewModeOptions, options: viewModeOptions,
top, top: clientY,
left, left: clientX,
actionManager: this.actionManager, actionManager: this.actionManager,
appState: this.state, appState: this.state,
container: this.excalidrawContainerRef.current!,
}); });
if (this.state.viewModeEnabled) { if (this.state.viewModeEnabled) {
@@ -3913,7 +3815,7 @@ class App extends React.Component<AppProps, AppState> {
ContextMenu.push({ ContextMenu.push({
options: [ options: [
this.isMobile && _isMobile &&
navigator.clipboard && { navigator.clipboard && {
name: "paste", name: "paste",
perform: (elements, appStates) => { perform: (elements, appStates) => {
@@ -3924,7 +3826,7 @@ class App extends React.Component<AppProps, AppState> {
}, },
contextItemLabel: "labels.paste", contextItemLabel: "labels.paste",
}, },
this.isMobile && navigator.clipboard && separator, _isMobile && navigator.clipboard && separator,
probablySupportsClipboardBlob && probablySupportsClipboardBlob &&
elements.length > 0 && elements.length > 0 &&
actionCopyAsPng, actionCopyAsPng,
@@ -3944,32 +3846,34 @@ class App extends React.Component<AppProps, AppState> {
actionToggleViewMode, actionToggleViewMode,
actionToggleStats, actionToggleStats,
], ],
top, top: clientY,
left, left: clientX,
actionManager: this.actionManager, actionManager: this.actionManager,
appState: this.state, appState: this.state,
container: this.excalidrawContainerRef.current!,
}); });
return; return;
} }
if (!this.state.selectedElementIds[element.id]) {
this.setState({ selectedElementIds: { [element.id]: true } });
}
if (this.state.viewModeEnabled) { if (this.state.viewModeEnabled) {
ContextMenu.push({ ContextMenu.push({
options: [navigator.clipboard && actionCopy, ...options], options: [navigator.clipboard && actionCopy, ...options],
top, top: clientY,
left, left: clientX,
actionManager: this.actionManager, actionManager: this.actionManager,
appState: this.state, appState: this.state,
container: this.excalidrawContainerRef.current!,
}); });
return; return;
} }
ContextMenu.push({ ContextMenu.push({
options: [ options: [
this.isMobile && actionCut, _isMobile && actionCut,
this.isMobile && navigator.clipboard && actionCopy, _isMobile && navigator.clipboard && actionCopy,
this.isMobile && _isMobile &&
navigator.clipboard && { navigator.clipboard && {
name: "paste", name: "paste",
perform: (elements, appStates) => { perform: (elements, appStates) => {
@@ -3980,7 +3884,7 @@ class App extends React.Component<AppProps, AppState> {
}, },
contextItemLabel: "labels.paste", contextItemLabel: "labels.paste",
}, },
this.isMobile && separator, _isMobile && separator,
...options, ...options,
separator, separator,
actionCopyStyles, actionCopyStyles,
@@ -3996,17 +3900,13 @@ class App extends React.Component<AppProps, AppState> {
actionSendToBack, actionSendToBack,
actionBringToFront, actionBringToFront,
separator, separator,
maybeFlipHorizontal && actionFlipHorizontal,
maybeFlipVertical && actionFlipVertical,
(maybeFlipHorizontal || maybeFlipVertical) && separator,
actionDuplicateSelection, actionDuplicateSelection,
actionDeleteSelected, actionDeleteSelected,
], ],
top, top: clientY,
left, left: clientX,
actionManager: this.actionManager, actionManager: this.actionManager,
appState: this.state, appState: this.state,
container: this.excalidrawContainerRef.current!,
}); });
}; };
@@ -4140,56 +4040,14 @@ class App extends React.Component<AppProps, AppState> {
} }
}, 300); }, 300);
private updateDOMRect = (cb?: () => void) => { public setCanvasOffsets = () => {
if (this.excalidrawContainerRef?.current) {
const excalidrawContainer = this.excalidrawContainerRef.current;
const {
width,
height,
left: offsetLeft,
top: offsetTop,
} = excalidrawContainer.getBoundingClientRect();
const {
width: currentWidth,
height: currentHeight,
offsetTop: currentOffsetTop,
offsetLeft: currentOffsetLeft,
} = this.state;
if (
width === currentWidth &&
height === currentHeight &&
offsetLeft === currentOffsetLeft &&
offsetTop === currentOffsetTop
) {
if (cb) {
cb();
}
return;
}
this.setState(
{
width,
height,
offsetLeft,
offsetTop,
},
() => {
cb && cb();
},
);
}
};
public refresh = () => {
this.setState({ ...this.getCanvasOffsets() }); this.setState({ ...this.getCanvasOffsets() });
}; };
private getCanvasOffsets(): Pick<AppState, "offsetTop" | "offsetLeft"> { private getCanvasOffsets(): Pick<AppState, "offsetTop" | "offsetLeft"> {
if (this.excalidrawContainerRef?.current) { if (this.excalidrawContainerRef?.current?.parentElement) {
const excalidrawContainer = this.excalidrawContainerRef.current; const parentElement = this.excalidrawContainerRef.current.parentElement;
const { left, top } = excalidrawContainer.getBoundingClientRect(); const { left, top } = parentElement.getBoundingClientRect();
return { return {
offsetLeft: left, offsetLeft: left,
offsetTop: top, offsetTop: top,
@@ -4223,6 +4081,9 @@ declare global {
history: SceneHistory; history: SceneHistory;
app: InstanceType<typeof App>; app: InstanceType<typeof App>;
library: typeof Library; library: typeof Library;
collab: InstanceType<
typeof import("../excalidraw-app/collab/CollabWrapper").default
>;
}; };
} }
} }
+3 -2
View File
@@ -1,3 +1,4 @@
import React from "react";
import clsx from "clsx"; import clsx from "clsx";
export const ButtonIconCycle = <T extends any>({ export const ButtonIconCycle = <T extends any>({
@@ -13,11 +14,11 @@ export const ButtonIconCycle = <T extends any>({
}) => { }) => {
const current = options.find((op) => op.value === value); const current = options.find((op) => op.value === value);
const cycle = () => { function cycle() {
const index = options.indexOf(current!); const index = options.indexOf(current!);
const next = (index + 1) % options.length; const next = (index + 1) % options.length;
onChange(options[next].value); onChange(options[next].value);
}; }
return ( return (
<label key={group} className={clsx({ active: current!.value !== null })}> <label key={group} className={clsx({ active: current!.value !== null })}>
+1 -1
View File
@@ -2,7 +2,7 @@ import React from "react";
import clsx from "clsx"; import clsx from "clsx";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import useIsMobile from "../is-mobile";
import { users } from "./icons"; import { users } from "./icons";
import "./CollabButton.scss"; import "./CollabButton.scss";
+1 -1
View File
@@ -218,7 +218,7 @@
left: 2px; left: 2px;
} }
@include isMobile { @media #{$is-mobile-query} {
display: none; display: none;
} }
} }
+1 -1
View File
@@ -76,7 +76,7 @@
z-index: 1; z-index: 1;
} }
@include isMobile { @media #{$is-mobile-query} {
.context-menu-option { .context-menu-option {
display: block; display: block;
+55 -57
View File
@@ -32,63 +32,67 @@ const ContextMenu = ({
actionManager, actionManager,
appState, appState,
}: ContextMenuProps) => { }: ContextMenuProps) => {
const isDarkTheme = !!document
.querySelector(".excalidraw")
?.classList.contains("theme--dark");
return ( return (
<Popover <div
onCloseRequest={onCloseRequest} className={clsx("excalidraw", {
top={top} "theme--dark theme--dark-background-none": isDarkTheme,
left={left} })}
fitInViewport={true}
> >
<ul <Popover
className="context-menu" onCloseRequest={onCloseRequest}
onContextMenu={(event) => event.preventDefault()} top={top}
left={left}
fitInViewport={true}
> >
{options.map((option, idx) => { <ul
if (option === "separator") { className="context-menu"
return <hr key={idx} className="context-menu-option-separator" />; onContextMenu={(event) => event.preventDefault()}
} >
{options.map((option, idx) => {
if (option === "separator") {
return <hr key={idx} className="context-menu-option-separator" />;
}
const actionName = option.name; const actionName = option.name;
const label = option.contextItemLabel const label = option.contextItemLabel
? t(option.contextItemLabel) ? t(option.contextItemLabel)
: ""; : "";
return ( return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}> <li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button <button
className={clsx("context-menu-option", { className={clsx("context-menu-option", {
dangerous: actionName === "deleteSelectedElements", dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState), checkmark: option.checked?.(appState),
})} })}
onClick={() => actionManager.executeAction(option)} onClick={() => actionManager.executeAction(option)}
> >
<div className="context-menu-option__label">{label}</div> <div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut"> <kbd className="context-menu-option__shortcut">
{actionName {actionName
? getShortcutFromShortcutName(actionName as ShortcutName) ? getShortcutFromShortcutName(actionName as ShortcutName)
: ""} : ""}
</kbd> </kbd>
</button> </button>
</li> </li>
); );
})} })}
</ul> </ul>
</Popover> </Popover>
</div>
); );
}; };
const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>(); let contextMenuNode: HTMLDivElement;
const getContextMenuNode = (): HTMLDivElement => {
const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
let contextMenuNode = contextMenuNodeByContainer.get(container);
if (contextMenuNode) { if (contextMenuNode) {
return contextMenuNode; return contextMenuNode;
} }
contextMenuNode = document.createElement("div"); const div = document.createElement("div");
container document.body.appendChild(div);
.querySelector(".excalidraw-contextMenuContainer")! return (contextMenuNode = div);
.appendChild(contextMenuNode);
contextMenuNodeByContainer.set(container, contextMenuNode);
return contextMenuNode;
}; };
type ContextMenuParams = { type ContextMenuParams = {
@@ -97,16 +101,10 @@ type ContextMenuParams = {
left: ContextMenuProps["left"]; left: ContextMenuProps["left"];
actionManager: ContextMenuProps["actionManager"]; actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>; appState: Readonly<AppState>;
container: HTMLElement;
}; };
const handleClose = (container: HTMLElement) => { const handleClose = () => {
const contextMenuNode = contextMenuNodeByContainer.get(container); unmountComponentAtNode(getContextMenuNode());
if (contextMenuNode) {
unmountComponentAtNode(contextMenuNode);
contextMenuNode.remove();
contextMenuNodeByContainer.delete(container);
}
}; };
export default { export default {
@@ -123,11 +121,11 @@ export default {
top={params.top} top={params.top}
left={params.left} left={params.left}
options={options} options={options}
onCloseRequest={() => handleClose(params.container)} onCloseRequest={handleClose}
actionManager={params.actionManager} actionManager={params.actionManager}
appState={params.appState} appState={params.appState}
/>, />,
getContextMenuNode(params.container), getContextMenuNode(),
); );
} }
}, },
+1 -1
View File
@@ -31,7 +31,7 @@
padding: 0 16px 16px; padding: 0 16px 16px;
} }
@include isMobile { @media #{$is-mobile-query} {
.Dialog { .Dialog {
--metric: calc(var(--space-factor) * 4); --metric: calc(var(--space-factor) * 4);
--inset-left: #{"max(var(--metric), var(--sal))"}; --inset-left: #{"max(var(--metric), var(--sal))"};
+1 -1
View File
@@ -2,7 +2,7 @@ import clsx from "clsx";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import useIsMobile from "../is-mobile";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import "./Dialog.scss"; import "./Dialog.scss";
import { back, close } from "./icons"; import { back, close } from "./icons";
+8 -1
View File
@@ -28,7 +28,14 @@ export const ErrorDialog = ({
onCloseRequest={handleClose} onCloseRequest={handleClose}
title={t("errorDialog.title")} title={t("errorDialog.title")}
> >
<div style={{ whiteSpace: "pre-wrap" }}>{message}</div> <div>
{message.split("\n").map((line) => (
<>
{line}
<br />
</>
))}
</div>
</Dialog> </Dialog>
)} )}
</> </>
+1 -11
View File
@@ -31,16 +31,9 @@
.ExportDialog__name { .ExportDialog__name {
grid-column: project-name; grid-column: project-name;
margin: auto; margin: auto;
display: flex;
align-items: center;
.TextInput { .TextInput {
height: calc(1rem - 3px); height: calc(1rem - 3px);
width: 200px;
overflow: hidden;
text-align: center;
margin-left: 8px;
text-overflow: ellipsis;
&--readonly { &--readonly {
background: none; background: none;
@@ -48,14 +41,11 @@
&:hover { &:hover {
background: none; background: none;
} }
width: auto;
max-width: 200px;
padding-left: 2px;
} }
} }
} }
@include isMobile { @media #{$is-mobile-query} {
.ExportDialog { .ExportDialog {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+1 -1
View File
@@ -6,7 +6,7 @@ import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import useIsMobile from "../is-mobile";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas, getExportSize } from "../scene/export"; import { exportToCanvas, getExportSize } from "../scene/export";
import { AppState } from "../types"; import { AppState } from "../types";
-8
View File
@@ -349,14 +349,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.ungroup")} label={t("labels.ungroup")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]} shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
/> />
<Shortcut
label={t("labels.flipHorizontal")}
shortcuts={[getShortcutKey("Shift+H")]}
/>
<Shortcut
label={t("labels.flipVertical")}
shortcuts={[getShortcutKey("Shift+V")]}
/>
</ShortcutIsland> </ShortcutIsland>
</Column> </Column>
</Columns> </Columns>
+3 -9
View File
@@ -9,13 +9,7 @@ type HelpIconProps = {
}; };
export const HelpIcon = (props: HelpIconProps) => ( export const HelpIcon = (props: HelpIconProps) => (
<button <label title={`${props.title} — ?`} className="help-icon">
className="help-icon" <div onClick={props.onClick}>{questionCircle}</div>
onClick={props.onClick} </label>
type="button"
title={`${props.title} — ?`}
aria-label={props.title}
>
{questionCircle}
</button>
); );
+1 -1
View File
@@ -19,7 +19,7 @@ $wide-viewport-width: 1000px;
color: $oc-gray-6; color: $oc-gray-6;
font-size: 0.8rem; font-size: 0.8rem;
@include isMobile { @media #{$is-mobile-query} {
position: static; position: static;
padding-right: 2em; padding-right: 2em;
} }
+1 -1
View File
@@ -111,7 +111,7 @@
:root[dir="rtl"] & { :root[dir="rtl"] & {
left: 2px; left: 2px;
} }
@include isMobile { @media #{$is-mobile-query} {
display: none; display: none;
} }
} }
+35 -54
View File
@@ -14,16 +14,10 @@ import { Library } from "../data/library";
import { isTextElement, showSelectedShapeActions } from "../element"; import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n"; import { Language, t } from "../i18n";
import { useIsMobile } from "../components/App"; import useIsMobile from "../is-mobile";
import { calculateScrollCenter, getSelectedElements } from "../scene"; import { calculateScrollCenter, getSelectedElements } from "../scene";
import { ExportType } from "../scene/types"; import { ExportType } from "../scene/types";
import { import { AppState, ExcalidrawProps, LibraryItem, LibraryItems } from "../types";
AppProps,
AppState,
ExcalidrawProps,
LibraryItem,
LibraryItems,
} from "../types";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
@@ -71,7 +65,6 @@ interface LayerUIProps {
renderCustomFooter?: (isMobile: boolean) => JSX.Element; renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean; viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"];
} }
const useOnClickOutside = ( const useOnClickOutside = (
@@ -128,11 +121,10 @@ const LibraryMenuItems = ({
const rows = []; const rows = [];
let addedPendingElements = false; let addedPendingElements = false;
const referrer = const referrer = libraryReturnUrl || window.location.origin;
libraryReturnUrl || window.location.origin + window.location.pathname;
rows.push( rows.push(
<div className="layer-ui__library-header" key="library-header"> <div className="layer-ui__library-header">
<ToolButton <ToolButton
key="import" key="import"
type="button" type="button"
@@ -142,9 +134,9 @@ const LibraryMenuItems = ({
onClick={() => { onClick={() => {
importLibraryFromJSON() importLibraryFromJSON()
.then(() => { .then(() => {
// Close and then open to get the libraries updated // Maybe we should close and open the menu so that the items get updated.
// But for now we just close the menu.
setAppState({ isLibraryOpen: false }); setAppState({ isLibraryOpen: false });
setAppState({ isLibraryOpen: true });
}) })
.catch(muteFSAbortError) .catch(muteFSAbortError)
.catch((error) => { .catch((error) => {
@@ -152,41 +144,36 @@ const LibraryMenuItems = ({
}); });
}} }}
/> />
{!!library.length && ( <ToolButton
<> key="export"
<ToolButton type="button"
key="export" title={t("buttons.export")}
type="button" aria-label={t("buttons.export")}
title={t("buttons.export")} icon={exportFile}
aria-label={t("buttons.export")} onClick={() => {
icon={exportFile} saveLibraryAsJSON()
onClick={() => { .catch(muteFSAbortError)
saveLibraryAsJSON() .catch((error) => {
.catch(muteFSAbortError) setAppState({ errorMessage: error.message });
.catch((error) => { });
setAppState({ errorMessage: error.message }); }}
}); />
}} <ToolButton
/> key="reset"
<ToolButton type="button"
key="reset" title={t("buttons.resetLibrary")}
type="button" aria-label={t("buttons.resetLibrary")}
title={t("buttons.resetLibrary")} icon={trash}
aria-label={t("buttons.resetLibrary")} onClick={() => {
icon={trash} if (window.confirm(t("alerts.resetLibrary"))) {
onClick={() => { Library.resetLibrary();
if (window.confirm(t("alerts.resetLibrary"))) { setLibraryItems([]);
Library.resetLibrary(); }
setLibraryItems([]); }}
} />
}}
/>
</>
)}
<a <a
href={`https://libraries.excalidraw.com?target=${ href={`https://libraries.excalidraw.com?referrer=${referrer}`}
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${Library.csrfToken}`}
target="_excalidraw_libraries" target="_excalidraw_libraries"
> >
{t("labels.libraries")} {t("labels.libraries")}
@@ -346,7 +333,6 @@ const LayerUI = ({
renderCustomFooter, renderCustomFooter,
viewModeEnabled, viewModeEnabled,
libraryReturnUrl, libraryReturnUrl,
UIOptions,
}: LayerUIProps) => { }: LayerUIProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -358,7 +344,6 @@ const LayerUI = ({
href="https://blog.excalidraw.com/end-to-end-encryption/" href="https://blog.excalidraw.com/end-to-end-encryption/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label={t("encrypted.link")}
> >
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}> <Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
{shield} {shield}
@@ -367,10 +352,6 @@ const LayerUI = ({
); );
const renderExportDialog = () => { const renderExportDialog = () => {
if (!UIOptions.canvasActions.export) {
return null;
}
const createExporter = (type: ExportType): ExportCB => async ( const createExporter = (type: ExportType): ExportCB => async (
exportedElements, exportedElements,
scale, scale,
+1 -1
View File
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
import { close } from "../components/icons"; import { close } from "../components/icons";
import { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import useIsMobile from "../is-mobile";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import { LibraryItem } from "../types"; import { LibraryItem } from "../types";
import "./LibraryUnit.scss"; import "./LibraryUnit.scss";
+2 -2
View File
@@ -52,7 +52,7 @@
border-radius: 6px; border-radius: 6px;
box-sizing: border-box; box-sizing: border-box;
@include isMobile { @media #{$is-mobile-query} {
max-width: 100%; max-width: 100%;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
@@ -82,7 +82,7 @@
} }
} }
@include isMobile { @media #{$is-mobile-query} {
.Modal { .Modal {
padding: 0; padding: 0;
} }
+1 -13
View File
@@ -1,10 +1,9 @@
import "./Modal.scss"; import "./Modal.scss";
import React, { useState, useLayoutEffect, useRef } from "react"; import React, { useState, useLayoutEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import clsx from "clsx"; import clsx from "clsx";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { useIsMobile } from "../components/App";
export const Modal = (props: { export const Modal = (props: {
className?: string; className?: string;
@@ -49,16 +48,6 @@ export const Modal = (props: {
const useBodyRoot = () => { const useBodyRoot = () => {
const [div, setDiv] = useState<HTMLDivElement | null>(null); const [div, setDiv] = useState<HTMLDivElement | null>(null);
const isMobile = useIsMobile();
const isMobileRef = useRef(isMobile);
isMobileRef.current = isMobile;
useLayoutEffect(() => {
if (div) {
div.classList.toggle("excalidraw--mobile", isMobile);
}
}, [div, isMobile]);
useLayoutEffect(() => { useLayoutEffect(() => {
const isDarkTheme = !!document const isDarkTheme = !!document
.querySelector(".excalidraw") .querySelector(".excalidraw")
@@ -66,7 +55,6 @@ const useBodyRoot = () => {
const div = document.createElement("div"); const div = document.createElement("div");
div.classList.add("excalidraw", "excalidraw-modal-container"); div.classList.add("excalidraw", "excalidraw-modal-container");
div.classList.toggle("excalidraw--mobile", isMobileRef.current);
if (isDarkTheme) { if (isDarkTheme) {
div.classList.add("theme--dark"); div.classList.add("theme--dark");
+2 -2
View File
@@ -2,7 +2,7 @@
.excalidraw { .excalidraw {
.PasteChartDialog { .PasteChartDialog {
@include isMobile { @media #{$is-mobile-query} {
.Island { .Island {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -13,7 +13,7 @@
align-items: center; align-items: center;
justify-content: space-around; justify-content: space-around;
flex-wrap: wrap; flex-wrap: wrap;
@include isMobile { @media #{$is-mobile-query} {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
.excalidraw { .excalidraw {
.popover { .popover {
position: absolute; position: fixed;
z-index: 10; z-index: 10;
} }
} }
+39 -30
View File
@@ -1,6 +1,7 @@
import "./TextInput.scss"; import "./TextInput.scss";
import React, { Component } from "react"; import React, { Component } from "react";
import { selectNode, removeSelection } from "../utils";
type Props = { type Props = {
value: string; value: string;
@@ -9,18 +10,17 @@ type Props = {
isNameEditable: boolean; isNameEditable: boolean;
}; };
type State = { export class ProjectName extends Component<Props> {
fileName: string; private handleFocus = (event: React.FocusEvent<HTMLElement>) => {
}; selectNode(event.currentTarget);
export class ProjectName extends Component<Props, State> {
state = {
fileName: this.props.value,
}; };
private handleBlur = (event: any) => {
const value = event.target.value; private handleBlur = (event: React.FocusEvent<HTMLElement>) => {
const value = event.currentTarget.innerText.trim();
if (value !== this.props.value) { if (value !== this.props.value) {
this.props.onChange(value); this.props.onChange(value);
} }
removeSelection();
}; };
private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => { private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
@@ -32,30 +32,39 @@ export class ProjectName extends Component<Props, State> {
event.currentTarget.blur(); event.currentTarget.blur();
} }
}; };
private makeEditable = (editable: HTMLSpanElement | null) => {
if (!editable) {
return;
}
try {
editable.contentEditable = "plaintext-only";
} catch {
editable.contentEditable = "true";
}
};
public render() { public render() {
return ( return this.props.isNameEditable ? (
<> <span
<label htmlFor="file-name"> suppressContentEditableWarning
{`${this.props.label}${this.props.isNameEditable ? "" : ":"}`} ref={this.makeEditable}
</label> data-type="wysiwyg"
{this.props.isNameEditable ? ( className="TextInput"
<input role="textbox"
className="TextInput" aria-label={this.props.label}
onBlur={this.handleBlur} onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
id="file-name" onFocus={this.handleFocus}
value={this.state.fileName} >
onChange={(event) => {this.props.value}
this.setState({ fileName: event.target.value }) </span>
} ) : (
/> <span
) : ( className="TextInput TextInput--readonly"
<span className="TextInput TextInput--readonly" id="file-name"> aria-label={this.props.label}
{this.props.value} >
</span> {this.props.value}
)} </span>
</>
); );
} }
} }
+95 -10
View File
@@ -1,22 +1,49 @@
import React from "react"; import React, { useEffect, useState } from "react";
import { copyTextToSystemClipboard } from "../clipboard";
import { DEFAULT_VERSION } from "../constants";
import { getCommonBounds } from "../element/bounds"; import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import {
getElementsStorageSize,
getTotalStorageSize,
} from "../excalidraw-app/data/localStorage";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import useIsMobile from "../is-mobile";
import { getTargetElements } from "../scene"; import { getTargetElements } from "../scene";
import { AppState, ExcalidrawProps } from "../types"; import { AppState } from "../types";
import { debounce, getVersion, nFormatter } from "../utils";
import { close } from "./icons"; import { close } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
import "./Stats.scss"; import "./Stats.scss";
type StorageSizes = { scene: number; total: number };
const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
cb({
scene: getElementsStorageSize(),
total: getTotalStorageSize(),
});
}, 500);
export const Stats = (props: { export const Stats = (props: {
appState: AppState; appState: AppState;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"];
}) => { }) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
scene: 0,
total: 0,
});
useEffect(() => {
getStorageSizes((sizes) => {
setStorageSizes(sizes);
});
});
useEffect(() => () => getStorageSizes.cancel(), []);
const boundingBox = getCommonBounds(props.elements); const boundingBox = getCommonBounds(props.elements);
const selectedElements = getTargetElements(props.elements, props.appState); const selectedElements = getTargetElements(props.elements, props.appState);
@@ -26,6 +53,17 @@ export const Stats = (props: {
return null; return null;
} }
const version = getVersion();
let hash;
let timestamp;
if (version !== DEFAULT_VERSION) {
timestamp = version.slice(0, 16).replace("T", " ");
hash = version.slice(21);
} else {
timestamp = t("stats.versionNotAvailable");
}
return ( return (
<div className="Stats"> <div className="Stats">
<Island padding={2}> <Island padding={2}>
@@ -50,7 +88,17 @@ export const Stats = (props: {
<td>{t("stats.height")}</td> <td>{t("stats.height")}</td>
<td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td> <td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
</tr> </tr>
<tr>
<th colSpan={2}>{t("stats.storage")}</th>
</tr>
<tr>
<td>{t("stats.scene")}</td>
<td>{nFormatter(storageSizes.scene, 1)}</td>
</tr>
<tr>
<td>{t("stats.total")}</td>
<td>{nFormatter(storageSizes.total, 1)}</td>
</tr>
{selectedElements.length === 1 && ( {selectedElements.length === 1 && (
<tr> <tr>
<th colSpan={2}>{t("stats.element")}</th> <th colSpan={2}>{t("stats.element")}</th>
@@ -72,17 +120,31 @@ export const Stats = (props: {
<> <>
<tr> <tr>
<td>{"x"}</td> <td>{"x"}</td>
<td>{Math.round(selectedBoundingBox[0])}</td> <td>
{Math.round(
selectedElements.length === 1
? selectedElements[0].x
: selectedBoundingBox[0],
)}
</td>
</tr> </tr>
<tr> <tr>
<td>{"y"}</td> <td>{"y"}</td>
<td>{Math.round(selectedBoundingBox[1])}</td> <td>
{Math.round(
selectedElements.length === 1
? selectedElements[0].y
: selectedBoundingBox[1],
)}
</td>
</tr> </tr>
<tr> <tr>
<td>{t("stats.width")}</td> <td>{t("stats.width")}</td>
<td> <td>
{Math.round( {Math.round(
selectedBoundingBox[2] - selectedBoundingBox[0], selectedElements.length === 1
? selectedElements[0].width
: selectedBoundingBox[2] - selectedBoundingBox[0],
)} )}
</td> </td>
</tr> </tr>
@@ -90,7 +152,9 @@ export const Stats = (props: {
<td>{t("stats.height")}</td> <td>{t("stats.height")}</td>
<td> <td>
{Math.round( {Math.round(
selectedBoundingBox[3] - selectedBoundingBox[1], selectedElements.length === 1
? selectedElements[0].height
: selectedBoundingBox[3] - selectedBoundingBox[1],
)} )}
</td> </td>
</tr> </tr>
@@ -106,7 +170,28 @@ export const Stats = (props: {
</td> </td>
</tr> </tr>
)} )}
{props.renderCustomStats?.(props.elements, props.appState)} <tr>
<th colSpan={2}>{t("stats.version")}</th>
</tr>
<tr>
<td
colSpan={2}
style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setAppState({
toastMessage: t("toast.copyToClipboard"),
});
} catch {}
}}
title={t("stats.versionCopy")}
>
{timestamp}
<br />
{hash}
</td>
</tr>
</tbody> </tbody>
</table> </table>
</Island> </Island>
+1 -1
View File
@@ -193,7 +193,7 @@
margin-left: 5px; margin-left: 5px;
margin-top: 1px; margin-top: 1px;
@include isMobile { @media #{$is-mobile-query} {
display: none; display: none;
} }
} }
-134
View File
@@ -123,22 +123,6 @@ export const shareIOS = createIcon(
{ width: 24, height: 24 }, { width: 24, height: 24 },
); );
export const shareWindows = createIcon(
<>
<path
stroke="currentColor"
fill="currentColor"
d="M40 5.6v6.1l-4.1.7c-8.9 1.4-16.5 6.9-20.6 15C13 32 10.9 43 12.4 43c.4 0 2.4-1.3 4.4-3 5-3.9 12.1-7 18.2-7.7l5-.6v12.8l11.2-11.3L62.5 22 51.2 10.8 40-.5v6.1zm10.2 22.6L44 34.5v-6.8l-6.9.6c-3.9.3-9.8 1.7-13.2 3.1-3.5 1.4-6.5 2.4-6.7 2.2-.9-1 3-7.5 6.4-10.8C28 18.6 34.4 16 40.1 16c3.7 0 3.9-.1 3.9-3.2V9.5l6.2 6.3 6.3 6.2-6.3 6.2z"
/>
<path
stroke="currentColor"
fill="currentColor"
d="M0 36v20h48v-6.2c0-6 0-6.1-2-4.3-1.1 1-2 2.9-2 4.2V52H4V34c0-17.3-.1-18-2-18s-2 .7-2 20z"
/>
</>,
{ width: 64, height: 64 },
);
// Icon imported form Storybook // Icon imported form Storybook
// Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE // Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE
export const resetZoom = createIcon( export const resetZoom = createIcon(
@@ -810,121 +794,3 @@ export const ArrowheadBarIcon = React.memo(
{ width: 40, height: 20 }, { width: 40, height: 20 },
), ),
); );
export const FontSizeSmallIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
fill={iconFillColor(theme)}
d="M 0 69.092 L 0 55.03 A 124.24 124.24 0 0 0 4.706 57.02 Q 6.826 57.863 8.708 58.5 A 53.466 53.466 0 0 0 12.231 59.571 Q 17.236 60.889 21.387 60.889 A 20.909 20.909 0 0 0 24.265 60.704 Q 25.719 60.502 26.903 60.077 A 8.649 8.649 0 0 0 29.028 58.985 Q 31.689 57.08 31.689 53.321 Q 31.689 51.221 30.518 49.585 A 10.126 10.126 0 0 0 29.282 48.177 Q 28.352 47.287 27.075 46.436 A 23.719 23.719 0 0 0 25.752 45.627 Q 23.774 44.492 20.176 42.735 A 254.44 254.44 0 0 0 17.822 41.602 Q 11.503 38.631 8.236 35.888 A 19.742 19.742 0 0 1 8.008 35.694 A 22.18 22.18 0 0 1 2.783 29.102 Q 0.83 25.342 0.83 20.313 A 22.471 22.471 0 0 1 1.733 13.778 A 17.283 17.283 0 0 1 7.251 5.42 A 21.486 21.486 0 0 1 15.177 1.272 Q 18.361 0.338 22.166 0.09 A 43.573 43.573 0 0 1 25 0 A 42.399 42.399 0 0 1 34.349 1.01 A 39.075 39.075 0 0 1 35.62 1.319 A 67.407 67.407 0 0 1 42.108 3.382 A 83.357 83.357 0 0 1 46.191 5.03 L 41.309 16.797 Q 35.596 14.453 31.86 13.526 A 30.762 30.762 0 0 0 25.417 12.612 A 28.337 28.337 0 0 0 24.512 12.598 A 14.846 14.846 0 0 0 22.022 12.793 Q 19.498 13.224 17.92 14.6 Q 15.625 16.602 15.625 19.824 Q 15.625 21.826 16.553 23.316 Q 17.48 24.805 19.507 26.197 A 18.343 18.343 0 0 0 20.659 26.912 Q 22.596 28.035 26.516 29.953 A 299.99 299.99 0 0 0 29.102 31.201 Q 37.91 35.412 41.841 39.642 A 16.553 16.553 0 0 1 42.822 40.796 A 17.675 17.675 0 0 1 46.301 49.233 A 23.517 23.517 0 0 1 46.533 52.588 A 21.581 21.581 0 0 1 45.471 59.515 A 17.733 17.733 0 0 1 39.575 67.823 Q 33.745 72.486 24.094 73.243 A 49.683 49.683 0 0 1 20.215 73.389 A 51.712 51.712 0 0 1 9.448 72.315 A 40.672 40.672 0 0 1 0 69.092 Z"
/>,
{ width: 47, height: 77 },
),
);
export const FontSizeMediumIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
fill={iconFillColor(theme)}
d="M 44.092 71.387 L 30.225 71.387 L 13.037 15.381 L 12.598 15.381 A 1505.093 1505.093 0 0 1 12.959 22.313 Q 13.426 31.715 13.508 36.4 A 102.991 102.991 0 0 1 13.525 38.184 L 13.525 71.387 L 0 71.387 L 0 0 L 20.605 0 L 37.5 54.59 L 37.793 54.59 L 55.713 0 L 76.318 0 L 76.318 71.387 L 62.207 71.387 L 62.207 37.598 Q 62.207 35.205 62.28 32.08 A 160.703 160.703 0 0 1 62.326 30.544 Q 62.452 26.754 62.866 17.168 A 5390.536 5390.536 0 0 1 62.939 15.479 L 62.5 15.479 L 44.092 71.387 Z"
/>,
{ width: 77, height: 75 },
),
);
export const FontSizeLargeIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
fill={iconFillColor(theme)}
d="M 44.092 71.387 L 0 71.387 L 0 0 L 15.137 0 L 15.137 58.887 L 44.092 58.887 L 44.092 71.387 Z"
/>,
{ width: 45, height: 75 },
),
);
export const FontSizeExtraLargeIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
fill={iconFillColor(theme)}
d="M 42.578 35.4 L 66.699 71.387 L 49.414 71.387 L 32.813 44.385 L 16.211 71.387 L 0 71.387 L 23.682 34.57 L 1.514 0 L 18.213 0 L 33.594 25.684 L 48.682 0 L 64.99 0 L 42.578 35.4 Z M 119.775 71.387 L 75.684 71.387 L 75.684 0 L 90.82 0 L 90.82 58.887 L 119.775 58.887 L 119.775 71.387 Z"
/>,
{ width: 120, height: 75 },
),
);
export const FontFamilyHandDrawnIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
fill={iconFillColor(theme)}
d="M290.74 93.24l128.02 128.02-277.99 277.99-114.14 12.6C11.35 513.54-1.56 500.62.14 485.34l12.7-114.22 277.9-277.88zm207.2-19.06l-60.11-60.11c-18.75-18.75-49.16-18.75-67.91 0l-56.55 56.55 128.02 128.02 56.55-56.55c18.75-18.76 18.75-49.16 0-67.91z"
/>,
{ width: 448, height: 512 },
),
);
export const FontFamilyNormalIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
fill={iconFillColor(theme)}
d="M 63.818 71.68 L 54.492 71.68 L 45.898 49.561 L 17.578 49.561 L 9.082 71.68 L 0 71.68 L 27.881 0 L 35.986 0 L 63.818 71.68 Z M 20.605 41.602 L 43.213 41.602 L 35.205 19.971 L 31.787 9.277 Q 30.322 15.137 28.711 19.971 L 20.605 41.602 Z"
/>
<path
fill={iconFillColor(theme)}
d="M 68.994 71.68 L 52.686 71.68 L 47.51 54.688 L 21.484 54.688 L 16.309 71.68 L 0 71.68 L 25.195 0 L 43.701 0 L 68.994 71.68 Z M 25.293 41.992 L 43.896 41.992 A 27590.463 27590.463 0 0 1 42.2 36.532 Q 36.965 19.676 35.937 16.273 A 120.932 120.932 0 0 1 35.815 15.869 A 131.65 131.65 0 0 1 35.396 14.435 Q 34.951 12.879 34.675 11.741 A 34.866 34.866 0 0 1 34.521 11.084 A 141.762 141.762 0 0 1 33.706 14.075 Q 31.482 21.957 25.293 41.992 Z"
/>
</>,
{ width: 70, height: 78 },
),
);
export const FontFamilyCodeIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
fill={iconFillColor(theme)}
d="M278.9 511.5l-61-17.7c-6.4-1.8-10-8.5-8.2-14.9L346.2 8.7c1.8-6.4 8.5-10 14.9-8.2l61 17.7c6.4 1.8 10 8.5 8.2 14.9L293.8 503.3c-1.9 6.4-8.5 10.1-14.9 8.2zm-114-112.2l43.5-46.4c4.6-4.9 4.3-12.7-.8-17.2L117 256l90.6-79.7c5.1-4.5 5.5-12.3.8-17.2l-43.5-46.4c-4.5-4.8-12.1-5.1-17-.5L3.8 247.2c-5.1 4.7-5.1 12.8 0 17.5l144.1 135.1c4.9 4.6 12.5 4.4 17-.5zm327.2.6l144.1-135.1c5.1-4.7 5.1-12.8 0-17.5L492.1 112.1c-4.8-4.5-12.4-4.3-17 .5L431.6 159c-4.6 4.9-4.3 12.7.8 17.2L523 256l-90.6 79.7c-5.1 4.5-5.5 12.3-.8 17.2l43.5 46.4c4.5 4.9 12.1 5.1 17 .6z"
/>
</>,
{ width: 640, height: 512 },
),
);
export const TextAlignLeftIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M12.83 352h262.34A12.82 12.82 0 00288 339.17v-38.34A12.82 12.82 0 00275.17 288H12.83A12.82 12.82 0 000 300.83v38.34A12.82 12.82 0 0012.83 352zm0-256h262.34A12.82 12.82 0 00288 83.17V44.83A12.82 12.82 0 00275.17 32H12.83A12.82 12.82 0 000 44.83v38.34A12.82 12.82 0 0012.83 96zM432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16z"
fill={iconFillColor(theme)}
/>,
{ width: 448, height: 512 },
),
);
export const TextAlignCenterIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zM108.1 96h231.81A12.09 12.09 0 00352 83.9V44.09A12.09 12.09 0 00339.91 32H108.1A12.09 12.09 0 0096 44.09V83.9A12.1 12.1 0 00108.1 96zm231.81 256A12.09 12.09 0 00352 339.9v-39.81A12.09 12.09 0 00339.91 288H108.1A12.09 12.09 0 0096 300.09v39.81a12.1 12.1 0 0012.1 12.1z"
fill={iconFillColor(theme)}
/>,
{ width: 448, height: 512 },
),
);
export const TextAlignRightIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M16 224h416a16 16 0 0016-16v-32a16 16 0 00-16-16H16a16 16 0 00-16 16v32a16 16 0 0016 16zm416 192H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm3.17-384H172.83A12.82 12.82 0 00160 44.83v38.34A12.82 12.82 0 00172.83 96h262.34A12.82 12.82 0 00448 83.17V44.83A12.82 12.82 0 00435.17 32zm0 256H172.83A12.82 12.82 0 00160 300.83v38.34A12.82 12.82 0 00172.83 352h262.34A12.82 12.82 0 00448 339.17v-38.34A12.82 12.82 0 00435.17 288z"
fill={iconFillColor(theme)}
/>,
{ width: 448, height: 512 },
),
);
+2 -33
View File
@@ -1,6 +1,5 @@
import { FontFamily } from "./element/types"; import { FontFamily } from "./element/types";
import cssVariables from "./css/variables.module.scss"; import cssVariables from "./css/variables.module.scss";
import { AppProps } from "./types";
export const APP_NAME = "Excalidraw"; export const APP_NAME = "Excalidraw";
@@ -85,15 +84,9 @@ export const MIME_TYPES = {
excalidrawlib: "application/vnd.excalidrawlib+json", excalidrawlib: "application/vnd.excalidrawlib+json",
}; };
export const EXPORT_DATA_TYPES = {
excalidraw: "excalidraw",
excalidrawClipboard: "excalidraw/clipboard",
excalidrawLibrary: "excalidrawlib",
} as const;
export const STORAGE_KEYS = { export const STORAGE_KEYS = {
LOCAL_STORAGE_LIBRARY: "excalidraw-library", LOCAL_STORAGE_LIBRARY: "excalidraw-library",
} as const; };
// time in milliseconds // time in milliseconds
export const TAP_TWICE_TIMEOUT = 300; export const TAP_TWICE_TIMEOUT = 300;
@@ -102,7 +95,7 @@ export const TITLE_TIMEOUT = 10000;
export const TOAST_TIMEOUT = 5000; export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 30000; export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100; export const SCROLL_TIMEOUT = 100;
export const DETECT_POSITION_CHANGE_INTERVAL = 500;
export const ZOOM_STEP = 0.1; export const ZOOM_STEP = 0.1;
// Report a user inactive after IDLE_THRESHOLD milliseconds // Report a user inactive after IDLE_THRESHOLD milliseconds
@@ -117,27 +110,3 @@ export const MODES = {
}; };
export const THEME_FILTER = cssVariables.themeFilter; export const THEME_FILTER = cssVariables.themeFilter;
export const URL_QUERY_KEYS = {
addLibrary: "addLibrary",
} as const;
export const URL_HASH_KEYS = {
addLibrary: "addLibrary",
} as const;
export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
canvasActions: {
changeViewBackgroundColor: true,
clearCanvas: true,
export: true,
loadScene: true,
saveAsScene: true,
saveScene: true,
theme: true,
},
};
export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
+2 -19
View File
@@ -16,8 +16,6 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 100%;
width: 100%;
// serves 2 purposes: // serves 2 purposes:
// 1. prevent selecting text outside the component when double-clicking or // 1. prevent selecting text outside the component when double-clicking or
@@ -49,12 +47,6 @@
z-index: var(--zIndex-canvas); z-index: var(--zIndex-canvas);
} }
#canvas {
// Remove the main canvas from document flow to avoid resizeObserver
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
position: absolute;
}
&.theme--dark { &.theme--dark {
// The percentage is inspired by // The percentage is inspired by
// https://material.io/design/color/dark-theme.html#properties, which // https://material.io/design/color/dark-theme.html#properties, which
@@ -230,8 +222,7 @@
align-items: center; align-items: center;
svg { svg {
width: 36px; width: 36px;
height: 14px; height: 18px;
padding: 2px;
opacity: 0.6; opacity: 0.6;
} }
&.active svg { &.active svg {
@@ -462,14 +453,6 @@
fill: $oc-gray-6; fill: $oc-gray-6;
bottom: 14px; bottom: 14px;
width: 1.5rem; width: 1.5rem;
padding: 0;
margin: 0;
background: none;
color: var(--icon-fill-color);
&:hover {
background: none;
}
:root[dir="ltr"] & { :root[dir="ltr"] & {
right: 14px; right: 14px;
@@ -480,7 +463,7 @@
} }
} }
@include isMobile { @media #{$is-mobile-query} {
aside { aside {
display: none; display: none;
} }
+3 -6
View File
@@ -1,13 +1,10 @@
@import "open-color/open-color.scss"; @import "open-color/open-color.scss";
@mixin isMobile() { // keep up to date with is-mobile.tsx
@at-root .excalidraw--mobile#{&} { $is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
@content;
}
}
$theme-filter: "invert(93%) hue-rotate(180deg)"; $theme-filter: "invert(93%) hue-rotate(180deg)";
:export { :export {
isMobileQuery: unquote($is-mobile-query);
themeFilter: unquote($theme-filter); themeFilter: unquote($theme-filter);
} }
+9 -3
View File
@@ -1,5 +1,5 @@
import { cleanAppStateForExport } from "../appState"; import { cleanAppStateForExport } from "../appState";
import { EXPORT_DATA_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element"; import { clearElementsForExport } from "../element";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -95,7 +95,13 @@ export const loadFromBlob = async (
elements: clearElementsForExport(data.elements || []), elements: clearElementsForExport(data.elements || []),
appState: { appState: {
theme: localAppState?.theme, theme: localAppState?.theme,
fileHandle: (!blob.type.startsWith("image/") && blob.handle) || null, fileHandle:
blob.handle &&
["application/json", MIME_TYPES.excalidraw].includes(
getMimeType(blob),
)
? blob.handle
: null,
...cleanAppStateForExport(data.appState || {}), ...cleanAppStateForExport(data.appState || {}),
...(localAppState ...(localAppState
? calculateScrollCenter(data.elements || [], localAppState, null) ? calculateScrollCenter(data.elements || [], localAppState, null)
@@ -115,7 +121,7 @@ export const loadFromBlob = async (
export const loadLibraryFromBlob = async (blob: Blob) => { export const loadLibraryFromBlob = async (blob: Blob) => {
const contents = await parseFileContents(blob); const contents = await parseFileContents(blob);
const data: LibraryData = JSON.parse(contents); const data: LibraryData = JSON.parse(contents);
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) { if (data.type !== "excalidrawlib") {
throw new Error(t("alerts.couldNotLoadInvalidFile")); throw new Error(t("alerts.couldNotLoadInvalidFile"));
} }
return data; return data;
+3 -9
View File
@@ -2,7 +2,7 @@ import decodePng from "png-chunks-extract";
import tEXt from "png-chunk-text"; import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode"; import encodePng from "png-chunks-encode";
import { stringToBase64, encode, decode, base64ToString } from "./encode"; import { stringToBase64, encode, decode, base64ToString } from "./encode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// PNG // PNG
@@ -67,10 +67,7 @@ export const decodePngMetadata = async (blob: Blob) => {
const encodedData = JSON.parse(metadata.text); const encodedData = JSON.parse(metadata.text);
if (!("encoded" in encodedData)) { if (!("encoded" in encodedData)) {
// legacy, un-encoded scene JSON // legacy, un-encoded scene JSON
if ( if ("type" in encodedData && encodedData.type === "excalidraw") {
"type" in encodedData &&
encodedData.type === EXPORT_DATA_TYPES.excalidraw
) {
return metadata.text; return metadata.text;
} }
throw new Error("FAILED"); throw new Error("FAILED");
@@ -118,10 +115,7 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
const encodedData = JSON.parse(json); const encodedData = JSON.parse(json);
if (!("encoded" in encodedData)) { if (!("encoded" in encodedData)) {
// legacy, un-encoded scene JSON // legacy, un-encoded scene JSON
if ( if ("type" in encodedData && encodedData.type === "excalidraw") {
"type" in encodedData &&
encodedData.type === EXPORT_DATA_TYPES.excalidraw
) {
return json; return json;
} }
throw new Error("FAILED"); throw new Error("FAILED");
+6 -6
View File
@@ -1,6 +1,6 @@
import { fileOpen, fileSave } from "browser-fs-access"; import { fileOpen, fileSave } from "browser-fs-access";
import { cleanAppStateForExport } from "../appState"; import { cleanAppStateForExport } from "../appState";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element"; import { clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
@@ -14,7 +14,7 @@ export const serializeAsJSON = (
): string => ): string =>
JSON.stringify( JSON.stringify(
{ {
type: EXPORT_DATA_TYPES.excalidraw, type: "excalidraw",
version: 2, version: 2,
source: window.location.origin, source: window.location.origin,
elements: clearElementsForExport(elements), elements: clearElementsForExport(elements),
@@ -69,7 +69,7 @@ export const isValidExcalidrawData = (data?: {
appState?: any; appState?: any;
}): data is ImportedDataState => { }): data is ImportedDataState => {
return ( return (
data?.type === EXPORT_DATA_TYPES.excalidraw && data?.type === "excalidraw" &&
(!data.elements || (!data.elements ||
(Array.isArray(data.elements) && (Array.isArray(data.elements) &&
(!data.appState || typeof data.appState === "object"))) (!data.appState || typeof data.appState === "object")))
@@ -80,7 +80,7 @@ export const isValidLibrary = (json: any) => {
return ( return (
typeof json === "object" && typeof json === "object" &&
json && json &&
json.type === EXPORT_DATA_TYPES.excalidrawLibrary && json.type === "excalidrawlib" &&
json.version === 1 json.version === 1
); );
}; };
@@ -89,7 +89,7 @@ export const saveLibraryAsJSON = async () => {
const library = await Library.loadLibrary(); const library = await Library.loadLibrary();
const serialized = JSON.stringify( const serialized = JSON.stringify(
{ {
type: EXPORT_DATA_TYPES.excalidrawLibrary, type: "excalidrawlib",
version: 1, version: 1,
library, library,
}, },
@@ -116,5 +116,5 @@ export const importLibraryFromJSON = async () => {
extensions: [".json", ".excalidrawlib"], extensions: [".json", ".excalidrawlib"],
*/ */
}); });
await Library.importLibrary(blob); Library.importLibrary(blob);
}; };
-2
View File
@@ -4,11 +4,9 @@ import { restoreElements } from "./restore";
import { STORAGE_KEYS } from "../constants"; import { STORAGE_KEYS } from "../constants";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { NonDeleted, ExcalidrawElement } from "../element/types"; import { NonDeleted, ExcalidrawElement } from "../element/types";
import { nanoid } from "nanoid";
export class Library { export class Library {
private static libraryCache: LibraryItems | null = null; private static libraryCache: LibraryItems | null = null;
public static csrfToken = nanoid();
static resetLibrary = () => { static resetLibrary = () => {
Library.libraryCache = null; Library.libraryCache = null;
+3 -1
View File
@@ -144,7 +144,7 @@ export const restoreElements = (
export const restoreAppState = ( export const restoreAppState = (
appState: ImportedDataState["appState"], appState: ImportedDataState["appState"],
localAppState: Partial<AppState> | null, localAppState: Partial<AppState> | null,
): DataState["appState"] => { ): AppState => {
appState = appState || {}; appState = appState || {};
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
@@ -166,6 +166,8 @@ export const restoreAppState = (
return { return {
...nextAppState, ...nextAppState,
offsetLeft: appState.offsetLeft || 0,
offsetTop: appState.offsetTop || 0,
// Migrates from previous version where appState.zoom was a number // Migrates from previous version where appState.zoom was a number
zoom: zoom:
typeof appState.zoom === "number" typeof appState.zoom === "number"
+1 -1
View File
@@ -6,7 +6,7 @@ export interface DataState {
version?: string; version?: string;
source?: string; source?: string;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
appState: Omit<AppState, "offsetTop" | "offsetLeft" | "width" | "height">; appState: MarkOptional<AppState, "offsetTop" | "offsetLeft">;
} }
export interface ImportedDataState { export interface ImportedDataState {
+6 -27
View File
@@ -87,30 +87,9 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
export const newElementWith = <TElement extends ExcalidrawElement>( export const newElementWith = <TElement extends ExcalidrawElement>(
element: TElement, element: TElement,
updates: ElementUpdate<TElement>, updates: ElementUpdate<TElement>,
): TElement => { ): TElement => ({
let didChange = false; ...element,
for (const key in updates) { ...updates,
const value = (updates as any)[key]; version: element.version + 1,
if (typeof value !== "undefined") { versionNonce: randomInteger(),
if ( });
(element as any)[key] === value &&
// if object, always update in case its deep prop was mutated
(typeof value !== "object" || value === null || key === "groupIds")
) {
continue;
}
didChange = true;
}
}
if (!didChange) {
return element;
}
return {
...element,
...updates,
version: element.version + 1,
versionNonce: randomInteger(),
};
};
+3 -3
View File
@@ -31,7 +31,7 @@ import {
import { PointerDownState } from "../components/App"; import { PointerDownState } from "../components/App";
import { Point } from "../types"; import { Point } from "../types";
export const normalizeAngle = (angle: number): number => { const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) { if (angle >= 2 * Math.PI) {
return angle - 2 * Math.PI; return angle - 2 * Math.PI;
} }
@@ -181,7 +181,7 @@ const getPerfectElementSizeWithRotation = (
return rotate(size.width, size.height, 0, 0, -angle); return rotate(size.width, size.height, 0, 0, -angle);
}; };
export const reshapeSingleTwoPointElement = ( const reshapeSingleTwoPointElement = (
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
resizeArrowDirection: "origin" | "end", resizeArrowDirection: "origin" | "end",
isRotateWithDiscreteAngle: boolean, isRotateWithDiscreteAngle: boolean,
@@ -378,7 +378,7 @@ const resizeSingleTextElement = (
} }
}; };
export const resizeSingleElement = ( const resizeSingleElement = (
stateAtResizeStart: NonDeletedExcalidrawElement, stateAtResizeStart: NonDeletedExcalidrawElement,
shouldKeepSidesRatio: boolean, shouldKeepSidesRatio: boolean,
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
+1 -2
View File
@@ -207,8 +207,7 @@ export const textWysiwyg = ({
// prevent blur when changing properties from the menu // prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => { const onPointerDown = (event: MouseEvent) => {
if ( if (
(event.target instanceof HTMLElement || event.target instanceof HTMLElement &&
event.target instanceof SVGElement) &&
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) && event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
!isWritableElement(event.target) !isWritableElement(event.target)
) { ) {
-85
View File
@@ -1,85 +0,0 @@
import React, { useEffect, useState } from "react";
import { debounce, getVersion, nFormatter } from "../utils";
import {
getElementsStorageSize,
getTotalStorageSize,
} from "./data/localStorage";
import { DEFAULT_VERSION } from "../constants";
import { t } from "../i18n";
import { copyTextToSystemClipboard } from "../clipboard";
type StorageSizes = { scene: number; total: number };
const STORAGE_SIZE_TIMEOUT = 500;
const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
cb({
scene: getElementsStorageSize(),
total: getTotalStorageSize(),
});
}, STORAGE_SIZE_TIMEOUT);
type Props = {
setToastMessage: (message: string) => void;
};
const CustomStats = (props: Props) => {
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
scene: 0,
total: 0,
});
useEffect(() => {
getStorageSizes((sizes) => {
setStorageSizes(sizes);
});
});
useEffect(() => () => getStorageSizes.cancel(), []);
const version = getVersion();
let hash;
let timestamp;
if (version !== DEFAULT_VERSION) {
timestamp = version.slice(0, 16).replace("T", " ");
hash = version.slice(21);
} else {
timestamp = t("stats.versionNotAvailable");
}
return (
<>
<tr>
<th colSpan={2}>{t("stats.storage")}</th>
</tr>
<tr>
<td>{t("stats.scene")}</td>
<td>{nFormatter(storageSizes.scene, 1)}</td>
</tr>
<tr>
<td>{t("stats.total")}</td>
<td>{nFormatter(storageSizes.total, 1)}</td>
</tr>
<tr>
<th colSpan={2}>{t("stats.version")}</th>
</tr>
<tr>
<td
colSpan={2}
style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setToastMessage(t("toast.copyToClipboard"));
} catch {}
}}
title={t("stats.versionCopy")}
>
{timestamp}
<br />
{hash}
</td>
</tr>
</>
);
};
export default CustomStats;
+11 -17
View File
@@ -38,7 +38,7 @@ import Portal from "./Portal";
import RoomDialog from "./RoomDialog"; import RoomDialog from "./RoomDialog";
import { createInverseContext } from "../../createInverseContext"; import { createInverseContext } from "../../createInverseContext";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { UserIdleState } from "../../types"; import { UserIdleState } from "./types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants"; import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
@@ -113,8 +113,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
process.env.NODE_ENV === ENV.TEST || process.env.NODE_ENV === ENV.TEST ||
process.env.NODE_ENV === ENV.DEVELOPMENT process.env.NODE_ENV === ENV.DEVELOPMENT
) { ) {
window.collab = window.collab || ({} as Window["collab"]); window.h = window.h || ({} as Window["h"]);
Object.defineProperties(window, { Object.defineProperties(window.h, {
collab: { collab: {
configurable: true, configurable: true,
value: this, value: this,
@@ -448,8 +448,15 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
private handleRemoteSceneUpdate = ( private handleRemoteSceneUpdate = (
elements: ReconciledElements, elements: ReconciledElements,
{ init = false }: { init?: boolean } = {}, {
init = false,
initFromSnapshot = false,
}: { init?: boolean; initFromSnapshot?: boolean } = {},
) => { ) => {
if (init || initFromSnapshot) {
this.excalidrawAPI.setScrollToContent(elements);
}
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
elements, elements,
commitToHistory: !!init, commitToHistory: !!init,
@@ -658,17 +665,4 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
} }
} }
declare global {
interface Window {
collab: InstanceType<typeof CollabWrapper>;
}
}
if (
process.env.NODE_ENV === ENV.TEST ||
process.env.NODE_ENV === ENV.DEVELOPMENT
) {
window.collab = window.collab || ({} as Window["collab"]);
}
export default CollabWrapper; export default CollabWrapper;
+1 -1
View File
@@ -9,7 +9,7 @@ import CollabWrapper from "./CollabWrapper";
import { getSyncableElements } from "../../packages/excalidraw/index"; import { getSyncableElements } from "../../packages/excalidraw/index";
import { ExcalidrawElement } from "../../element/types"; import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, SCENE } from "../app_constants"; import { BROADCAST, SCENE } from "../app_constants";
import { UserIdleState } from "../../types"; import { UserIdleState } from "./types";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
class Portal { class Portal {
+3 -3
View File
@@ -32,13 +32,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@include isMobile { @media #{$is-mobile-query} {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
} }
@include isMobile { @media #{$is-mobile-query} {
.RoomDialog-usernameLabel { .RoomDialog-usernameLabel {
font-weight: bold; font-weight: bold;
} }
@@ -51,7 +51,7 @@
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 1 1 auto;
margin-inline-start: 1em; margin-inline-start: 1em;
@include isMobile { @media #{$is-mobile-query} {
margin-top: 0.5em; margin-top: 0.5em;
margin-inline-start: 0; margin-inline-start: 0;
} }
+3 -16
View File
@@ -7,27 +7,12 @@ import {
stop, stop,
share, share,
shareIOS, shareIOS,
shareWindows,
} from "../../components/icons"; } from "../../components/icons";
import { ToolButton } from "../../components/ToolButton"; import { ToolButton } from "../../components/ToolButton";
import { t } from "../../i18n"; import { t } from "../../i18n";
import "./RoomDialog.scss"; import "./RoomDialog.scss";
import Stack from "../../components/Stack"; import Stack from "../../components/Stack";
const getShareIcon = () => {
const navigator = window.navigator as any;
const isAppleBrowser = /Apple/.test(navigator.vendor);
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
if (isAppleBrowser) {
return shareIOS;
} else if (isWindowsBrowser) {
return shareWindows;
}
return share;
};
const RoomDialog = ({ const RoomDialog = ({
handleClose, handleClose,
activeRoomLink, activeRoomLink,
@@ -46,6 +31,8 @@ const RoomDialog = ({
setErrorMessage: (message: string) => void; setErrorMessage: (message: string) => void;
}) => { }) => {
const roomLinkInput = useRef<HTMLInputElement>(null); const roomLinkInput = useRef<HTMLInputElement>(null);
const navigator = window.navigator as any;
const isAppleBrowser = /Apple/.test(navigator.vendor);
const copyRoomLink = async () => { const copyRoomLink = async () => {
try { try {
@@ -106,7 +93,7 @@ const RoomDialog = ({
{"share" in navigator ? ( {"share" in navigator ? (
<ToolButton <ToolButton
type="button" type="button"
icon={getShareIcon()} icon={isAppleBrowser ? shareIOS : share}
title={t("labels.share")} title={t("labels.share")}
aria-label={t("labels.share")} aria-label={t("labels.share")}
onClick={shareRoomLink} onClick={shareRoomLink}
+5
View File
@@ -0,0 +1,5 @@
export enum UserIdleState {
ACTIVE = "active",
AWAY = "away",
IDLE = "idle",
}
+17 -42
View File
@@ -3,7 +3,8 @@ import { restore } from "../../data/restore";
import { ImportedDataState } from "../../data/types"; import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types"; import { ExcalidrawElement } from "../../element/types";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { AppState, UserIdleState } from "../../types"; import { AppState } from "../../types";
import { UserIdleState } from "../collab/types";
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2); const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
@@ -79,10 +80,8 @@ export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSour
_brand: "socketUpdateData"; _brand: "socketUpdateData";
}; };
const IV_LENGTH_BYTES = 12; // 96 bits
export const createIV = () => { export const createIV = () => {
const arr = new Uint8Array(IV_LENGTH_BYTES); const arr = new Uint8Array(12);
return window.crypto.getRandomValues(arr); return window.crypto.getRandomValues(arr);
}; };
@@ -176,22 +175,6 @@ export const getImportedKey = (key: string, usage: KeyUsage) =>
[usage], [usage],
); );
const decryptImported = async (
iv: ArrayBuffer,
encrypted: ArrayBuffer,
privateKey: string,
): Promise<ArrayBuffer> => {
const key = await getImportedKey(privateKey, "decrypt");
return window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
key,
encrypted,
);
};
const importFromBackend = async ( const importFromBackend = async (
id: string | null, id: string | null,
privateKey?: string | null, privateKey?: string | null,
@@ -200,7 +183,6 @@ const importFromBackend = async (
const response = await fetch( const response = await fetch(
privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`, privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
); );
if (!response.ok) { if (!response.ok) {
window.alert(t("alerts.importBackendFailed")); window.alert(t("alerts.importBackendFailed"));
return {}; return {};
@@ -208,19 +190,16 @@ const importFromBackend = async (
let data: ImportedDataState; let data: ImportedDataState;
if (privateKey) { if (privateKey) {
const buffer = await response.arrayBuffer(); const buffer = await response.arrayBuffer();
const key = await getImportedKey(privateKey, "decrypt");
let decrypted: ArrayBuffer; const iv = new Uint8Array(12);
try { const decrypted = await window.crypto.subtle.decrypt(
// Buffer should contain both the IV (fixed length) and encrypted data {
const iv = buffer.slice(0, IV_LENGTH_BYTES); name: "AES-GCM",
const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength); iv,
decrypted = await decryptImported(iv, encrypted, privateKey); },
} catch (error) { key,
// Fixed IV (old format, backward compatibility) buffer,
const fixedIv = new Uint8Array(IV_LENGTH_BYTES); );
decrypted = await decryptImported(fixedIv, buffer, privateKey);
}
// We need to convert the decrypted array buffer to a string // We need to convert the decrypted array buffer to a string
const string = new window.TextDecoder("utf-8").decode( const string = new window.TextDecoder("utf-8").decode(
new Uint8Array(decrypted) as any, new Uint8Array(decrypted) as any,
@@ -284,8 +263,9 @@ export const exportToBackend = async (
true, // extractable true, // extractable
["encrypt", "decrypt"], ["encrypt", "decrypt"],
); );
// The iv is set to 0. We are never going to reuse the same key so we don't
const iv = createIV(); // need to have an iv. (I hope that's correct...)
const iv = new Uint8Array(12);
// We use symmetric encryption. AES-GCM is the recommended algorithm and // We use symmetric encryption. AES-GCM is the recommended algorithm and
// includes checks that the ciphertext has not been modified by an attacker. // includes checks that the ciphertext has not been modified by an attacker.
const encrypted = await window.crypto.subtle.encrypt( const encrypted = await window.crypto.subtle.encrypt(
@@ -296,11 +276,6 @@ export const exportToBackend = async (
key, key,
encoded, encoded,
); );
// Concatenate IV with encrypted data (IV does not have to be secret).
const payloadBlob = new Blob([iv.buffer, encrypted]);
const payload = await new Response(payloadBlob).arrayBuffer();
// We use jwk encoding to be able to extract just the base64 encoded key. // We use jwk encoding to be able to extract just the base64 encoded key.
// We will hardcode the rest of the attributes when importing back the key. // We will hardcode the rest of the attributes when importing back the key.
const exportedKey = await window.crypto.subtle.exportKey("jwk", key); const exportedKey = await window.crypto.subtle.exportKey("jwk", key);
@@ -308,7 +283,7 @@ export const exportToBackend = async (
try { try {
const response = await fetch(BACKEND_V2_POST, { const response = await fetch(BACKEND_V2_POST, {
method: "POST", method: "POST",
body: payload, body: encrypted,
}); });
const json = await response.json(); const json = await response.json();
if (json.id) { if (json.id) {
+4 -4
View File
@@ -101,7 +101,7 @@ export const importFromLocalStorage = () => {
export const getElementsStorageSize = () => { export const getElementsStorageSize = () => {
try { try {
const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS); const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
const elementsSize = elements?.length || 0; const elementsSize = elements ? JSON.stringify(elements).length : 0;
return elementsSize; return elementsSize;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -117,9 +117,9 @@ export const getTotalStorageSize = () => {
APP_STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, APP_STORAGE_KEYS.LOCAL_STORAGE_LIBRARY,
); );
const appStateSize = appState?.length || 0; const appStateSize = appState ? JSON.stringify(appState).length : 0;
const collabSize = collab?.length || 0; const collabSize = collab ? JSON.stringify(collab).length : 0;
const librarySize = library?.length || 0; const librarySize = library ? JSON.stringify(library).length : 0;
return appStateSize + collabSize + librarySize + getElementsStorageSize(); return appStateSize + collabSize + librarySize + getElementsStorageSize();
} catch (error) { } catch (error) {
+34 -42
View File
@@ -3,6 +3,7 @@ import React, {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useLayoutEffect,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
@@ -11,13 +12,7 @@ import { getDefaultAppState } from "../appState";
import { ExcalidrawImperativeAPI } from "../components/App"; import { ExcalidrawImperativeAPI } from "../components/App";
import { ErrorDialog } from "../components/ErrorDialog"; import { ErrorDialog } from "../components/ErrorDialog";
import { TopErrorBoundary } from "../components/TopErrorBoundary"; import { TopErrorBoundary } from "../components/TopErrorBoundary";
import { import { APP_NAME, EVENT, TITLE_TIMEOUT, VERSION_TIMEOUT } from "../constants";
APP_NAME,
EVENT,
TITLE_TIMEOUT,
URL_HASH_KEYS,
VERSION_TIMEOUT,
} from "../constants";
import { loadFromBlob } from "../data/blob"; import { loadFromBlob } from "../data/blob";
import { DataState, ImportedDataState } from "../data/types"; import { DataState, ImportedDataState } from "../data/types";
import { import {
@@ -49,7 +44,6 @@ import {
importFromLocalStorage, importFromLocalStorage,
saveToLocalStorage, saveToLocalStorage,
} from "./data/localStorage"; } from "./data/localStorage";
import CustomStats from "./CustomStats";
const languageDetector = new LanguageDetector(); const languageDetector = new LanguageDetector();
languageDetector.init({ languageDetector.init({
@@ -161,11 +155,31 @@ const initializeScene = async (opts: {
return null; return null;
}; };
const ExcalidrawWrapper = () => { function ExcalidrawWrapper() {
// dimensions
// ---------------------------------------------------------------------------
const [dimensions, setDimensions] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const currentLangCode = languageDetector.detect() || defaultLang.code; const currentLangCode = languageDetector.detect() || defaultLang.code;
const [langCode, setLangCode] = useState(currentLangCode); const [langCode, setLangCode] = useState(currentLangCode);
useLayoutEffect(() => {
const onResize = () => {
setDimensions({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// initial state // initial state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -199,24 +213,12 @@ const ExcalidrawWrapper = () => {
initialStatePromiseRef.current.promise.resolve(scene); initialStatePromiseRef.current.promise.resolve(scene);
}); });
const onHashChange = (event: HashChangeEvent) => { const onHashChange = (_: HashChangeEvent) => {
event.preventDefault(); initializeScene({ collabAPI }).then((scene) => {
const hash = new URLSearchParams(window.location.hash.slice(1)); if (scene) {
const libraryUrl = hash.get(URL_HASH_KEYS.addLibrary); excalidrawAPI.updateScene(scene);
if (libraryUrl) { }
// If hash changed and it contains library url, import it and replace });
// the url to its previous state (important in case of collaboration
// and similar).
// Using history API won't trigger another hashchange.
window.history.replaceState({}, "", event.oldURL);
excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
} else {
initializeScene({ collabAPI }).then((scene) => {
if (scene) {
excalidrawAPI.updateScene(scene);
}
});
}
}; };
const titleTimeout = setTimeout( const titleTimeout = setTimeout(
@@ -303,19 +305,13 @@ const ExcalidrawWrapper = () => {
[langCode], [langCode],
); );
const renderCustomStats = () => {
return (
<CustomStats
setToastMessage={(message) => excalidrawAPI!.setToastMessage(message)}
/>
);
};
return ( return (
<> <>
<Excalidraw <Excalidraw
ref={excalidrawRefCallback} ref={excalidrawRefCallback}
onChange={onChange} onChange={onChange}
width={dimensions.width}
height={dimensions.height}
initialData={initialStatePromiseRef.current.promise} initialData={initialStatePromiseRef.current.promise}
onCollabButtonClick={collabAPI?.onCollabButtonClick} onCollabButtonClick={collabAPI?.onCollabButtonClick}
isCollaborating={collabAPI?.isCollaborating()} isCollaborating={collabAPI?.isCollaborating()}
@@ -323,8 +319,6 @@ const ExcalidrawWrapper = () => {
onExportToBackend={onExportToBackend} onExportToBackend={onExportToBackend}
renderFooter={renderFooter} renderFooter={renderFooter}
langCode={langCode} langCode={langCode}
renderCustomStats={renderCustomStats}
detectScroll={false}
/> />
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />} {excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
{errorMessage && ( {errorMessage && (
@@ -335,9 +329,9 @@ const ExcalidrawWrapper = () => {
)} )}
</> </>
); );
}; }
const ExcalidrawApp = () => { export default function ExcalidrawApp() {
return ( return (
<TopErrorBoundary> <TopErrorBoundary>
<CollabContextConsumer> <CollabContextConsumer>
@@ -345,6 +339,4 @@ const ExcalidrawApp = () => {
</CollabContextConsumer> </CollabContextConsumer>
</TopErrorBoundary> </TopErrorBoundary>
); );
}; }
export default ExcalidrawApp;
-2
View File
@@ -89,5 +89,3 @@ interface Blob {
handle?: import("browser-fs-acces").FileSystemHandle; handle?: import("browser-fs-acces").FileSystemHandle;
name?: string; name?: string;
} }
declare module "*.scss";
-2
View File
@@ -63,8 +63,6 @@ const canvas = exportToCanvas(
...getDefaultAppState(), ...getDefaultAppState(),
offsetTop: 0, offsetTop: 0,
offsetLeft: 0, offsetLeft: 0,
width: 0,
height: 0,
}, },
{ {
exportBackground: true, exportBackground: true,
+40
View File
@@ -0,0 +1,40 @@
import React, { useState, useEffect, useRef, useContext } from "react";
import variables from "./css/variables.module.scss";
const context = React.createContext(false);
const getIsMobileMatcher = () => {
return window.matchMedia
? window.matchMedia(variables.isMobileQuery)
: (({
matches: false,
addListener: () => {},
removeListener: () => {},
} as any) as MediaQueryList);
};
export const IsMobileProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const query = useRef<MediaQueryList>();
if (!query.current) {
query.current = getIsMobileMatcher();
}
const [isMobile, setMobile] = useState(query.current.matches);
useEffect(() => {
const handler = () => setMobile(query.current!.matches);
query.current!.addListener(handler);
return () => query.current!.removeListener(handler);
}, []);
return <context.Provider value={isMobile}>{children}</context.Provider>;
};
export const isMobile = () => getIsMobileMatcher().matches;
export default function useIsMobile() {
return useContext(context);
}
+10 -13
View File
@@ -61,7 +61,7 @@
"architect": "معماري", "architect": "معماري",
"artist": "رسام", "artist": "رسام",
"cartoonist": "كرتوني", "cartoonist": "كرتوني",
"fileTitle": "", "fileTitle": "عنوان الملف",
"colorPicker": "اختيار الألوان", "colorPicker": "اختيار الألوان",
"canvasBackground": "خلفية اللوحة", "canvasBackground": "خلفية اللوحة",
"drawingCanvas": "لوحة الرسم", "drawingCanvas": "لوحة الرسم",
@@ -77,7 +77,7 @@
"group": "تحديد مجموعة", "group": "تحديد مجموعة",
"ungroup": "إلغاء تحديد مجموعة", "ungroup": "إلغاء تحديد مجموعة",
"collaborators": "المتعاونون", "collaborators": "المتعاونون",
"showGrid": "إظهار الشبكة", "showGrid": "",
"addToLibrary": "أضف إلى المكتبة", "addToLibrary": "أضف إلى المكتبة",
"removeFromLibrary": "حذف من المكتبة", "removeFromLibrary": "حذف من المكتبة",
"libraryLoadingMessage": "جارٍ تحميل المكتبة…", "libraryLoadingMessage": "جارٍ تحميل المكتبة…",
@@ -92,11 +92,9 @@
"centerHorizontally": "توسيط أفقي", "centerHorizontally": "توسيط أفقي",
"distributeHorizontally": "التوزيع الأفقي", "distributeHorizontally": "التوزيع الأفقي",
"distributeVertically": "التوزيع عمودياً", "distributeVertically": "التوزيع عمودياً",
"flipHorizontal": "", "viewMode": "",
"flipVertical": "",
"viewMode": "نمط العرض",
"toggleExportColorScheme": "", "toggleExportColorScheme": "",
"share": "مشاركة" "share": ""
}, },
"buttons": { "buttons": {
"clearReset": "إعادة تعيين اللوحة", "clearReset": "إعادة تعيين اللوحة",
@@ -121,7 +119,7 @@
"edit": "تعديل", "edit": "تعديل",
"undo": "تراجع", "undo": "تراجع",
"redo": "إعادة تنفيذ", "redo": "إعادة تنفيذ",
"resetLibrary": "إعادة ضبط المكتبة", "resetLibrary": "",
"createNewRoom": "إنشاء غرفة جديدة", "createNewRoom": "إنشاء غرفة جديدة",
"fullScreen": "شاشة كاملة", "fullScreen": "شاشة كاملة",
"darkMode": "الوضع المظلم", "darkMode": "الوضع المظلم",
@@ -140,7 +138,7 @@
"decryptFailed": "تعذر فك تشفير البيانات.", "decryptFailed": "تعذر فك تشفير البيانات.",
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.", "uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟", "loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
"collabStopOverridePrompt": "إيقاف الجلسة سيؤدي إلى الكتابة فوق رسومك السابقة المخزنة داخليا. هل أنت متأكد؟\n\n(إذا كنت ترغب في الاحتفاظ برسمك المخزن داخليا، ببساطة أغلق علامة تبويب المتصفح بدلاً من ذلك.)", "collabStopOverridePrompt": "",
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.", "errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟", "confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟", "imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
@@ -214,9 +212,9 @@
"curvedArrow": "", "curvedArrow": "",
"curvedLine": "", "curvedLine": "",
"documentation": "", "documentation": "",
"drag": "اسحب", "drag": "",
"editor": "المحرر", "editor": "",
"github": "عثرت على مشكلة؟ إرسال", "github": "",
"howto": "", "howto": "",
"or": "", "or": "",
"preventBinding": "", "preventBinding": "",
@@ -230,8 +228,7 @@
"zoomToSelection": "" "zoomToSelection": ""
}, },
"encrypted": { "encrypted": {
"tooltip": "رسوماتك مشفرة من النهاية إلى النهاية حتى أن خوادم Excalidraw لن تراها أبدا.", "tooltip": "رسوماتك مشفرة من النهاية إلى النهاية حتى أن خوادم Excalidraw لن تراها أبدا."
"link": ""
}, },
"stats": { "stats": {
"angle": "الزاوية", "angle": "الزاوية",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Архитект", "architect": "Архитект",
"artist": "Художник", "artist": "Художник",
"cartoonist": "Карикатурист", "cartoonist": "Карикатурист",
"fileTitle": "", "fileTitle": "Заглавие на файл",
"colorPicker": "Избор на цвят", "colorPicker": "Избор на цвят",
"canvasBackground": "Фон на платно", "canvasBackground": "Фон на платно",
"drawingCanvas": "Платно за рисуване", "drawingCanvas": "Платно за рисуване",
@@ -92,8 +92,6 @@
"centerHorizontally": "Центрирай хоризонтално", "centerHorizontally": "Центрирай хоризонтално",
"distributeHorizontally": "Разпредели хоризонтално", "distributeHorizontally": "Разпредели хоризонтално",
"distributeVertically": "Разпредели вертикално", "distributeVertically": "Разпредели вертикално",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "Изглед", "viewMode": "Изглед",
"toggleExportColorScheme": "", "toggleExportColorScheme": "",
"share": "" "share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "Приближи селекцията" "zoomToSelection": "Приближи селекцията"
}, },
"encrypted": { "encrypted": {
"tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат.", "tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат."
"link": ""
}, },
"stats": { "stats": {
"angle": "Ъгъл", "angle": "Ъгъл",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Arquitecte", "architect": "Arquitecte",
"artist": "Artista", "artist": "Artista",
"cartoonist": "Dibuixant", "cartoonist": "Dibuixant",
"fileTitle": "", "fileTitle": "Títol del fitxer",
"colorPicker": "Selector de colors", "colorPicker": "Selector de colors",
"canvasBackground": "Fons del llenç", "canvasBackground": "Fons del llenç",
"drawingCanvas": "Llenç de dibuix", "drawingCanvas": "Llenç de dibuix",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centrar horitzontalment", "centerHorizontally": "Centrar horitzontalment",
"distributeHorizontally": "Distribuir horitzontalment", "distributeHorizontally": "Distribuir horitzontalment",
"distributeVertically": "Distribuir verticalment", "distributeVertically": "Distribuir verticalment",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "Mode de visualització", "viewMode": "Mode de visualització",
"toggleExportColorScheme": "Canvia l'esquema de colors de l'exportació", "toggleExportColorScheme": "Canvia l'esquema de colors de l'exportació",
"share": "Compartir" "share": "Compartir"
@@ -230,8 +228,7 @@
"zoomToSelection": "Zoom per veure la selecció" "zoomToSelection": "Zoom per veure la selecció"
}, },
"encrypted": { "encrypted": {
"tooltip": "Els vostres dibuixos estan xifrats de punta a punta de manera que els servidors dExcalidraw no els veuran mai.", "tooltip": "Els vostres dibuixos estan xifrats de punta a punta de manera que els servidors dExcalidraw no els veuran mai."
"link": ""
}, },
"stats": { "stats": {
"angle": "Angle", "angle": "Angle",
+1 -4
View File
@@ -92,8 +92,6 @@
"centerHorizontally": "Horizontal zentrieren", "centerHorizontally": "Horizontal zentrieren",
"distributeHorizontally": "Horizontal verteilen", "distributeHorizontally": "Horizontal verteilen",
"distributeVertically": "Vertikal verteilen", "distributeVertically": "Vertikal verteilen",
"flipHorizontal": "Horizontal spiegeln",
"flipVertical": "Vertikal spiegeln",
"viewMode": "Ansichtsmodus", "viewMode": "Ansichtsmodus",
"toggleExportColorScheme": "Farbschema für Export umschalten", "toggleExportColorScheme": "Farbschema für Export umschalten",
"share": "Teilen" "share": "Teilen"
@@ -230,8 +228,7 @@
"zoomToSelection": "Auf Auswahl zoomen" "zoomToSelection": "Auf Auswahl zoomen"
}, },
"encrypted": { "encrypted": {
"tooltip": "Da deine Zeichnungen Ende-zu-Ende verschlüsselt werden, sehen auch unsere Excalidraw-Server sie niemals.", "tooltip": "Da deine Zeichnungen Ende-zu-Ende verschlüsselt werden, sehen auch unsere Excalidraw-Server sie niemals."
"link": "Blogbeitrag über Ende-zu-Ende-Verschlüsselung in Excalidraw"
}, },
"stats": { "stats": {
"angle": "Winkel", "angle": "Winkel",
+3 -6
View File
@@ -61,7 +61,7 @@
"architect": "Αρχιτέκτονας", "architect": "Αρχιτέκτονας",
"artist": "Καλλιτέχνης", "artist": "Καλλιτέχνης",
"cartoonist": "Σκιτσογράφος", "cartoonist": "Σκιτσογράφος",
"fileTitle": "Όνομα αρχείου", "fileTitle": "Τίτλος αρχείου",
"colorPicker": "Επιλογή Χρώματος", "colorPicker": "Επιλογή Χρώματος",
"canvasBackground": "Φόντο καμβά", "canvasBackground": "Φόντο καμβά",
"drawingCanvas": "Σχεδίαση καμβά", "drawingCanvas": "Σχεδίαση καμβά",
@@ -92,11 +92,9 @@
"centerHorizontally": "Κέντρο οριζόντια", "centerHorizontally": "Κέντρο οριζόντια",
"distributeHorizontally": "Οριζόντια κατανομή", "distributeHorizontally": "Οριζόντια κατανομή",
"distributeVertically": "Κατακόρυφη κατανομή", "distributeVertically": "Κατακόρυφη κατανομή",
"flipHorizontal": "Οριζόντια αναστροφή",
"flipVertical": "Κατακόρυφη αναστροφή",
"viewMode": "Λειτουργία προβολής", "viewMode": "Λειτουργία προβολής",
"toggleExportColorScheme": "Εναλλαγή εξαγωγής θέματος χρωμάτων", "toggleExportColorScheme": "Εναλλαγή εξαγωγής θέματος χρωμάτων",
"share": "Κοινοποίηση" "share": ""
}, },
"buttons": { "buttons": {
"clearReset": "Επαναφορά του καμβά", "clearReset": "Επαναφορά του καμβά",
@@ -230,8 +228,7 @@
"zoomToSelection": "Ζουμ στην επιλογή" "zoomToSelection": "Ζουμ στην επιλογή"
}, },
"encrypted": { "encrypted": {
"tooltip": "Τα σχέδιά σου είναι κρυπτογραφημένα από άκρο σε άκρο, έτσι δεν θα είναι ποτέ ορατά μέσα από τους διακομιστές του Excalidraw.", "tooltip": "Τα σχέδιά σου είναι κρυπτογραφημένα από άκρο σε άκρο, έτσι δεν θα είναι ποτέ ορατά μέσα από τους διακομιστές του Excalidraw."
"link": ""
}, },
"stats": { "stats": {
"angle": "Γωνία", "angle": "Γωνία",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Architect", "architect": "Architect",
"artist": "Artist", "artist": "Artist",
"cartoonist": "Cartoonist", "cartoonist": "Cartoonist",
"fileTitle": "File name", "fileTitle": "File title",
"colorPicker": "Color picker", "colorPicker": "Color picker",
"canvasBackground": "Canvas background", "canvasBackground": "Canvas background",
"drawingCanvas": "Drawing canvas", "drawingCanvas": "Drawing canvas",
@@ -92,8 +92,6 @@
"centerHorizontally": "Center horizontally", "centerHorizontally": "Center horizontally",
"distributeHorizontally": "Distribute horizontally", "distributeHorizontally": "Distribute horizontally",
"distributeVertically": "Distribute vertically", "distributeVertically": "Distribute vertically",
"flipHorizontal": "Flip horizontal",
"flipVertical": "Flip vertical",
"viewMode": "View mode", "viewMode": "View mode",
"toggleExportColorScheme": "Toggle export color scheme", "toggleExportColorScheme": "Toggle export color scheme",
"share": "Share" "share": "Share"
@@ -230,8 +228,7 @@
"zoomToSelection": "Zoom to selection" "zoomToSelection": "Zoom to selection"
}, },
"encrypted": { "encrypted": {
"tooltip": "Your drawings are end-to-end encrypted so Excalidraw's servers will never see them.", "tooltip": "Your drawings are end-to-end encrypted so Excalidraw's servers will never see them."
"link": "Blog post on end-to-end encryption in Excalidraw"
}, },
"stats": { "stats": {
"angle": "Angle", "angle": "Angle",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Arquitecto", "architect": "Arquitecto",
"artist": "Artista", "artist": "Artista",
"cartoonist": "Caricatura", "cartoonist": "Caricatura",
"fileTitle": "Nombre del archivo", "fileTitle": "Título del archivo",
"colorPicker": "Selector de color", "colorPicker": "Selector de color",
"canvasBackground": "Fondo del lienzo", "canvasBackground": "Fondo del lienzo",
"drawingCanvas": "Lienzo de dibujo", "drawingCanvas": "Lienzo de dibujo",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centrar horizontalmente", "centerHorizontally": "Centrar horizontalmente",
"distributeHorizontally": "Distribuir horizontalmente", "distributeHorizontally": "Distribuir horizontalmente",
"distributeVertically": "Distribuir verticalmente", "distributeVertically": "Distribuir verticalmente",
"flipHorizontal": "Girar horizontalmente",
"flipVertical": "Girar verticalmente",
"viewMode": "Modo presentación", "viewMode": "Modo presentación",
"toggleExportColorScheme": "Cambiar el esquema de colores de exportación", "toggleExportColorScheme": "Cambiar el esquema de colores de exportación",
"share": "Compartir" "share": "Compartir"
@@ -230,8 +228,7 @@
"zoomToSelection": "Hacer zoom a la selección" "zoomToSelection": "Hacer zoom a la selección"
}, },
"encrypted": { "encrypted": {
"tooltip": "Tus dibujos están cifrados de punto a punto, por lo que los servidores de Excalidraw nunca los verán.", "tooltip": "Tus dibujos están cifrados de punto a punto, por lo que los servidores de Excalidraw nunca los verán."
"link": ""
}, },
"stats": { "stats": {
"angle": "Ángulo", "angle": "Ángulo",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "معمار", "architect": "معمار",
"artist": "هنرمند", "artist": "هنرمند",
"cartoonist": "کارتونیست", "cartoonist": "کارتونیست",
"fileTitle": "", "fileTitle": "عنوان فایل",
"colorPicker": "انتخابگر رنگ", "colorPicker": "انتخابگر رنگ",
"canvasBackground": "بوم", "canvasBackground": "بوم",
"drawingCanvas": "بوم نقاشی", "drawingCanvas": "بوم نقاشی",
@@ -92,8 +92,6 @@
"centerHorizontally": "وسط قرار دادن به صورت افقی", "centerHorizontally": "وسط قرار دادن به صورت افقی",
"distributeHorizontally": "توزیع کردن به صورت افقی", "distributeHorizontally": "توزیع کردن به صورت افقی",
"distributeVertically": "توزیع کردن به صورت عمودی", "distributeVertically": "توزیع کردن به صورت عمودی",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "", "viewMode": "",
"toggleExportColorScheme": "", "toggleExportColorScheme": "",
"share": "" "share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "بزرگنمایی قسمت انتخاب شده" "zoomToSelection": "بزرگنمایی قسمت انتخاب شده"
}, },
"encrypted": { "encrypted": {
"tooltip": "شما در یک محیط رمزگزاری شده دو طرفه در حال طراحی هستید پس Excalidraw هرگز طرح های شما را نمیبند.", "tooltip": "شما در یک محیط رمزگزاری شده دو طرفه در حال طراحی هستید پس Excalidraw هرگز طرح های شما را نمیبند."
"link": ""
}, },
"stats": { "stats": {
"angle": "زاویه", "angle": "زاویه",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Arkkitehti", "architect": "Arkkitehti",
"artist": "Taiteilija", "artist": "Taiteilija",
"cartoonist": "Sarjakuva", "cartoonist": "Sarjakuva",
"fileTitle": "Tiedostonimi", "fileTitle": "Tiedoston otsikko",
"colorPicker": "Värin valinta", "colorPicker": "Värin valinta",
"canvasBackground": "Piirtoalueen tausta", "canvasBackground": "Piirtoalueen tausta",
"drawingCanvas": "Piirtoalue", "drawingCanvas": "Piirtoalue",
@@ -92,8 +92,6 @@
"centerHorizontally": "Keskitä vaakasuunnassa", "centerHorizontally": "Keskitä vaakasuunnassa",
"distributeHorizontally": "Jaa vaakasuunnassa", "distributeHorizontally": "Jaa vaakasuunnassa",
"distributeVertically": "Jaa pystysuunnassa", "distributeVertically": "Jaa pystysuunnassa",
"flipHorizontal": "Käännä vaakasuunnassa",
"flipVertical": "Käännä pystysuunnassa",
"viewMode": "Katselutila", "viewMode": "Katselutila",
"toggleExportColorScheme": "Vaihda viennin väriteema", "toggleExportColorScheme": "Vaihda viennin väriteema",
"share": "Jaa" "share": "Jaa"
@@ -230,8 +228,7 @@
"zoomToSelection": "Näytä valinta" "zoomToSelection": "Näytä valinta"
}, },
"encrypted": { "encrypted": {
"tooltip": "Piirroksesi ovat päästä-päähän-salattuja, joten Excalidrawin palvelimet eivät koskaan näe niitä.", "tooltip": "Piirroksesi ovat päästä-päähän-salattuja, joten Excalidrawin palvelimet eivät koskaan näe niitä."
"link": ""
}, },
"stats": { "stats": {
"angle": "Kulma", "angle": "Kulma",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Architecte", "architect": "Architecte",
"artist": "Artiste", "artist": "Artiste",
"cartoonist": "Caricaturiste", "cartoonist": "Caricaturiste",
"fileTitle": "Nom du fichier", "fileTitle": "Titre du fichier",
"colorPicker": "Sélecteur de couleur", "colorPicker": "Sélecteur de couleur",
"canvasBackground": "Arrière-plan du canevas", "canvasBackground": "Arrière-plan du canevas",
"drawingCanvas": "Zone de dessin", "drawingCanvas": "Zone de dessin",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centrer horizontalement", "centerHorizontally": "Centrer horizontalement",
"distributeHorizontally": "Distribuer horizontalement", "distributeHorizontally": "Distribuer horizontalement",
"distributeVertically": "Distribuer verticalement", "distributeVertically": "Distribuer verticalement",
"flipHorizontal": "Retourner horizontalement",
"flipVertical": "Retourner verticalement",
"viewMode": "Mode présentation", "viewMode": "Mode présentation",
"toggleExportColorScheme": "Activer/Désactiver l'export du thème de couleur", "toggleExportColorScheme": "Activer/Désactiver l'export du thème de couleur",
"share": "Partager" "share": "Partager"
@@ -230,8 +228,7 @@
"zoomToSelection": "Zoomer sur la sélection" "zoomToSelection": "Zoomer sur la sélection"
}, },
"encrypted": { "encrypted": {
"tooltip": "Vos dessins sont chiffrés de bout en bout, les serveurs d'Excalidraw ne les verront jamais.", "tooltip": "Vos dessins sont chiffrés de bout en bout, les serveurs d'Excalidraw ne les verront jamais."
"link": "Article de blog sur le chiffrement de bout en bout dans Excalidraw"
}, },
"stats": { "stats": {
"angle": "Angle", "angle": "Angle",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "ארכיטקט", "architect": "ארכיטקט",
"artist": "אמן", "artist": "אמן",
"cartoonist": "קריקטוריסט", "cartoonist": "קריקטוריסט",
"fileTitle": "", "fileTitle": "כותרת הקובץ",
"colorPicker": "בחירת צבע", "colorPicker": "בחירת צבע",
"canvasBackground": "רקע הלוח", "canvasBackground": "רקע הלוח",
"drawingCanvas": "לוח ציור", "drawingCanvas": "לוח ציור",
@@ -92,8 +92,6 @@
"centerHorizontally": "מרכז אופקית", "centerHorizontally": "מרכז אופקית",
"distributeHorizontally": "חלוקה אופקית", "distributeHorizontally": "חלוקה אופקית",
"distributeVertically": "חלוקה אנכית", "distributeVertically": "חלוקה אנכית",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "מצב תצוגה", "viewMode": "מצב תצוגה",
"toggleExportColorScheme": "", "toggleExportColorScheme": "",
"share": "" "share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "התמקד בבחירה" "zoomToSelection": "התמקד בבחירה"
}, },
"encrypted": { "encrypted": {
"tooltip": "הרישומים שלך מוצפנים מקצה לקצה כך שהשרתים של Excalidraw לא יראו אותם לעולם.", "tooltip": "הרישומים שלך מוצפנים מקצה לקצה כך שהשרתים של Excalidraw לא יראו אותם לעולם."
"link": ""
}, },
"stats": { "stats": {
"angle": "זווית", "angle": "זווית",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "वास्तुकार", "architect": "वास्तुकार",
"artist": "कलाकार", "artist": "कलाकार",
"cartoonist": "व्यंग्य चित्रकार", "cartoonist": "व्यंग्य चित्रकार",
"fileTitle": "फ़ाइल का नाम", "fileTitle": "फ़ाइल का शीर्षक",
"colorPicker": "रंग चयन", "colorPicker": "रंग चयन",
"canvasBackground": "कैनवास बैकग्राउंड", "canvasBackground": "कैनवास बैकग्राउंड",
"drawingCanvas": "कैनवास बना रहे हैं", "drawingCanvas": "कैनवास बना रहे हैं",
@@ -92,8 +92,6 @@
"centerHorizontally": "क्षैतिज केन्द्रित", "centerHorizontally": "क्षैतिज केन्द्रित",
"distributeHorizontally": "क्षैतिज रूप से वितरित करें", "distributeHorizontally": "क्षैतिज रूप से वितरित करें",
"distributeVertically": "खड़ी रूप से वितरित करें", "distributeVertically": "खड़ी रूप से वितरित करें",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "", "viewMode": "",
"toggleExportColorScheme": "", "toggleExportColorScheme": "",
"share": "" "share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "चयन तक ज़ूम करे" "zoomToSelection": "चयन तक ज़ूम करे"
}, },
"encrypted": { "encrypted": {
"tooltip": "आपके चित्र अंत-से-अंत एन्क्रिप्टेड हैं, इसलिए एक्सक्लूसिव्रॉव के सर्वर उन्हें कभी नहीं देखेंगे।", "tooltip": "आपके चित्र अंत-से-अंत एन्क्रिप्टेड हैं, इसलिए एक्सक्लूसिव्रॉव के सर्वर उन्हें कभी नहीं देखेंगे।"
"link": ""
}, },
"stats": { "stats": {
"angle": "कोण", "angle": "कोण",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Tervezői", "architect": "Tervezői",
"artist": "Művészi", "artist": "Művészi",
"cartoonist": "Karikatúrás", "cartoonist": "Karikatúrás",
"fileTitle": "", "fileTitle": "Fájl címe",
"colorPicker": "Színválasztó", "colorPicker": "Színválasztó",
"canvasBackground": "Vászon háttérszíne", "canvasBackground": "Vászon háttérszíne",
"drawingCanvas": "Rajzvászon", "drawingCanvas": "Rajzvászon",
@@ -92,8 +92,6 @@
"centerHorizontally": "Vízszintesen középre igazított", "centerHorizontally": "Vízszintesen középre igazított",
"distributeHorizontally": "Vízszintes elosztás", "distributeHorizontally": "Vízszintes elosztás",
"distributeVertically": "Függőleges elosztás", "distributeVertically": "Függőleges elosztás",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "", "viewMode": "",
"toggleExportColorScheme": "", "toggleExportColorScheme": "",
"share": "" "share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "" "zoomToSelection": ""
}, },
"encrypted": { "encrypted": {
"tooltip": "A rajzaidat végpontok közötti titkosítással tároljuk, tehát az Excalidraw szervereiről se tud más belenézni.", "tooltip": "A rajzaidat végpontok közötti titkosítással tároljuk, tehát az Excalidraw szervereiről se tud más belenézni."
"link": ""
}, },
"stats": { "stats": {
"angle": "Szög", "angle": "Szög",
+3 -6
View File
@@ -61,7 +61,7 @@
"architect": "Arsitek", "architect": "Arsitek",
"artist": "Artis", "artist": "Artis",
"cartoonist": "Kartunis", "cartoonist": "Kartunis",
"fileTitle": "Nama file", "fileTitle": "Judul file",
"colorPicker": "Pilihan Warna", "colorPicker": "Pilihan Warna",
"canvasBackground": "Latar Kanvas", "canvasBackground": "Latar Kanvas",
"drawingCanvas": "Kanvas", "drawingCanvas": "Kanvas",
@@ -92,8 +92,6 @@
"centerHorizontally": "Pusatkan secara horizontal", "centerHorizontally": "Pusatkan secara horizontal",
"distributeHorizontally": "Distribusikan horizontal", "distributeHorizontally": "Distribusikan horizontal",
"distributeVertically": "Distribusikan vertikal", "distributeVertically": "Distribusikan vertikal",
"flipHorizontal": "Balikkan horizontal",
"flipVertical": "Balikkan vertikal",
"viewMode": "Mode tampilan", "viewMode": "Mode tampilan",
"toggleExportColorScheme": "Ubah skema warna ekspor", "toggleExportColorScheme": "Ubah skema warna ekspor",
"share": "Bagikan" "share": "Bagikan"
@@ -145,7 +143,7 @@
"confirmAddLibrary": "Ini akan menambahkan {{numShapes}} bentuk ke pustaka Anda. Anda yakin?", "confirmAddLibrary": "Ini akan menambahkan {{numShapes}} bentuk ke pustaka Anda. Anda yakin?",
"imageDoesNotContainScene": "Mengimpor gambar tidak didukung saat ini.\n\nApakah Anda ingin impor pemandangan? Gambar ini tidak berisi data pemandangan. Sudah ka Anda aktifkan ini ketika ekspor?", "imageDoesNotContainScene": "Mengimpor gambar tidak didukung saat ini.\n\nApakah Anda ingin impor pemandangan? Gambar ini tidak berisi data pemandangan. Sudah ka Anda aktifkan ini ketika ekspor?",
"cannotRestoreFromImage": "Pemandangan tidak dapat dipulihkan dari file gambar ini", "cannotRestoreFromImage": "Pemandangan tidak dapat dipulihkan dari file gambar ini",
"invalidSceneUrl": "Tidak dapat impor pemandangan dari URL. Kemungkinan URL itu rusak atau tidak berisi data JSON Excalidraw yang valid.", "invalidSceneUrl": "",
"resetLibrary": "Ini akan menghapus pustaka Anda. Anda yakin?" "resetLibrary": "Ini akan menghapus pustaka Anda. Anda yakin?"
}, },
"toolBar": { "toolBar": {
@@ -230,8 +228,7 @@
"zoomToSelection": "Perbesar ke seleksi" "zoomToSelection": "Perbesar ke seleksi"
}, },
"encrypted": { "encrypted": {
"tooltip": "Gambar anda terenkripsi end-to-end sehingga server Excalidraw tidak akan pernah dapat melihatnya.", "tooltip": "Gambar anda terenkripsi end-to-end sehingga server Excalidraw tidak akan pernah dapat melihatnya."
"link": "Pos blog tentang enkripsi ujung ke ujung di Excalidraw"
}, },
"stats": { "stats": {
"angle": "Sudut", "angle": "Sudut",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Architetto", "architect": "Architetto",
"artist": "Artista", "artist": "Artista",
"cartoonist": "Fumettista", "cartoonist": "Fumettista",
"fileTitle": "Nome del file", "fileTitle": "Titolo del file",
"colorPicker": "Selettore colore", "colorPicker": "Selettore colore",
"canvasBackground": "Sfondo tela", "canvasBackground": "Sfondo tela",
"drawingCanvas": "Area di disegno", "drawingCanvas": "Area di disegno",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centra orizzontalmente", "centerHorizontally": "Centra orizzontalmente",
"distributeHorizontally": "Distribuisci orizzontalmente", "distributeHorizontally": "Distribuisci orizzontalmente",
"distributeVertically": "Distribuisci verticalmente", "distributeVertically": "Distribuisci verticalmente",
"flipHorizontal": "Capovolgi orizzontalmente",
"flipVertical": "Capovolgi verticalmente",
"viewMode": "Modalità visualizzazione", "viewMode": "Modalità visualizzazione",
"toggleExportColorScheme": "Cambia lo schema di colori in esportazione", "toggleExportColorScheme": "Cambia lo schema di colori in esportazione",
"share": "Condividi" "share": "Condividi"
@@ -230,8 +228,7 @@
"zoomToSelection": "Zoom alla selezione" "zoomToSelection": "Zoom alla selezione"
}, },
"encrypted": { "encrypted": {
"tooltip": "I tuoi disegni sono crittografati end-to-end in modo che i server di Excalidraw non li possano mai vedere.", "tooltip": "I tuoi disegni sono crittografati end-to-end in modo che i server di Excalidraw non li possano mai vedere."
"link": "Articolo del blog sulla crittografia end-to-end di Excalidraw"
}, },
"stats": { "stats": {
"angle": "Angolo", "angle": "Angolo",
+3 -6
View File
@@ -92,8 +92,6 @@
"centerHorizontally": "横方向に中央揃え", "centerHorizontally": "横方向に中央揃え",
"distributeHorizontally": "水平方向に分散配置", "distributeHorizontally": "水平方向に分散配置",
"distributeVertically": "垂直方向に分散配置", "distributeVertically": "垂直方向に分散配置",
"flipHorizontal": "水平方向に反転",
"flipVertical": "垂直方向に反転",
"viewMode": "閲覧モード", "viewMode": "閲覧モード",
"toggleExportColorScheme": "", "toggleExportColorScheme": "",
"share": "" "share": ""
@@ -203,7 +201,7 @@
"desc_inProgressIntro": "共同編集セッションが有効になっています。", "desc_inProgressIntro": "共同編集セッションが有効になっています。",
"desc_shareLink": "下記URLを共同編集したい人に共有してください:", "desc_shareLink": "下記URLを共同編集したい人に共有してください:",
"desc_exitSession": "セッションを終了すると部屋から切断されますが、手元の環境で編集を続けることができます。変更内容は他の人には反映されません。", "desc_exitSession": "セッションを終了すると部屋から切断されますが、手元の環境で編集を続けることができます。変更内容は他の人には反映されません。",
"shareTitle": "Excalidrawのライブコラボレーションセッションに参加する" "shareTitle": ""
}, },
"errorDialog": { "errorDialog": {
"title": "エラー" "title": "エラー"
@@ -230,8 +228,7 @@
"zoomToSelection": "選択要素にズーム" "zoomToSelection": "選択要素にズーム"
}, },
"encrypted": { "encrypted": {
"tooltip": "描画内容はエンドツーエンド暗号化が施されており、Excalidrawサーバーが内容を見ることはできません。", "tooltip": "描画内容はエンドツーエンド暗号化が施されており、Excalidrawサーバーが内容を見ることはできません。"
"link": "Excalidrawのエンドツーエンド暗号化に関するブログ記事"
}, },
"stats": { "stats": {
"angle": "角度", "angle": "角度",
@@ -254,7 +251,7 @@
"copyToClipboardAsPng": "", "copyToClipboardAsPng": "",
"fileSaved": "ファイルを保存しました", "fileSaved": "ファイルを保存しました",
"fileSavedToFilename": "{filename} に保存しました", "fileSavedToFilename": "{filename} に保存しました",
"canvas": "キャンバス", "canvas": "",
"selection": "" "selection": ""
} }
} }
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Amasdag", "architect": "Amasdag",
"artist": "Anaẓur", "artist": "Anaẓur",
"cartoonist": "", "cartoonist": "",
"fileTitle": "Isem n ufaylu", "fileTitle": "Azwel n ufaylu",
"colorPicker": "Amafran n yini", "colorPicker": "Amafran n yini",
"canvasBackground": "Agilal n teɣzut n usuneɣ", "canvasBackground": "Agilal n teɣzut n usuneɣ",
"drawingCanvas": "Taɣzut n usuneɣ", "drawingCanvas": "Taɣzut n usuneɣ",
@@ -92,8 +92,6 @@
"centerHorizontally": "Di tlemmast s uglawi", "centerHorizontally": "Di tlemmast s uglawi",
"distributeHorizontally": "Freq s uglawi", "distributeHorizontally": "Freq s uglawi",
"distributeVertically": "Freq s yibeddi", "distributeVertically": "Freq s yibeddi",
"flipHorizontal": "Tuttya taglawant",
"flipVertical": "Tuttya tubdidt",
"viewMode": "Askar n tmuɣli", "viewMode": "Askar n tmuɣli",
"toggleExportColorScheme": "Sermed/sens asifeḍ usentel n yini", "toggleExportColorScheme": "Sermed/sens asifeḍ usentel n yini",
"share": "Bḍu" "share": "Bḍu"
@@ -230,8 +228,7 @@
"zoomToSelection": "Simɣur ɣer tefrayt" "zoomToSelection": "Simɣur ɣer tefrayt"
}, },
"encrypted": { "encrypted": {
"tooltip": "Unuɣen-inek (m) ttuwgelhnen seg yixef s ixef dɣa iqeddacen n Excalidraw werǧin ad ten-walin. ", "tooltip": "Unuɣen-inek (m) ttuwgelhnen seg yixef s ixef dɣa iqeddacen n Excalidraw werǧin ad ten-walin. "
"link": "Amagrad ɣef uwgelhen ixef s ixef di Excalidraw"
}, },
"stats": { "stats": {
"angle": "Tiɣmeṛt", "angle": "Tiɣmeṛt",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "건축가", "architect": "건축가",
"artist": "예술가", "artist": "예술가",
"cartoonist": "만화가", "cartoonist": "만화가",
"fileTitle": "", "fileTitle": "파일명",
"colorPicker": "색상 선택기", "colorPicker": "색상 선택기",
"canvasBackground": "캔버스 배경", "canvasBackground": "캔버스 배경",
"drawingCanvas": "캔버스 그리기", "drawingCanvas": "캔버스 그리기",
@@ -92,8 +92,6 @@
"centerHorizontally": "수평으로 중앙 정렬", "centerHorizontally": "수평으로 중앙 정렬",
"distributeHorizontally": "수평으로 분배", "distributeHorizontally": "수평으로 분배",
"distributeVertically": "수직으로 분배", "distributeVertically": "수직으로 분배",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "보기 모드", "viewMode": "보기 모드",
"toggleExportColorScheme": "", "toggleExportColorScheme": "",
"share": "" "share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "선택 영역으로 확대/축소" "zoomToSelection": "선택 영역으로 확대/축소"
}, },
"encrypted": { "encrypted": {
"tooltip": "그림은 종단 간 암호화되므로 Excalidraw의 서버는 절대로 내용을 알 수 없습니다.", "tooltip": "그림은 종단 간 암호화되므로 Excalidraw의 서버는 절대로 내용을 알 수 없습니다."
"link": ""
}, },
"stats": { "stats": {
"angle": "각도", "angle": "각도",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "ဗိသုကာ", "architect": "ဗိသုကာ",
"artist": "ပန်းချီ", "artist": "ပန်းချီ",
"cartoonist": "ကာတွန်း", "cartoonist": "ကာတွန်း",
"fileTitle": "", "fileTitle": "ခေါင်းစဉ်",
"colorPicker": "အရောင်ရွေး", "colorPicker": "အရောင်ရွေး",
"canvasBackground": "ကားချပ်နောက်ခံ", "canvasBackground": "ကားချပ်နောက်ခံ",
"drawingCanvas": "ပုံဆွဲကားချပ်", "drawingCanvas": "ပုံဆွဲကားချပ်",
@@ -92,8 +92,6 @@
"centerHorizontally": "အလျားလိုက်အလယ်ညှိ", "centerHorizontally": "အလျားလိုက်အလယ်ညှိ",
"distributeHorizontally": "အလျားလိုက်", "distributeHorizontally": "အလျားလိုက်",
"distributeVertically": "ထောင်လိုက်", "distributeVertically": "ထောင်လိုက်",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "", "viewMode": "",
"toggleExportColorScheme": "", "toggleExportColorScheme": "",
"share": "" "share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "" "zoomToSelection": ""
}, },
"encrypted": { "encrypted": {
"tooltip": "ရေးဆွဲထားသောပုံများအား နှစ်ဘက်စွန်းတိုင်လျှို့ဝှက်ထားသဖြင့် Excalidraw ၏ဆာဗာများပင်လျှင်မြင်တွေ့ရမည်မဟုတ်ပါ။", "tooltip": "ရေးဆွဲထားသောပုံများအား နှစ်ဘက်စွန်းတိုင်လျှို့ဝှက်ထားသဖြင့် Excalidraw ၏ဆာဗာများပင်လျှင်မြင်တွေ့ရမည်မဟုတ်ပါ။"
"link": ""
}, },
"stats": { "stats": {
"angle": "ထောင့်", "angle": "ထောင့်",
+1 -4
View File
@@ -92,8 +92,6 @@
"centerHorizontally": "Midtstill horisontalt", "centerHorizontally": "Midtstill horisontalt",
"distributeHorizontally": "Distribuer horisontalt", "distributeHorizontally": "Distribuer horisontalt",
"distributeVertically": "Distribuer vertikalt", "distributeVertically": "Distribuer vertikalt",
"flipHorizontal": "Snu horisontalt",
"flipVertical": "Snu vertikalt",
"viewMode": "Visningsmodus", "viewMode": "Visningsmodus",
"toggleExportColorScheme": "Veksle eksport av fargepalett", "toggleExportColorScheme": "Veksle eksport av fargepalett",
"share": "Del" "share": "Del"
@@ -230,8 +228,7 @@
"zoomToSelection": "Zoom til utvalg" "zoomToSelection": "Zoom til utvalg"
}, },
"encrypted": { "encrypted": {
"tooltip": "Dine tegninger er ende-til-ende-krypterte slik at Excalidraw sine servere aldri vil se dem.", "tooltip": "Dine tegninger er ende-til-ende-krypterte slik at Excalidraw sine servere aldri vil se dem."
"link": "Blogginnlegg om ende-til-ende-kryptering i Excalidraw"
}, },
"stats": { "stats": {
"angle": "Vinkel", "angle": "Vinkel",
+2 -5
View File
@@ -92,8 +92,6 @@
"centerHorizontally": "Horizontaal Centreren", "centerHorizontally": "Horizontaal Centreren",
"distributeHorizontally": "Horizontaal verspreiden", "distributeHorizontally": "Horizontaal verspreiden",
"distributeVertically": "Verticaal distribueren", "distributeVertically": "Verticaal distribueren",
"flipHorizontal": "Horizontaal spiegelen",
"flipVertical": "Verticaal spiegelen",
"viewMode": "Weergavemodus", "viewMode": "Weergavemodus",
"toggleExportColorScheme": "Kleurenschema exporteren aan/uit", "toggleExportColorScheme": "Kleurenschema exporteren aan/uit",
"share": "Deel" "share": "Deel"
@@ -140,7 +138,7 @@
"decryptFailed": "Kan gegevens niet decoderen.", "decryptFailed": "Kan gegevens niet decoderen.",
"uploadedSecurly": "De upload is beveiligd met end-to-end encryptie, wat betekent dat de Excalidraw server en derden de inhoud niet kunnen lezen.", "uploadedSecurly": "De upload is beveiligd met end-to-end encryptie, wat betekent dat de Excalidraw server en derden de inhoud niet kunnen lezen.",
"loadSceneOverridePrompt": "Het laden van externe tekening zal uw bestaande inhoud vervangen. Wil je doorgaan?", "loadSceneOverridePrompt": "Het laden van externe tekening zal uw bestaande inhoud vervangen. Wil je doorgaan?",
"collabStopOverridePrompt": "Wanneer de sessie wordt gestopt, overschrijft u de eerdere, lokaal opgeslagen tekening. Weet je het zeker?\n\n(Als je de lokale tekening wilt behouden, sluit je in plaats daarvan het browsertabblad)", "collabStopOverridePrompt": "",
"errorLoadingLibrary": "Bij het laden van de externe bibliotheek is een fout opgetreden.", "errorLoadingLibrary": "Bij het laden van de externe bibliotheek is een fout opgetreden.",
"confirmAddLibrary": "Hiermee worden {{numShapes}} vorm(n) aan uw bibliotheek toegevoegd. Ben je het zeker?", "confirmAddLibrary": "Hiermee worden {{numShapes}} vorm(n) aan uw bibliotheek toegevoegd. Ben je het zeker?",
"imageDoesNotContainScene": "Afbeeldingen importeren wordt op dit moment niet ondersteund.\n\nWil je een scène importeren? Deze afbeelding lijkt geen scène gegevens te bevatten. Heb je dit geactiveerd tijdens het exporteren?", "imageDoesNotContainScene": "Afbeeldingen importeren wordt op dit moment niet ondersteund.\n\nWil je een scène importeren? Deze afbeelding lijkt geen scène gegevens te bevatten. Heb je dit geactiveerd tijdens het exporteren?",
@@ -230,8 +228,7 @@
"zoomToSelection": "Inzoomen op selectie" "zoomToSelection": "Inzoomen op selectie"
}, },
"encrypted": { "encrypted": {
"tooltip": "Je tekeningen zijn beveiligd met end-to-end encryptie, dus Excalidraw's servers zullen nooit zien wat je tekent.", "tooltip": "Je tekeningen zijn beveiligd met end-to-end encryptie, dus Excalidraw's servers zullen nooit zien wat je tekent."
"link": "Blog post over end-to-end versleuteling in Excalidraw"
}, },
"stats": { "stats": {
"angle": "Hoek", "angle": "Hoek",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Arkitekt", "architect": "Arkitekt",
"artist": "Kunstnar", "artist": "Kunstnar",
"cartoonist": "Teiknar", "cartoonist": "Teiknar",
"fileTitle": "", "fileTitle": "Filnamn",
"colorPicker": "Fargeveljar", "colorPicker": "Fargeveljar",
"canvasBackground": "Lerretsbakgrunn", "canvasBackground": "Lerretsbakgrunn",
"drawingCanvas": "Lerret", "drawingCanvas": "Lerret",
@@ -92,8 +92,6 @@
"centerHorizontally": "Midtstill horisontalt", "centerHorizontally": "Midtstill horisontalt",
"distributeHorizontally": "Sprei horisontalt", "distributeHorizontally": "Sprei horisontalt",
"distributeVertically": "Sprei vertikalt", "distributeVertically": "Sprei vertikalt",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "", "viewMode": "",
"toggleExportColorScheme": "", "toggleExportColorScheme": "",
"share": "" "share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "" "zoomToSelection": ""
}, },
"encrypted": { "encrypted": {
"tooltip": "Teikningane dine er ende-til-ende-krypterte slik at Excalidraw sine serverar aldri får sjå dei.", "tooltip": "Teikningane dine er ende-til-ende-krypterte slik at Excalidraw sine serverar aldri får sjå dei."
"link": ""
}, },
"stats": { "stats": {
"angle": "Vinkel", "angle": "Vinkel",
+5 -8
View File
@@ -38,7 +38,7 @@
"fontSize": "Talha poliça", "fontSize": "Talha poliça",
"fontFamily": "Familha de poliça", "fontFamily": "Familha de poliça",
"onlySelected": "Seleccion sonque", "onlySelected": "Seleccion sonque",
"withBackground": "Inclure lo rèireplan", "withBackground": "Inclure rèireplan",
"exportEmbedScene": "Integrar la scèna al fichièr dexpo", "exportEmbedScene": "Integrar la scèna al fichièr dexpo",
"exportEmbedScene_details": "Las donadas de scèna seràn enregistradas dins lo fichièr PNG/SVG exportat, per que la scèna pòsca èsser restaurada a partir daqueste fichièr.\nAumentarà la talha del fichièr exportat.", "exportEmbedScene_details": "Las donadas de scèna seràn enregistradas dins lo fichièr PNG/SVG exportat, per que la scèna pòsca èsser restaurada a partir daqueste fichièr.\nAumentarà la talha del fichièr exportat.",
"addWatermark": "Apondre « Fabricat amb Excalidraw »", "addWatermark": "Apondre « Fabricat amb Excalidraw »",
@@ -61,7 +61,7 @@
"architect": "Arquitècte", "architect": "Arquitècte",
"artist": "Artista", "artist": "Artista",
"cartoonist": "Dessenhaire", "cartoonist": "Dessenhaire",
"fileTitle": "Nom del fichièr", "fileTitle": "Títol del fichièr",
"colorPicker": "Selector de color", "colorPicker": "Selector de color",
"canvasBackground": "Rèireplan del canabàs", "canvasBackground": "Rèireplan del canabàs",
"drawingCanvas": "Zòna de dessenh", "drawingCanvas": "Zòna de dessenh",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centrar orizontalament", "centerHorizontally": "Centrar orizontalament",
"distributeHorizontally": "Distribuir orizontalament", "distributeHorizontally": "Distribuir orizontalament",
"distributeVertically": "Distribuir verticalament", "distributeVertically": "Distribuir verticalament",
"flipHorizontal": "Virar orizontalament",
"flipVertical": "Virar verticalament",
"viewMode": "Mòde de vista", "viewMode": "Mòde de vista",
"toggleExportColorScheme": "Alternar lesquèma de color dexpòrt", "toggleExportColorScheme": "Alternar lesquèma de color dexpòrt",
"share": "Partejar" "share": "Partejar"
@@ -101,8 +99,8 @@
"buttons": { "buttons": {
"clearReset": "Reïnicializar lo canabàs", "clearReset": "Reïnicializar lo canabàs",
"export": "Exportar", "export": "Exportar",
"exportToPng": "Exportar en PNG", "exportToPng": "Export en PNG",
"exportToSvg": "Exportar en SVG", "exportToSvg": "Export en SVG",
"copyToClipboard": "Copiar al quichapapièrs", "copyToClipboard": "Copiar al quichapapièrs",
"copyPngToClipboard": "Copiar PNG al quichapapièrs", "copyPngToClipboard": "Copiar PNG al quichapapièrs",
"scale": "Escala", "scale": "Escala",
@@ -230,8 +228,7 @@
"zoomToSelection": "Zoomar la seleccion" "zoomToSelection": "Zoomar la seleccion"
}, },
"encrypted": { "encrypted": {
"tooltip": "Vòstres dessenhs son chifrats del cap a la fin en consequéncia los servidors dExcalidraw los veiràn pas jamai.", "tooltip": "Vòstres dessenhs son chifrats del cap a la fin en consequéncia los servidors dExcalidraw los veiràn pas jamai."
"link": "Article de blòg sul chiframent del cap a la fin dins Excalidraw"
}, },
"stats": { "stats": {
"angle": "Angle", "angle": "Angle",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "ਭਵਨ ਨਿਰਮਾਣਕਾਰੀ", "architect": "ਭਵਨ ਨਿਰਮਾਣਕਾਰੀ",
"artist": "ਕਲਾਕਾਰ", "artist": "ਕਲਾਕਾਰ",
"cartoonist": "ਕਾਰਟੂਨਿਸਟ", "cartoonist": "ਕਾਰਟੂਨਿਸਟ",
"fileTitle": "", "fileTitle": "ਫਾਈਲ ਦਾ ਸਿਰਨਾਵਾਂ",
"colorPicker": "ਰੰਗ ਚੋਣਕਾਰ", "colorPicker": "ਰੰਗ ਚੋਣਕਾਰ",
"canvasBackground": "ਕੈਨਵਸ ਦਾ ਬੈਕਗਰਾਉਂਡ", "canvasBackground": "ਕੈਨਵਸ ਦਾ ਬੈਕਗਰਾਉਂਡ",
"drawingCanvas": "ਡਰਾਇੰਗ ਕੈਨਵਸ", "drawingCanvas": "ਡਰਾਇੰਗ ਕੈਨਵਸ",
@@ -92,8 +92,6 @@
"centerHorizontally": "ਖੜ੍ਹਵੇਂ ਵਿਚਕਾਰ ਕਰੋ", "centerHorizontally": "ਖੜ੍ਹਵੇਂ ਵਿਚਕਾਰ ਕਰੋ",
"distributeHorizontally": "ਖੜ੍ਹਵੇਂ ਇਕਸਾਰ ਵੰਡੋ", "distributeHorizontally": "ਖੜ੍ਹਵੇਂ ਇਕਸਾਰ ਵੰਡੋ",
"distributeVertically": "ਲੇਟਵੇਂ ਇਕਸਾਰ ਵੰਡੋ", "distributeVertically": "ਲੇਟਵੇਂ ਇਕਸਾਰ ਵੰਡੋ",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "ਦੇਖੋ ਮੋਡ", "viewMode": "ਦੇਖੋ ਮੋਡ",
"toggleExportColorScheme": "", "toggleExportColorScheme": "",
"share": "" "share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "ਚੋਣ ਤੱਕ ਜ਼ੂਮ ਕਰੋ" "zoomToSelection": "ਚੋਣ ਤੱਕ ਜ਼ੂਮ ਕਰੋ"
}, },
"encrypted": { "encrypted": {
"tooltip": "ਤੁਹਾਡੀ ਡਰਾਇੰਗਾਂ ਸਿਰੇ-ਤੋਂ-ਸਿਰੇ ਤੱਕ ਇਨਕਰਿਪਟ ਕੀਤੀਆਂ ਹੋਈਆਂ ਹਨ, ਇਸ ਲਈ Excalidraw ਦੇ ਸਰਵਰ ਉਹਨਾਂ ਨੂੰ ਕਦੇ ਵੀ ਨਹੀਂ ਦੇਖਣਗੇ।", "tooltip": "ਤੁਹਾਡੀ ਡਰਾਇੰਗਾਂ ਸਿਰੇ-ਤੋਂ-ਸਿਰੇ ਤੱਕ ਇਨਕਰਿਪਟ ਕੀਤੀਆਂ ਹੋਈਆਂ ਹਨ, ਇਸ ਲਈ Excalidraw ਦੇ ਸਰਵਰ ਉਹਨਾਂ ਨੂੰ ਕਦੇ ਵੀ ਨਹੀਂ ਦੇਖਣਗੇ।"
"link": ""
}, },
"stats": { "stats": {
"angle": "ਕੋਣ", "angle": "ਕੋਣ",
+24 -24
View File
@@ -1,37 +1,37 @@
{ {
"ar-SA": 85, "ar-SA": 83,
"bg-BG": 93, "bg-BG": 94,
"ca-ES": 98, "ca-ES": 100,
"de-DE": 100, "de-DE": 100,
"el-GR": 98, "el-GR": 98,
"en": 100, "en": 100,
"es-ES": 99, "es-ES": 100,
"fa-IR": 88, "fa-IR": 90,
"fi-FI": 99, "fi-FI": 100,
"fr-FR": 100, "fr-FR": 100,
"he-IL": 89, "he-IL": 91,
"hi-IN": 91, "hi-IN": 93,
"hu-HU": 81, "hu-HU": 83,
"id-ID": 100, "id-ID": 99,
"it-IT": 100, "it-IT": 100,
"ja-JP": 97, "ja-JP": 96,
"kab-KAB": 99, "kab-KAB": 99,
"ko-KR": 92, "ko-KR": 94,
"my-MM": 76, "my-MM": 77,
"nb-NO": 100, "nb-NO": 100,
"nl-NL": 100, "nl-NL": 99,
"nn-NO": 83, "nn-NO": 85,
"oc-FR": 100, "oc-FR": 100,
"pa-IN": 94, "pa-IN": 95,
"pl-PL": 95, "pl-PL": 96,
"pt-BR": 100, "pt-BR": 100,
"pt-PT": 95, "pt-PT": 97,
"ro-RO": 100, "ro-RO": 100,
"ru-RU": 98, "ru-RU": 100,
"sk-SK": 99, "sk-SK": 100,
"sv-SE": 100, "sv-SE": 100,
"tr-TR": 99, "tr-TR": 83,
"uk-UA": 99, "uk-UA": 95,
"zh-CN": 99, "zh-CN": 100,
"zh-TW": 99 "zh-TW": 100
} }
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Dokładny", "architect": "Dokładny",
"artist": "Artystyczny", "artist": "Artystyczny",
"cartoonist": "Rysunkowy", "cartoonist": "Rysunkowy",
"fileTitle": "", "fileTitle": "Tytuł pliku",
"colorPicker": "Paleta kolorów", "colorPicker": "Paleta kolorów",
"canvasBackground": "Kolor dokumentu", "canvasBackground": "Kolor dokumentu",
"drawingCanvas": "Obszar roboczy", "drawingCanvas": "Obszar roboczy",
@@ -92,8 +92,6 @@
"centerHorizontally": "Wyśrodkuj w poziomie", "centerHorizontally": "Wyśrodkuj w poziomie",
"distributeHorizontally": "Rozłóż poziomo", "distributeHorizontally": "Rozłóż poziomo",
"distributeVertically": "Rozłóż pionowo", "distributeVertically": "Rozłóż pionowo",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "Tryb widoku", "viewMode": "Tryb widoku",
"toggleExportColorScheme": "", "toggleExportColorScheme": "",
"share": "" "share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "Przybliż do zaznaczenia" "zoomToSelection": "Przybliż do zaznaczenia"
}, },
"encrypted": { "encrypted": {
"tooltip": "Twoje rysunki są zabezpieczone szyfrowaniem end-to-end, tak więc nawet w Excalidraw nie jesteśmy w stanie zobaczyć tego co tworzysz.", "tooltip": "Twoje rysunki są zabezpieczone szyfrowaniem end-to-end, tak więc nawet w Excalidraw nie jesteśmy w stanie zobaczyć tego co tworzysz."
"link": ""
}, },
"stats": { "stats": {
"angle": "Kąt", "angle": "Kąt",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Arquiteto", "architect": "Arquiteto",
"artist": "Artista", "artist": "Artista",
"cartoonist": "Cartunista", "cartoonist": "Cartunista",
"fileTitle": "Nome do arquivo", "fileTitle": "Título do arquivo",
"colorPicker": "Seletor de cores", "colorPicker": "Seletor de cores",
"canvasBackground": "Fundo da tela", "canvasBackground": "Fundo da tela",
"drawingCanvas": "Tela de desenho", "drawingCanvas": "Tela de desenho",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centralizar horizontalmente", "centerHorizontally": "Centralizar horizontalmente",
"distributeHorizontally": "Distribuir horizontalmente", "distributeHorizontally": "Distribuir horizontalmente",
"distributeVertically": "Distribuir verticalmente", "distributeVertically": "Distribuir verticalmente",
"flipHorizontal": "Inverter horizontalmente",
"flipVertical": "Inverter verticalmente",
"viewMode": "Modo de visualização", "viewMode": "Modo de visualização",
"toggleExportColorScheme": "Alternar esquema de cores de exportação", "toggleExportColorScheme": "Alternar esquema de cores de exportação",
"share": "Compartilhar" "share": "Compartilhar"
@@ -230,8 +228,7 @@
"zoomToSelection": "Ampliar a seleção" "zoomToSelection": "Ampliar a seleção"
}, },
"encrypted": { "encrypted": {
"tooltip": "Seus desenhos são criptografados de ponta a ponta, então os servidores do Excalidraw nunca os verão.", "tooltip": "Seus desenhos são criptografados de ponta a ponta, então os servidores do Excalidraw nunca os verão."
"link": "Postagem de blog com uma criptografia de ponta a ponta no Excalidraw"
}, },
"stats": { "stats": {
"angle": "Ângulo", "angle": "Ângulo",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Arquitecto", "architect": "Arquitecto",
"artist": "Artista", "artist": "Artista",
"cartoonist": "Caricaturista", "cartoonist": "Caricaturista",
"fileTitle": "", "fileTitle": "Título do ficheiro",
"colorPicker": "Seletor de cores", "colorPicker": "Seletor de cores",
"canvasBackground": "Fundo da tela", "canvasBackground": "Fundo da tela",
"drawingCanvas": "Tela de desenho", "drawingCanvas": "Tela de desenho",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centralizar horizontalmente", "centerHorizontally": "Centralizar horizontalmente",
"distributeHorizontally": "Distribuir horizontalmente", "distributeHorizontally": "Distribuir horizontalmente",
"distributeVertically": "Distribuir verticalmente", "distributeVertically": "Distribuir verticalmente",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "Modo de visualização", "viewMode": "Modo de visualização",
"toggleExportColorScheme": "Alternar esquema de cores de exportação", "toggleExportColorScheme": "Alternar esquema de cores de exportação",
"share": "" "share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "Ampliar a seleção" "zoomToSelection": "Ampliar a seleção"
}, },
"encrypted": { "encrypted": {
"tooltip": "Seus desenhos são criptografados de ponta a ponta, então os servidores do Excalidraw nunca os verão.", "tooltip": "Seus desenhos são criptografados de ponta a ponta, então os servidores do Excalidraw nunca os verão."
"link": ""
}, },
"stats": { "stats": {
"angle": "Ângulo", "angle": "Ângulo",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Arhitect", "architect": "Arhitect",
"artist": "Artist", "artist": "Artist",
"cartoonist": "Caricaturist", "cartoonist": "Caricaturist",
"fileTitle": "Nume de fișier", "fileTitle": "Denumirea fișierului",
"colorPicker": "Selector de culoare", "colorPicker": "Selector de culoare",
"canvasBackground": "Fundalul pânzei", "canvasBackground": "Fundalul pânzei",
"drawingCanvas": "Pânză pentru desenat", "drawingCanvas": "Pânză pentru desenat",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centrare orizontală", "centerHorizontally": "Centrare orizontală",
"distributeHorizontally": "Distribuie orizontal", "distributeHorizontally": "Distribuie orizontal",
"distributeVertically": "Distribuie vertical", "distributeVertically": "Distribuie vertical",
"flipHorizontal": "Răsturnare orizontală",
"flipVertical": "Răsturnare verticală",
"viewMode": "Mod de vizualizare", "viewMode": "Mod de vizualizare",
"toggleExportColorScheme": "Comutare schemă de culori de export", "toggleExportColorScheme": "Comutare schemă de culori de export",
"share": "Distribuie" "share": "Distribuie"
@@ -230,8 +228,7 @@
"zoomToSelection": "Panoramare la selecție" "zoomToSelection": "Panoramare la selecție"
}, },
"encrypted": { "encrypted": {
"tooltip": "Desenele tale sunt criptate integral, astfel că serverele Excalidraw nu le vor vedea niciodată.", "tooltip": "Desenele tale sunt criptate integral, astfel că serverele Excalidraw nu le vor vedea niciodată."
"link": "Articol de blog pe criptarea integrală din Excalidraw"
}, },
"stats": { "stats": {
"angle": "Unghi", "angle": "Unghi",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Архитектор", "architect": "Архитектор",
"artist": "Художник", "artist": "Художник",
"cartoonist": "Карикатурист", "cartoonist": "Карикатурист",
"fileTitle": "Имя файла", "fileTitle": "Название файла",
"colorPicker": "Выбор цвета", "colorPicker": "Выбор цвета",
"canvasBackground": "Фон холста", "canvasBackground": "Фон холста",
"drawingCanvas": "Полотно", "drawingCanvas": "Полотно",
@@ -92,8 +92,6 @@
"centerHorizontally": "Центрировать по горизонтали", "centerHorizontally": "Центрировать по горизонтали",
"distributeHorizontally": "Распределить по горизонтали", "distributeHorizontally": "Распределить по горизонтали",
"distributeVertically": "Распределить по вертикали", "distributeVertically": "Распределить по вертикали",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "Вид", "viewMode": "Вид",
"toggleExportColorScheme": "Экспортировать цветовую схему", "toggleExportColorScheme": "Экспортировать цветовую схему",
"share": "Поделиться" "share": "Поделиться"
@@ -230,8 +228,7 @@
"zoomToSelection": "Увеличить до выделенного" "zoomToSelection": "Увеличить до выделенного"
}, },
"encrypted": { "encrypted": {
"tooltip": "Ваши данные защищены сквозным (End-to-end) шифрованием. Серверы Excalidraw никогда не получат доступ к ним.", "tooltip": "Ваши данные защищены сквозным (End-to-end) шифрованием. Серверы Excalidraw никогда не получат доступ к ним."
"link": ""
}, },
"stats": { "stats": {
"angle": "Угол", "angle": "Угол",

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