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
yarn-debug.log*
yarn-error.log*
src/packages/excalidraw/types
+15 -15
View File
@@ -19,20 +19,20 @@
]
},
"dependencies": {
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.11.10",
"@testing-library/react": "11.2.6",
"@types/jest": "26.0.22",
"@types/react": "17.0.3",
"@types/react-dom": "17.0.3",
"@sentry/browser": "6.2.2",
"@sentry/integrations": "6.2.1",
"@testing-library/jest-dom": "5.11.9",
"@testing-library/react": "11.2.5",
"@types/jest": "26.0.20",
"@types/react": "17.0.2",
"@types/react-dom": "17.0.1",
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.16.4",
"browser-fs-access": "0.14.2",
"clsx": "1.1.1",
"firebase": "8.3.2",
"i18next-browser-languagedetector": "6.1.0",
"firebase": "8.2.10",
"i18next-browser-languagedetector": "6.0.1",
"lodash.throttle": "4.1.1",
"nanoid": "3.1.22",
"nanoid": "3.1.21",
"open-color": "1.8.0",
"pako": "1.0.11",
"png-chunk-text": "1.0.0",
@@ -40,13 +40,13 @@
"png-chunks-extract": "1.0.0",
"points-on-curve": "0.2.0",
"pwacompat": "2.0.17",
"react": "17.0.2",
"react-dom": "17.0.2",
"react": "17.0.1",
"react-dom": "17.0.1",
"react-scripts": "4.0.3",
"roughjs": "4.3.1",
"sass": "1.32.8",
"socket.io-client": "2.3.1",
"typescript": "4.2.4"
"typescript": "4.2.3"
},
"devDependencies": {
"@excalidraw/eslint-config": "1.0.0",
@@ -56,7 +56,7 @@
"@types/resize-observer-browser": "0.1.5",
"eslint-config-prettier": "8.1.0",
"eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.9.0",
"firebase-tools": "9.6.1",
"husky": "4.3.8",
"jest-canvas-mock": "2.3.1",
"lint-staged": "10.5.4",
+2 -6
View File
@@ -51,7 +51,8 @@
name="twitter:description"
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" />
<!-- Excalidraw version -->
@@ -87,8 +88,6 @@
<link rel="stylesheet" href="fonts.css" type="text/css" />
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script
@@ -147,9 +146,6 @@
color: var(--popup-text-color);
font-size: 1.3em;
}
#root {
height: 100%;
}
</style>
</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 { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { CODES, KEYS } from "../keys";
import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll";
@@ -33,7 +33,6 @@ export const actionChangeViewBackgroundColor = register({
type="canvasBackground"
color={appState.viewBackgroundColor}
onChange={(color) => updateData(color)}
data-testid="canvas-background-picker"
/>
</div>
);
@@ -73,7 +72,6 @@ export const actionClearCanvas = register({
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 { loadFromJSON, saveAsJSON } from "../data";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { KEYS } from "../keys";
import { register } from "./register";
import { supported } from "browser-fs-access";
export const actionChangeProjectName = register({
name: "changeProjectName",
@@ -24,9 +23,7 @@ export const actionChangeProjectName = register({
label={t("labels.fileTitle")}
value={appState.name || "Unnamed"}
onChange={(name: string) => updateData(name)}
isNameEditable={
typeof appProps.name === "undefined" && !appState.viewModeEnabled
}
isNameEditable={typeof appProps.name === "undefined"}
/>
),
});
@@ -136,7 +133,6 @@ export const actionSaveScene = register({
aria-label={t("buttons.save")}
showAriaLabel={useIsMobile()}
onClick={() => updateData(null)}
data-testid="save-button"
/>
),
});
@@ -166,9 +162,10 @@ export const actionSaveAsScene = register({
title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")}
showAriaLabel={useIsMobile()}
hidden={!supported}
hidden={
!("chooseFileSystemEntries" in window || "showOpenFilePicker" in window)
}
onClick={() => updateData(null)}
data-testid="save-as-button"
/>
),
});
@@ -206,7 +203,6 @@ export const actionLoadScene = register({
aria-label={t("buttons.load")}
showAriaLabel={useIsMobile()}
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 { AppState } from "../../src/types";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ButtonSelect } from "../components/ButtonSelect";
import { ColorPicker } from "../components/ColorPicker";
import { IconPicker } from "../components/IconPicker";
import {
@@ -20,16 +21,6 @@ import {
StrokeStyleDottedIcon,
StrokeStyleSolidIcon,
StrokeWidthIcon,
FontSizeSmallIcon,
FontSizeMediumIcon,
FontSizeLargeIcon,
FontSizeExtraLargeIcon,
FontFamilyHandDrawnIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon,
TextAlignCenterIcon,
TextAlignRightIcon,
} from "../components/icons";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
import {
@@ -422,29 +413,13 @@ export const actionChangeFontSize = register({
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.fontSize")}</legend>
<ButtonIconSelect
<ButtonSelect
group="font-size"
options={[
{
value: 16,
text: t("labels.small"),
icon: <FontSizeSmallIcon theme={appState.theme} />,
},
{
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: 16, text: t("labels.small") },
{ value: 20, text: t("labels.medium") },
{ value: 28, text: t("labels.large") },
{ value: 36, text: t("labels.veryLarge") },
]}
value={getFormValue(
elements,
@@ -481,28 +456,16 @@ export const actionChangeFontFamily = register({
};
},
PanelComponent: ({ elements, appState, updateData }) => {
const options: { value: FontFamily; text: string; icon: JSX.Element }[] = [
{
value: 1,
text: t("labels.handDrawn"),
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} />,
},
const options: { value: FontFamily; text: string }[] = [
{ value: 1, text: t("labels.handDrawn") },
{ value: 2, text: t("labels.normal") },
{ value: 3, text: t("labels.code") },
];
return (
<fieldset>
<legend>{t("labels.fontFamily")}</legend>
<ButtonIconSelect<FontFamily | false>
<ButtonSelect<FontFamily | false>
group="font-family"
options={options}
value={getFormValue(
@@ -543,24 +506,12 @@ export const actionChangeTextAlign = register({
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.textAlign")}</legend>
<ButtonIconSelect<TextAlign | false>
<ButtonSelect<TextAlign | false>
group="text-align"
options={[
{
value: "left",
text: t("labels.left"),
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: "left", text: t("labels.left") },
{ value: "center", text: t("labels.center") },
{ value: "right", text: t("labels.right") },
]}
value={getFormValue(
elements,
-2
View File
@@ -66,8 +66,6 @@ export {
distributeVertically,
} from "./actionDistribute";
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
export {
actionCopy,
actionCut,
+3 -15
View File
@@ -7,12 +7,12 @@ import {
ActionResult,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppProps, AppState } from "../types";
import { AppState, ExcalidrawProps } from "../types";
import { MODES } from "../constants";
// This is the <App> component, but for now we don't care about anything but its
// `canvas` state.
type App = { canvas: HTMLCanvasElement | null; props: AppProps };
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
@@ -52,14 +52,10 @@ export class ActionManager implements ActionsManagerInterface {
}
handleKeyDown(event: KeyboardEvent) {
const canvasActions = this.app.props.UIOptions.canvasActions;
const data = Object.values(this.actions)
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
.filter(
(action) =>
(action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions]
: true) &&
action.keyTest &&
action.keyTest(
event,
@@ -106,15 +102,7 @@ export class ActionManager implements ActionsManagerInterface {
// like the user list. We can use this key to extract more
// data from app state. This is an alternative to generic prop hell!
renderAction = (name: ActionName, id?: string) => {
const canvasActions = this.app.props.UIOptions.canvasActions;
if (
this.actions[name] &&
"PanelComponent" in this.actions[name] &&
(name in canvasActions
? canvasActions[name as keyof typeof canvasActions]
: true)
) {
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
const action = this.actions[name];
const PanelComponent = action.PanelComponent!;
const updateData = (formState?: any) => {
+1 -5
View File
@@ -23,9 +23,7 @@ export type ShortcutName =
| "zenMode"
| "stats"
| "addToLibrary"
| "viewMode"
| "flipHorizontal"
| "flipVertical";
| "viewMode";
const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")],
@@ -59,8 +57,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
zenMode: [getShortcutKey("Alt+Z")],
stats: [],
addToLibrary: [],
flipHorizontal: [getShortcutKey("Shift+H")],
flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")],
};
+1 -6
View File
@@ -6,10 +6,7 @@ import { AppState, ExcalidrawProps } from "../types";
export type ActionResult =
| {
elements?: readonly ExcalidrawElement[] | null;
appState?: MarkOptional<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
commitToHistory: boolean;
syncHistory?: boolean;
}
@@ -88,8 +85,6 @@ export type ActionName =
| "alignHorizontallyCentered"
| "distributeHorizontally"
| "distributeVertically"
| "flipHorizontal"
| "flipVertical"
| "viewMode"
| "exportWithDarkMode";
+3 -1
View File
@@ -10,7 +10,7 @@ import { getDateTime } from "./utils";
export const getDefaultAppState = (): Omit<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
"offsetTop" | "offsetLeft"
> => {
return {
theme: "light",
@@ -43,6 +43,7 @@ export const getDefaultAppState = (): Omit<
exportWithDarkMode: false,
fileHandle: null,
gridSize: null,
height: window.innerHeight,
isBindingEnabled: true,
isLibraryOpen: false,
isLoading: false,
@@ -69,6 +70,7 @@ export const getDefaultAppState = (): Omit<
suggestedBindings: [],
toastMessage: null,
viewBackgroundColor: oc.white,
width: window.innerWidth,
zenModeEnabled: false,
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
viewModeEnabled: false,
+21 -22
View File
@@ -7,20 +7,15 @@ import { AppState } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { canvasToBlob } from "./data/blob";
import { EXPORT_DATA_TYPES } from "./constants";
const TYPE_ELEMENTS = "excalidraw/elements";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
type: typeof TYPE_ELEMENTS;
created: number;
elements: ExcalidrawElement[];
};
export interface ClipboardData {
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
text?: string;
errorMessage?: string;
}
let CLIPBOARD = "";
let PREFER_APP_CLIPBOARD = false;
@@ -36,16 +31,8 @@ export const probablySupportsClipboardBlob =
"ClipboardItem" in window &&
"toBlob" in HTMLCanvasElement.prototype;
const clipboardContainsElements = (
contents: any,
): contents is { elements: ExcalidrawElement[] } => {
if (
[
EXPORT_DATA_TYPES.excalidraw,
EXPORT_DATA_TYPES.excalidrawClipboard,
].includes(contents?.type) &&
Array.isArray(contents.elements)
) {
const isElementsClipboard = (contents: any): contents is ElementsClipboard => {
if (contents?.type === TYPE_ELEMENTS) {
return true;
}
return false;
@@ -56,7 +43,8 @@ export const copyToClipboard = async (
appState: AppState,
) => {
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
type: TYPE_ELEMENTS,
created: Date.now(),
elements: getSelectedElements(elements, appState),
};
const json = JSON.stringify(contents);
@@ -117,7 +105,12 @@ const getSystemClipboard = async (
*/
export const parseClipboard = async (
event: ClipboardEvent | null,
): Promise<ClipboardData> => {
): Promise<{
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
text?: string;
errorMessage?: string;
}> => {
const systemClipboard = await getSystemClipboard(event);
// if system clipboard empty, couldn't be resolved, or contains previously
@@ -138,9 +131,15 @@ export const parseClipboard = async (
try {
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 };
}
// in-app clipboard is newer than system clipboard
return appClipboardData;
} catch {
// 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 { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import {
canChangeSharpness,
canHaveArrowheads,
+181 -320
View File
@@ -1,9 +1,8 @@
import { Point, simplify } from "points-on-curve";
import React, { useContext } from "react";
import React from "react";
import { RoughCanvas } from "roughjs/bin/canvas";
import rough from "roughjs/bin/rough";
import clsx from "clsx";
import { supported } from "browser-fs-access";
import {
actionAddToLibrary,
@@ -17,8 +16,6 @@ import {
actionDeleteSelected,
actionDuplicateSelection,
actionFinalize,
actionFlipHorizontal,
actionFlipVertical,
actionGroup,
actionPasteStyles,
actionSelectAll,
@@ -44,9 +41,7 @@ import {
import {
APP_NAME,
CURSOR_TYPE,
DEFAULT_UI_OPTIONS,
DEFAULT_VERTICAL_ALIGN,
DETECT_POSITION_CHANGE_INTERVAL,
DRAGGING_THRESHOLD,
ELEMENT_SHIFT_TRANSLATE_AMOUNT,
ELEMENT_TRANSLATE_AMOUNT,
@@ -55,16 +50,11 @@ import {
GRID_SIZE,
LINE_CONFIRM_THRESHOLD,
MIME_TYPES,
MQ_MAX_HEIGHT_LANDSCAPE,
MQ_MAX_WIDTH_LANDSCAPE,
MQ_MAX_WIDTH_PORTRAIT,
POINTER_BUTTON,
SCROLL_TIMEOUT,
TAP_TWICE_TIMEOUT,
TEXT_TO_CENTER_SNAP_THRESHOLD,
TOUCH_CTX_MENU_TIMEOUT,
URL_HASH_KEYS,
URL_QUERY_KEYS,
ZOOM_STEP,
} from "../constants";
import { loadFromBlob } from "../data";
@@ -165,11 +155,16 @@ import Scene from "../scene/Scene";
import { SceneState, ScrollBars } from "../scene/types";
import { getNewZoom } from "../scene/zoom";
import { findShapeByKey } from "../shapes";
import { AppProps, AppState, Gesture, GestureEvent, SceneData } from "../types";
import {
AppState,
ExcalidrawProps,
Gesture,
GestureEvent,
SceneData,
} from "../types";
import {
debounce,
distance,
getNearestScrollableContainer,
isInputLike,
isToolIcon,
isWritableElement,
@@ -183,15 +178,13 @@ import {
viewportCoordsToSceneCoords,
withBatchedUpdates,
} from "../utils";
import { isMobile } from "../is-mobile";
import ContextMenu, { ContextMenuOption } from "./ContextMenu";
import LayerUI from "./LayerUI";
import { Stats } from "./Stats";
import { Toast } from "./Toast";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
export const IsMobileContext = React.createContext(false);
export const useIsMobile = () => useContext(IsMobileContext);
const { history } = createHistory();
let didTapTwice: boolean = false;
@@ -281,37 +274,30 @@ export type ExcalidrawImperativeAPI = {
setScrollToContent: InstanceType<typeof App>["setScrollToContent"];
getSceneElements: InstanceType<typeof App>["getSceneElements"];
getAppState: () => InstanceType<typeof App>["state"];
refresh: InstanceType<typeof App>["refresh"];
importLibrary: InstanceType<typeof App>["importLibraryFromUrl"];
setToastMessage: InstanceType<typeof App>["setToastMessage"];
setCanvasOffsets: InstanceType<typeof App>["setCanvasOffsets"];
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
ready: true;
};
class App extends React.Component<AppProps, AppState> {
class App extends React.Component<ExcalidrawProps, AppState> {
canvas: HTMLCanvasElement | null = null;
rc: RoughCanvas | null = null;
unmounted: boolean = false;
actionManager: ActionManager;
isMobile = false;
detachIsMobileMqHandler?: () => void;
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
public static defaultProps: Partial<AppProps> = {
// needed for tests to pass since we directly render App in many tests
UIOptions: DEFAULT_UI_OPTIONS,
public static defaultProps: Partial<ExcalidrawProps> = {
width: window.innerWidth,
height: window.innerHeight,
};
private scene: Scene;
private resizeObserver: ResizeObserver | undefined;
private nearestScrollableContainer: HTMLElement | Document | undefined;
private detectPositionIntervalId: NodeJS.Timeout | undefined;
constructor(props: AppProps) {
constructor(props: ExcalidrawProps) {
super(props);
const defaultAppState = getDefaultAppState();
const {
width = window.innerWidth,
height = window.innerHeight,
excalidrawRef,
viewModeEnabled = false,
zenModeEnabled = false,
@@ -323,13 +309,13 @@ class App extends React.Component<AppProps, AppState> {
...defaultAppState,
theme,
isLoading: true,
width,
height,
...this.getCanvasOffsets(),
viewModeEnabled,
zenModeEnabled,
gridSize: gridModeEnabled ? GRID_SIZE : null,
name,
width: window.innerWidth,
height: window.innerHeight,
};
if (excalidrawRef) {
const readyPromise =
@@ -348,9 +334,7 @@ class App extends React.Component<AppProps, AppState> {
setScrollToContent: this.setScrollToContent,
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
refresh: this.refresh,
importLibrary: this.importLibraryFromUrl,
setToastMessage: this.setToastMessage,
setCanvasOffsets: this.setCanvasOffsets,
} as const;
if (typeof excalidrawRef === "function") {
excalidrawRef(api);
@@ -422,6 +406,7 @@ class App extends React.Component<AppProps, AppState> {
onPointerUp={this.removePointer}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onDrop={this.handleCanvasOnDrop}
>
{t("labels.drawingCanvas")}
</canvas>
@@ -436,12 +421,7 @@ class App extends React.Component<AppProps, AppState> {
viewModeEnabled,
} = this.state;
const {
onCollabButtonClick,
onExportToBackend,
renderFooter,
renderCustomStats,
} = this.props;
const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props;
const DEFAULT_PASTE_X = canvasDOMWidth / 2;
const DEFAULT_PASTE_Y = canvasDOMHeight / 2;
@@ -450,64 +430,57 @@ class App extends React.Component<AppProps, AppState> {
<div
className={clsx("excalidraw", {
"excalidraw--view-mode": viewModeEnabled,
"excalidraw--mobile": this.isMobile,
})}
ref={this.excalidrawContainerRef}
onDrop={this.handleAppOnDrop}
style={{
width: canvasDOMWidth,
height: canvasDOMHeight,
}}
>
<IsMobileContext.Provider value={this.isMobile}>
<LayerUI
canvas={this.canvas}
<LayerUI
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}
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" &&
this.props.UIOptions.canvasActions.theme
}
libraryReturnUrl={this.props.libraryReturnUrl}
UIOptions={this.props.UIOptions}
onClose={this.toggleStats}
/>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
{this.state.showStats && (
<Stats
appState={this.state}
setAppState={this.setAppState}
elements={this.scene.getElements()}
onClose={this.toggleStats}
renderCustomStats={renderCustomStats}
/>
)}
{this.state.toastMessage !== null && (
<Toast
message={this.state.toastMessage}
clearToast={this.clearToast}
/>
)}
<main>{this.renderCanvas()}</main>
</IsMobileContext.Provider>
)}
{this.state.toastMessage !== null && (
<Toast
message={this.state.toastMessage}
clearToast={this.clearToast}
/>
)}
<main>{this.renderCanvas()}</main>
</div>
);
}
@@ -552,7 +525,8 @@ class App extends React.Component<AppProps, AppState> {
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
let gridSize = actionResult?.appState?.gridSize || null;
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") {
viewModeEnabled = this.props.viewModeEnabled;
}
@@ -572,6 +546,7 @@ class App extends React.Component<AppProps, AppState> {
if (typeof this.props.name !== "undefined") {
name = this.props.name;
}
this.setState(
(state) => {
// 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 || {}, {
editingElement:
editingElement || actionResult.appState?.editingElement || null,
width: state.width,
height: state.height,
offsetTop: state.offsetTop,
offsetLeft: state.offsetLeft,
viewModeEnabled,
zenModeEnabled,
gridSize,
@@ -624,17 +603,8 @@ class App extends React.Component<AppProps, AppState> {
this.onSceneUpdated();
};
private importLibraryFromUrl = async (url: string, token?: string | null) => {
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
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()}`);
}
private importLibraryFromUrl = async (url: string) => {
window.history.replaceState({}, APP_NAME, window.location.origin);
try {
const request = await fetch(decodeURIComponent(url));
const blob = await request.blob();
@@ -643,17 +613,14 @@ class App extends React.Component<AppProps, AppState> {
throw new Error();
}
if (
token === Library.csrfToken ||
window.confirm(
t("alerts.confirmAddLibrary", { numShapes: json.library.length }),
)
) {
await Library.importLibrary(blob);
// hack to rerender the library items after import
if (this.state.isLibraryOpen) {
this.setState({ isLibraryOpen: false });
}
this.setState({ isLibraryOpen: true });
this.setState({
isLibraryOpen: true,
});
}
} catch (error) {
window.alert(t("alerts.errorLoadingLibrary"));
@@ -712,6 +679,7 @@ class App extends React.Component<AppProps, AppState> {
if (!this.state.isLoading) {
this.setState({ isLoading: true });
}
let initialData = null;
try {
initialData = (await this.props.initialData) || null;
@@ -720,6 +688,7 @@ class App extends React.Component<AppProps, AppState> {
}
const scene = restore(initialData, null);
scene.appState = {
...scene.appState,
isLoading: false,
@@ -747,18 +716,12 @@ class App extends React.Component<AppProps, AppState> {
commitToHistory: true,
});
const libraryUrl =
// current
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,
);
const addToLibraryUrl = new URLSearchParams(window.location.search).get(
"addLibrary",
);
if (libraryUrl) {
await this.importLibraryFromUrl(libraryUrl);
if (addToLibraryUrl) {
await this.importLibraryFromUrl(addToLibraryUrl);
}
};
@@ -791,57 +754,23 @@ class App extends React.Component<AppProps, AppState> {
this.scene.addCallback(this.onSceneUpdated);
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));
if (searchParams.has("web-share-target")) {
// Obtain a file that was shared via the Web Share Target API.
this.restoreFileFromShare();
} else {
this.updateDOMRect(this.initializeScene);
this.setState(this.getCanvasOffsets(), () => {
this.initializeScene();
});
}
}
public componentWillUnmount() {
this.resizeObserver?.disconnect();
this.unmounted = true;
this.removeEventListeners();
this.scene.destroy();
clearTimeout(touchTimeout);
if (this.detectPositionIntervalId) {
clearInterval(this.detectPositionIntervalId);
}
touchTimeout = 0;
}
@@ -856,10 +785,6 @@ class App extends React.Component<AppProps, AppState> {
document.removeEventListener(EVENT.COPY, this.onCopy);
document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
document.removeEventListener(EVENT.CUT, this.onCut);
this.nearestScrollableContainer?.removeEventListener(
EVENT.SCROLL,
this.onScroll,
);
document.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
document.removeEventListener(
@@ -889,8 +814,6 @@ class App extends React.Component<AppProps, AppState> {
this.onGestureEnd as any,
false,
);
this.detachIsMobileMqHandler?.();
}
private addEventListeners() {
@@ -926,15 +849,8 @@ class App extends React.Component<AppProps, AppState> {
document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
document.addEventListener(EVENT.CUT, this.onCut);
if (this.props.detectScroll) {
this.nearestScrollableContainer = getNearestScrollableContainer(
this.excalidrawContainerRef.current!,
);
this.nearestScrollableContainer.addEventListener(
EVENT.SCROLL,
this.onScroll,
);
}
document.addEventListener(EVENT.SCROLL, this.onScroll);
window.addEventListener(EVENT.RESIZE, this.onResize, false);
window.addEventListener(EVENT.UNLOAD, this.onUnload, 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);
}
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
if (prevProps.langCode !== this.props.langCode) {
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) {
this.setState(
{ viewModeEnabled: !!this.props.viewModeEnabled },
@@ -1075,7 +1002,7 @@ class App extends React.Component<AppProps, AppState> {
},
{
renderOptimizations: true,
renderScrollbars: !this.isMobile,
renderScrollbars: !isMobile(),
},
);
if (scrollBars) {
@@ -1104,7 +1031,7 @@ class App extends React.Component<AppProps, AppState> {
}
}
private updateOffsetsIfChanged = () => {
private onScroll = debounce(() => {
const { offsetTop, offsetLeft } = this.getCanvasOffsets();
this.setState((state) => {
if (state.offsetLeft === offsetLeft && state.offsetTop === offsetTop) {
@@ -1112,9 +1039,7 @@ class App extends React.Component<AppProps, AppState> {
}
return { offsetTop, offsetLeft };
});
};
private onScroll = debounce(this.updateOffsetsIfChanged, SCROLL_TIMEOUT);
}, SCROLL_TIMEOUT);
// Copy/paste
@@ -1216,11 +1141,6 @@ class App extends React.Component<AppProps, AppState> {
return;
}
const data = await parseClipboard(event);
if (this.props.onPaste) {
if (await this.props.onPaste(data, event)) {
return;
}
}
if (data.errorMessage) {
this.setState({ errorMessage: data.errorMessage });
} else if (data.spreadsheet) {
@@ -1388,10 +1308,6 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ toastMessage: null });
};
setToastMessage = (toastMessage: string) => {
this.setState({ toastMessage });
};
restoreFileFromShare = async () => {
try {
const webShareTargetCache = await caches.open("web-share-target");
@@ -1489,7 +1405,7 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) {
if (event[KEYS.CTRL_OR_CMD]) {
this.setState({ isBindingEnabled: false });
}
@@ -1965,7 +1881,8 @@ class App extends React.Component<AppProps, AppState> {
}
resetCursor(this.canvas);
if (!event[KEYS.CTRL_OR_CMD] && !this.state.viewModeEnabled) {
if (!event[KEYS.CTRL_OR_CMD]) {
this.startTextEditing({
sceneX,
sceneY,
@@ -2339,7 +2256,10 @@ class App extends React.Component<AppProps, AppState> {
touchTimeout = window.setTimeout(() => {
touchTimeout = 0;
if (!invalidateContextMenu) {
this.handleCanvasContextMenu(event);
this.openContextMenu({
clientX: event.clientX,
clientY: event.clientY,
});
}
}, 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 {
const file = event.dataTransfer.files[0];
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];
if (
file?.type === MIME_TYPES.excalidrawlib ||
file?.name?.endsWith(".excalidrawlib")
file?.type === "application/json" ||
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 });
if (supported) {
if (
"chooseFileSystemEntries" in window ||
"showOpenFilePicker" in window
) {
try {
// This will only work as of Chrome 86,
// 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);
}
}
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.preventDefault();
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);
}
this.openContextMenu(event);
};
private maybeDragNewGenericElement = (
@@ -3844,17 +3754,18 @@ class App extends React.Component<AppProps, AppState> {
return false;
};
/** @private use this.handleCanvasContextMenu */
private _openContextMenu = (
{
left,
top,
}: {
left: number;
top: number;
},
type: "canvas" | "element",
) => {
private openContextMenu = ({
clientX,
clientY,
}: {
clientX: number;
clientY: number;
}) => {
const { x, y } = viewportCoordsToSceneCoords(
{ clientX, clientY },
this.state,
);
const maybeGroupAction = actionGroup.contextItemPredicate!(
this.actionManager.getElementsIncludingDeleted(),
this.actionManager.getAppState(),
@@ -3865,20 +3776,12 @@ class App extends React.Component<AppProps, AppState> {
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 elements = this.scene.getElements();
const _isMobile = isMobile();
const elements = this.scene.getElements();
const element = this.getElementAtPosition(x, y);
const options: ContextMenuOption[] = [];
if (probablySupportsClipboardBlob && elements.length > 0) {
options.push(actionCopyAsPng);
@@ -3887,7 +3790,7 @@ class App extends React.Component<AppProps, AppState> {
if (probablySupportsClipboardWriteText && elements.length > 0) {
options.push(actionCopyAsSvg);
}
if (type === "canvas") {
if (!element) {
const viewModeOptions = [
...options,
typeof this.props.gridModeEnabled === "undefined" &&
@@ -3900,11 +3803,10 @@ class App extends React.Component<AppProps, AppState> {
ContextMenu.push({
options: viewModeOptions,
top,
left,
top: clientY,
left: clientX,
actionManager: this.actionManager,
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
if (this.state.viewModeEnabled) {
@@ -3913,7 +3815,7 @@ class App extends React.Component<AppProps, AppState> {
ContextMenu.push({
options: [
this.isMobile &&
_isMobile &&
navigator.clipboard && {
name: "paste",
perform: (elements, appStates) => {
@@ -3924,7 +3826,7 @@ class App extends React.Component<AppProps, AppState> {
},
contextItemLabel: "labels.paste",
},
this.isMobile && navigator.clipboard && separator,
_isMobile && navigator.clipboard && separator,
probablySupportsClipboardBlob &&
elements.length > 0 &&
actionCopyAsPng,
@@ -3944,32 +3846,34 @@ class App extends React.Component<AppProps, AppState> {
actionToggleViewMode,
actionToggleStats,
],
top,
left,
top: clientY,
left: clientX,
actionManager: this.actionManager,
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
return;
}
if (!this.state.selectedElementIds[element.id]) {
this.setState({ selectedElementIds: { [element.id]: true } });
}
if (this.state.viewModeEnabled) {
ContextMenu.push({
options: [navigator.clipboard && actionCopy, ...options],
top,
left,
top: clientY,
left: clientX,
actionManager: this.actionManager,
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
return;
}
ContextMenu.push({
options: [
this.isMobile && actionCut,
this.isMobile && navigator.clipboard && actionCopy,
this.isMobile &&
_isMobile && actionCut,
_isMobile && navigator.clipboard && actionCopy,
_isMobile &&
navigator.clipboard && {
name: "paste",
perform: (elements, appStates) => {
@@ -3980,7 +3884,7 @@ class App extends React.Component<AppProps, AppState> {
},
contextItemLabel: "labels.paste",
},
this.isMobile && separator,
_isMobile && separator,
...options,
separator,
actionCopyStyles,
@@ -3996,17 +3900,13 @@ class App extends React.Component<AppProps, AppState> {
actionSendToBack,
actionBringToFront,
separator,
maybeFlipHorizontal && actionFlipHorizontal,
maybeFlipVertical && actionFlipVertical,
(maybeFlipHorizontal || maybeFlipVertical) && separator,
actionDuplicateSelection,
actionDeleteSelected,
],
top,
left,
top: clientY,
left: clientX,
actionManager: this.actionManager,
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
};
@@ -4140,56 +4040,14 @@ class App extends React.Component<AppProps, AppState> {
}
}, 300);
private updateDOMRect = (cb?: () => void) => {
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 = () => {
public setCanvasOffsets = () => {
this.setState({ ...this.getCanvasOffsets() });
};
private getCanvasOffsets(): Pick<AppState, "offsetTop" | "offsetLeft"> {
if (this.excalidrawContainerRef?.current) {
const excalidrawContainer = this.excalidrawContainerRef.current;
const { left, top } = excalidrawContainer.getBoundingClientRect();
if (this.excalidrawContainerRef?.current?.parentElement) {
const parentElement = this.excalidrawContainerRef.current.parentElement;
const { left, top } = parentElement.getBoundingClientRect();
return {
offsetLeft: left,
offsetTop: top,
@@ -4223,6 +4081,9 @@ declare global {
history: SceneHistory;
app: InstanceType<typeof App>;
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";
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 cycle = () => {
function cycle() {
const index = options.indexOf(current!);
const next = (index + 1) % options.length;
onChange(options[next].value);
};
}
return (
<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 { ToolButton } from "./ToolButton";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { users } from "./icons";
import "./CollabButton.scss";
+1 -1
View File
@@ -218,7 +218,7 @@
left: 2px;
}
@include isMobile {
@media #{$is-mobile-query} {
display: none;
}
}
+1 -1
View File
@@ -76,7 +76,7 @@
z-index: 1;
}
@include isMobile {
@media #{$is-mobile-query} {
.context-menu-option {
display: block;
+55 -57
View File
@@ -32,63 +32,67 @@ const ContextMenu = ({
actionManager,
appState,
}: ContextMenuProps) => {
const isDarkTheme = !!document
.querySelector(".excalidraw")
?.classList.contains("theme--dark");
return (
<Popover
onCloseRequest={onCloseRequest}
top={top}
left={left}
fitInViewport={true}
<div
className={clsx("excalidraw", {
"theme--dark theme--dark-background-none": isDarkTheme,
})}
>
<ul
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
<Popover
onCloseRequest={onCloseRequest}
top={top}
left={left}
fitInViewport={true}
>
{options.map((option, idx) => {
if (option === "separator") {
return <hr key={idx} className="context-menu-option-separator" />;
}
<ul
className="context-menu"
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 label = option.contextItemLabel
? t(option.contextItemLabel)
: "";
return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button
className={clsx("context-menu-option", {
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() => actionManager.executeAction(option)}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
const actionName = option.name;
const label = option.contextItemLabel
? t(option.contextItemLabel)
: "";
return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button
className={clsx("context-menu-option", {
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() => actionManager.executeAction(option)}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
</div>
);
};
const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>();
const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
let contextMenuNode = contextMenuNodeByContainer.get(container);
let contextMenuNode: HTMLDivElement;
const getContextMenuNode = (): HTMLDivElement => {
if (contextMenuNode) {
return contextMenuNode;
}
contextMenuNode = document.createElement("div");
container
.querySelector(".excalidraw-contextMenuContainer")!
.appendChild(contextMenuNode);
contextMenuNodeByContainer.set(container, contextMenuNode);
return contextMenuNode;
const div = document.createElement("div");
document.body.appendChild(div);
return (contextMenuNode = div);
};
type ContextMenuParams = {
@@ -97,16 +101,10 @@ type ContextMenuParams = {
left: ContextMenuProps["left"];
actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>;
container: HTMLElement;
};
const handleClose = (container: HTMLElement) => {
const contextMenuNode = contextMenuNodeByContainer.get(container);
if (contextMenuNode) {
unmountComponentAtNode(contextMenuNode);
contextMenuNode.remove();
contextMenuNodeByContainer.delete(container);
}
const handleClose = () => {
unmountComponentAtNode(getContextMenuNode());
};
export default {
@@ -123,11 +121,11 @@ export default {
top={params.top}
left={params.left}
options={options}
onCloseRequest={() => handleClose(params.container)}
onCloseRequest={handleClose}
actionManager={params.actionManager}
appState={params.appState}
/>,
getContextMenuNode(params.container),
getContextMenuNode(),
);
}
},
+1 -1
View File
@@ -31,7 +31,7 @@
padding: 0 16px 16px;
}
@include isMobile {
@media #{$is-mobile-query} {
.Dialog {
--metric: calc(var(--space-factor) * 4);
--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 { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { KEYS } from "../keys";
import "./Dialog.scss";
import { back, close } from "./icons";
+8 -1
View File
@@ -28,7 +28,14 @@ export const ErrorDialog = ({
onCloseRequest={handleClose}
title={t("errorDialog.title")}
>
<div style={{ whiteSpace: "pre-wrap" }}>{message}</div>
<div>
{message.split("\n").map((line) => (
<>
{line}
<br />
</>
))}
</div>
</Dialog>
)}
</>
+1 -11
View File
@@ -31,16 +31,9 @@
.ExportDialog__name {
grid-column: project-name;
margin: auto;
display: flex;
align-items: center;
.TextInput {
height: calc(1rem - 3px);
width: 200px;
overflow: hidden;
text-align: center;
margin-left: 8px;
text-overflow: ellipsis;
&--readonly {
background: none;
@@ -48,14 +41,11 @@
&:hover {
background: none;
}
width: auto;
max-width: 200px;
padding-left: 2px;
}
}
}
@include isMobile {
@media #{$is-mobile-query} {
.ExportDialog {
display: flex;
flex-direction: column;
+1 -1
View File
@@ -6,7 +6,7 @@ import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas, getExportSize } from "../scene/export";
import { AppState } from "../types";
-8
View File
@@ -349,14 +349,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.ungroup")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
/>
<Shortcut
label={t("labels.flipHorizontal")}
shortcuts={[getShortcutKey("Shift+H")]}
/>
<Shortcut
label={t("labels.flipVertical")}
shortcuts={[getShortcutKey("Shift+V")]}
/>
</ShortcutIsland>
</Column>
</Columns>
+3 -9
View File
@@ -9,13 +9,7 @@ type HelpIconProps = {
};
export const HelpIcon = (props: HelpIconProps) => (
<button
className="help-icon"
onClick={props.onClick}
type="button"
title={`${props.title} — ?`}
aria-label={props.title}
>
{questionCircle}
</button>
<label title={`${props.title} — ?`} className="help-icon">
<div onClick={props.onClick}>{questionCircle}</div>
</label>
);
+1 -1
View File
@@ -19,7 +19,7 @@ $wide-viewport-width: 1000px;
color: $oc-gray-6;
font-size: 0.8rem;
@include isMobile {
@media #{$is-mobile-query} {
position: static;
padding-right: 2em;
}
+1 -1
View File
@@ -111,7 +111,7 @@
:root[dir="rtl"] & {
left: 2px;
}
@include isMobile {
@media #{$is-mobile-query} {
display: none;
}
}
+35 -54
View File
@@ -14,16 +14,10 @@ import { Library } from "../data/library";
import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { calculateScrollCenter, getSelectedElements } from "../scene";
import { ExportType } from "../scene/types";
import {
AppProps,
AppState,
ExcalidrawProps,
LibraryItem,
LibraryItems,
} from "../types";
import { AppState, ExcalidrawProps, LibraryItem, LibraryItems } from "../types";
import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
@@ -71,7 +65,6 @@ interface LayerUIProps {
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"];
}
const useOnClickOutside = (
@@ -128,11 +121,10 @@ const LibraryMenuItems = ({
const rows = [];
let addedPendingElements = false;
const referrer =
libraryReturnUrl || window.location.origin + window.location.pathname;
const referrer = libraryReturnUrl || window.location.origin;
rows.push(
<div className="layer-ui__library-header" key="library-header">
<div className="layer-ui__library-header">
<ToolButton
key="import"
type="button"
@@ -142,9 +134,9 @@ const LibraryMenuItems = ({
onClick={() => {
importLibraryFromJSON()
.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: true });
})
.catch(muteFSAbortError)
.catch((error) => {
@@ -152,41 +144,36 @@ const LibraryMenuItems = ({
});
}}
/>
{!!library.length && (
<>
<ToolButton
key="export"
type="button"
title={t("buttons.export")}
aria-label={t("buttons.export")}
icon={exportFile}
onClick={() => {
saveLibraryAsJSON()
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
/>
<ToolButton
key="reset"
type="button"
title={t("buttons.resetLibrary")}
aria-label={t("buttons.resetLibrary")}
icon={trash}
onClick={() => {
if (window.confirm(t("alerts.resetLibrary"))) {
Library.resetLibrary();
setLibraryItems([]);
}
}}
/>
</>
)}
<ToolButton
key="export"
type="button"
title={t("buttons.export")}
aria-label={t("buttons.export")}
icon={exportFile}
onClick={() => {
saveLibraryAsJSON()
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
/>
<ToolButton
key="reset"
type="button"
title={t("buttons.resetLibrary")}
aria-label={t("buttons.resetLibrary")}
icon={trash}
onClick={() => {
if (window.confirm(t("alerts.resetLibrary"))) {
Library.resetLibrary();
setLibraryItems([]);
}
}}
/>
<a
href={`https://libraries.excalidraw.com?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${Library.csrfToken}`}
href={`https://libraries.excalidraw.com?referrer=${referrer}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
@@ -346,7 +333,6 @@ const LayerUI = ({
renderCustomFooter,
viewModeEnabled,
libraryReturnUrl,
UIOptions,
}: LayerUIProps) => {
const isMobile = useIsMobile();
@@ -358,7 +344,6 @@ const LayerUI = ({
href="https://blog.excalidraw.com/end-to-end-encryption/"
target="_blank"
rel="noopener noreferrer"
aria-label={t("encrypted.link")}
>
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
{shield}
@@ -367,10 +352,6 @@ const LayerUI = ({
);
const renderExportDialog = () => {
if (!UIOptions.canvasActions.export) {
return null;
}
const createExporter = (type: ExportType): ExportCB => async (
exportedElements,
scale,
+1 -1
View File
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
import { close } from "../components/icons";
import { MIME_TYPES } from "../constants";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { exportToSvg } from "../scene/export";
import { LibraryItem } from "../types";
import "./LibraryUnit.scss";
+2 -2
View File
@@ -52,7 +52,7 @@
border-radius: 6px;
box-sizing: border-box;
@include isMobile {
@media #{$is-mobile-query} {
max-width: 100%;
border: 0;
border-radius: 0;
@@ -82,7 +82,7 @@
}
}
@include isMobile {
@media #{$is-mobile-query} {
.Modal {
padding: 0;
}
+1 -13
View File
@@ -1,10 +1,9 @@
import "./Modal.scss";
import React, { useState, useLayoutEffect, useRef } from "react";
import React, { useState, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import clsx from "clsx";
import { KEYS } from "../keys";
import { useIsMobile } from "../components/App";
export const Modal = (props: {
className?: string;
@@ -49,16 +48,6 @@ export const Modal = (props: {
const useBodyRoot = () => {
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(() => {
const isDarkTheme = !!document
.querySelector(".excalidraw")
@@ -66,7 +55,6 @@ const useBodyRoot = () => {
const div = document.createElement("div");
div.classList.add("excalidraw", "excalidraw-modal-container");
div.classList.toggle("excalidraw--mobile", isMobileRef.current);
if (isDarkTheme) {
div.classList.add("theme--dark");
+2 -2
View File
@@ -2,7 +2,7 @@
.excalidraw {
.PasteChartDialog {
@include isMobile {
@media #{$is-mobile-query} {
.Island {
display: flex;
flex-direction: column;
@@ -13,7 +13,7 @@
align-items: center;
justify-content: space-around;
flex-wrap: wrap;
@include isMobile {
@media #{$is-mobile-query} {
flex-direction: column;
justify-content: center;
}
+1 -1
View File
@@ -1,6 +1,6 @@
.excalidraw {
.popover {
position: absolute;
position: fixed;
z-index: 10;
}
}
+39 -30
View File
@@ -1,6 +1,7 @@
import "./TextInput.scss";
import React, { Component } from "react";
import { selectNode, removeSelection } from "../utils";
type Props = {
value: string;
@@ -9,18 +10,17 @@ type Props = {
isNameEditable: boolean;
};
type State = {
fileName: string;
};
export class ProjectName extends Component<Props, State> {
state = {
fileName: this.props.value,
export class ProjectName extends Component<Props> {
private handleFocus = (event: React.FocusEvent<HTMLElement>) => {
selectNode(event.currentTarget);
};
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) {
this.props.onChange(value);
}
removeSelection();
};
private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
@@ -32,30 +32,39 @@ export class ProjectName extends Component<Props, State> {
event.currentTarget.blur();
}
};
private makeEditable = (editable: HTMLSpanElement | null) => {
if (!editable) {
return;
}
try {
editable.contentEditable = "plaintext-only";
} catch {
editable.contentEditable = "true";
}
};
public render() {
return (
<>
<label htmlFor="file-name">
{`${this.props.label}${this.props.isNameEditable ? "" : ":"}`}
</label>
{this.props.isNameEditable ? (
<input
className="TextInput"
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
id="file-name"
value={this.state.fileName}
onChange={(event) =>
this.setState({ fileName: event.target.value })
}
/>
) : (
<span className="TextInput TextInput--readonly" id="file-name">
{this.props.value}
</span>
)}
</>
return this.props.isNameEditable ? (
<span
suppressContentEditableWarning
ref={this.makeEditable}
data-type="wysiwyg"
className="TextInput"
role="textbox"
aria-label={this.props.label}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
onFocus={this.handleFocus}
>
{this.props.value}
</span>
) : (
<span
className="TextInput TextInput--readonly"
aria-label={this.props.label}
>
{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 { NonDeletedExcalidrawElement } from "../element/types";
import {
getElementsStorageSize,
getTotalStorageSize,
} from "../excalidraw-app/data/localStorage";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import useIsMobile from "../is-mobile";
import { getTargetElements } from "../scene";
import { AppState, ExcalidrawProps } from "../types";
import { AppState } from "../types";
import { debounce, getVersion, nFormatter } from "../utils";
import { close } from "./icons";
import { Island } from "./Island";
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: {
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"];
}) => {
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 selectedElements = getTargetElements(props.elements, props.appState);
@@ -26,6 +53,17 @@ export const Stats = (props: {
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 (
<div className="Stats">
<Island padding={2}>
@@ -50,7 +88,17 @@ export const Stats = (props: {
<td>{t("stats.height")}</td>
<td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
</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 && (
<tr>
<th colSpan={2}>{t("stats.element")}</th>
@@ -72,17 +120,31 @@ export const Stats = (props: {
<>
<tr>
<td>{"x"}</td>
<td>{Math.round(selectedBoundingBox[0])}</td>
<td>
{Math.round(
selectedElements.length === 1
? selectedElements[0].x
: selectedBoundingBox[0],
)}
</td>
</tr>
<tr>
<td>{"y"}</td>
<td>{Math.round(selectedBoundingBox[1])}</td>
<td>
{Math.round(
selectedElements.length === 1
? selectedElements[0].y
: selectedBoundingBox[1],
)}
</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>
{Math.round(
selectedBoundingBox[2] - selectedBoundingBox[0],
selectedElements.length === 1
? selectedElements[0].width
: selectedBoundingBox[2] - selectedBoundingBox[0],
)}
</td>
</tr>
@@ -90,7 +152,9 @@ export const Stats = (props: {
<td>{t("stats.height")}</td>
<td>
{Math.round(
selectedBoundingBox[3] - selectedBoundingBox[1],
selectedElements.length === 1
? selectedElements[0].height
: selectedBoundingBox[3] - selectedBoundingBox[1],
)}
</td>
</tr>
@@ -106,7 +170,28 @@ export const Stats = (props: {
</td>
</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>
</table>
</Island>
+1 -1
View File
@@ -193,7 +193,7 @@
margin-left: 5px;
margin-top: 1px;
@include isMobile {
@media #{$is-mobile-query} {
display: none;
}
}
-134
View File
@@ -123,22 +123,6 @@ export const shareIOS = createIcon(
{ 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
// Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE
export const resetZoom = createIcon(
@@ -810,121 +794,3 @@ export const ArrowheadBarIcon = React.memo(
{ 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 cssVariables from "./css/variables.module.scss";
import { AppProps } from "./types";
export const APP_NAME = "Excalidraw";
@@ -85,15 +84,9 @@ export const MIME_TYPES = {
excalidrawlib: "application/vnd.excalidrawlib+json",
};
export const EXPORT_DATA_TYPES = {
excalidraw: "excalidraw",
excalidrawClipboard: "excalidraw/clipboard",
excalidrawLibrary: "excalidrawlib",
} as const;
export const STORAGE_KEYS = {
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
} as const;
};
// time in milliseconds
export const TAP_TWICE_TIMEOUT = 300;
@@ -102,7 +95,7 @@ export const TITLE_TIMEOUT = 10000;
export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100;
export const DETECT_POSITION_CHANGE_INTERVAL = 500;
export const ZOOM_STEP = 0.1;
// Report a user inactive after IDLE_THRESHOLD milliseconds
@@ -117,27 +110,3 @@ export const MODES = {
};
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;
left: 0;
right: 0;
height: 100%;
width: 100%;
// serves 2 purposes:
// 1. prevent selecting text outside the component when double-clicking or
@@ -49,12 +47,6 @@
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 {
// The percentage is inspired by
// https://material.io/design/color/dark-theme.html#properties, which
@@ -230,8 +222,7 @@
align-items: center;
svg {
width: 36px;
height: 14px;
padding: 2px;
height: 18px;
opacity: 0.6;
}
&.active svg {
@@ -462,14 +453,6 @@
fill: $oc-gray-6;
bottom: 14px;
width: 1.5rem;
padding: 0;
margin: 0;
background: none;
color: var(--icon-fill-color);
&:hover {
background: none;
}
:root[dir="ltr"] & {
right: 14px;
@@ -480,7 +463,7 @@
}
}
@include isMobile {
@media #{$is-mobile-query} {
aside {
display: none;
}
+3 -6
View File
@@ -1,13 +1,10 @@
@import "open-color/open-color.scss";
@mixin isMobile() {
@at-root .excalidraw--mobile#{&} {
@content;
}
}
// keep up to date with is-mobile.tsx
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
$theme-filter: "invert(93%) hue-rotate(180deg)";
:export {
isMobileQuery: unquote($is-mobile-query);
themeFilter: unquote($theme-filter);
}
+9 -3
View File
@@ -1,5 +1,5 @@
import { cleanAppStateForExport } from "../appState";
import { EXPORT_DATA_TYPES } from "../constants";
import { MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
import { CanvasError } from "../errors";
import { t } from "../i18n";
@@ -95,7 +95,13 @@ export const loadFromBlob = async (
elements: clearElementsForExport(data.elements || []),
appState: {
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 || {}),
...(localAppState
? calculateScrollCenter(data.elements || [], localAppState, null)
@@ -115,7 +121,7 @@ export const loadFromBlob = async (
export const loadLibraryFromBlob = async (blob: Blob) => {
const contents = await parseFileContents(blob);
const data: LibraryData = JSON.parse(contents);
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
if (data.type !== "excalidrawlib") {
throw new Error(t("alerts.couldNotLoadInvalidFile"));
}
return data;
+3 -9
View File
@@ -2,7 +2,7 @@ import decodePng from "png-chunks-extract";
import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode";
import { stringToBase64, encode, decode, base64ToString } from "./encode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { MIME_TYPES } from "../constants";
// -----------------------------------------------------------------------------
// PNG
@@ -67,10 +67,7 @@ export const decodePngMetadata = async (blob: Blob) => {
const encodedData = JSON.parse(metadata.text);
if (!("encoded" in encodedData)) {
// legacy, un-encoded scene JSON
if (
"type" in encodedData &&
encodedData.type === EXPORT_DATA_TYPES.excalidraw
) {
if ("type" in encodedData && encodedData.type === "excalidraw") {
return metadata.text;
}
throw new Error("FAILED");
@@ -118,10 +115,7 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
const encodedData = JSON.parse(json);
if (!("encoded" in encodedData)) {
// legacy, un-encoded scene JSON
if (
"type" in encodedData &&
encodedData.type === EXPORT_DATA_TYPES.excalidraw
) {
if ("type" in encodedData && encodedData.type === "excalidraw") {
return json;
}
throw new Error("FAILED");
+6 -6
View File
@@ -1,6 +1,6 @@
import { fileOpen, fileSave } from "browser-fs-access";
import { cleanAppStateForExport } from "../appState";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
@@ -14,7 +14,7 @@ export const serializeAsJSON = (
): string =>
JSON.stringify(
{
type: EXPORT_DATA_TYPES.excalidraw,
type: "excalidraw",
version: 2,
source: window.location.origin,
elements: clearElementsForExport(elements),
@@ -69,7 +69,7 @@ export const isValidExcalidrawData = (data?: {
appState?: any;
}): data is ImportedDataState => {
return (
data?.type === EXPORT_DATA_TYPES.excalidraw &&
data?.type === "excalidraw" &&
(!data.elements ||
(Array.isArray(data.elements) &&
(!data.appState || typeof data.appState === "object")))
@@ -80,7 +80,7 @@ export const isValidLibrary = (json: any) => {
return (
typeof json === "object" &&
json &&
json.type === EXPORT_DATA_TYPES.excalidrawLibrary &&
json.type === "excalidrawlib" &&
json.version === 1
);
};
@@ -89,7 +89,7 @@ export const saveLibraryAsJSON = async () => {
const library = await Library.loadLibrary();
const serialized = JSON.stringify(
{
type: EXPORT_DATA_TYPES.excalidrawLibrary,
type: "excalidrawlib",
version: 1,
library,
},
@@ -116,5 +116,5 @@ export const importLibraryFromJSON = async () => {
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 { getNonDeletedElements } from "../element";
import { NonDeleted, ExcalidrawElement } from "../element/types";
import { nanoid } from "nanoid";
export class Library {
private static libraryCache: LibraryItems | null = null;
public static csrfToken = nanoid();
static resetLibrary = () => {
Library.libraryCache = null;
+3 -1
View File
@@ -144,7 +144,7 @@ export const restoreElements = (
export const restoreAppState = (
appState: ImportedDataState["appState"],
localAppState: Partial<AppState> | null,
): DataState["appState"] => {
): AppState => {
appState = appState || {};
const defaultAppState = getDefaultAppState();
@@ -166,6 +166,8 @@ export const restoreAppState = (
return {
...nextAppState,
offsetLeft: appState.offsetLeft || 0,
offsetTop: appState.offsetTop || 0,
// Migrates from previous version where appState.zoom was a number
zoom:
typeof appState.zoom === "number"
+1 -1
View File
@@ -6,7 +6,7 @@ export interface DataState {
version?: string;
source?: string;
elements: readonly ExcalidrawElement[];
appState: Omit<AppState, "offsetTop" | "offsetLeft" | "width" | "height">;
appState: MarkOptional<AppState, "offsetTop" | "offsetLeft">;
}
export interface ImportedDataState {
+6 -27
View File
@@ -87,30 +87,9 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
export const newElementWith = <TElement extends ExcalidrawElement>(
element: TElement,
updates: ElementUpdate<TElement>,
): TElement => {
let didChange = false;
for (const key in updates) {
const value = (updates as any)[key];
if (typeof value !== "undefined") {
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(),
};
};
): TElement => ({
...element,
...updates,
version: element.version + 1,
versionNonce: randomInteger(),
});
+3 -3
View File
@@ -31,7 +31,7 @@ import {
import { PointerDownState } from "../components/App";
import { Point } from "../types";
export const normalizeAngle = (angle: number): number => {
const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) {
return angle - 2 * Math.PI;
}
@@ -181,7 +181,7 @@ const getPerfectElementSizeWithRotation = (
return rotate(size.width, size.height, 0, 0, -angle);
};
export const reshapeSingleTwoPointElement = (
const reshapeSingleTwoPointElement = (
element: NonDeleted<ExcalidrawLinearElement>,
resizeArrowDirection: "origin" | "end",
isRotateWithDiscreteAngle: boolean,
@@ -378,7 +378,7 @@ const resizeSingleTextElement = (
}
};
export const resizeSingleElement = (
const resizeSingleElement = (
stateAtResizeStart: NonDeletedExcalidrawElement,
shouldKeepSidesRatio: boolean,
element: NonDeletedExcalidrawElement,
+1 -2
View File
@@ -207,8 +207,7 @@ export const textWysiwyg = ({
// prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => {
if (
(event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
event.target instanceof HTMLElement &&
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
!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 { createInverseContext } from "../../createInverseContext";
import { t } from "../../i18n";
import { UserIdleState } from "../../types";
import { UserIdleState } from "./types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
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.DEVELOPMENT
) {
window.collab = window.collab || ({} as Window["collab"]);
Object.defineProperties(window, {
window.h = window.h || ({} as Window["h"]);
Object.defineProperties(window.h, {
collab: {
configurable: true,
value: this,
@@ -448,8 +448,15 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
private handleRemoteSceneUpdate = (
elements: ReconciledElements,
{ init = false }: { init?: boolean } = {},
{
init = false,
initFromSnapshot = false,
}: { init?: boolean; initFromSnapshot?: boolean } = {},
) => {
if (init || initFromSnapshot) {
this.excalidrawAPI.setScrollToContent(elements);
}
this.excalidrawAPI.updateScene({
elements,
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;
+1 -1
View File
@@ -9,7 +9,7 @@ import CollabWrapper from "./CollabWrapper";
import { getSyncableElements } from "../../packages/excalidraw/index";
import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, SCENE } from "../app_constants";
import { UserIdleState } from "../../types";
import { UserIdleState } from "./types";
import { trackEvent } from "../../analytics";
class Portal {
+3 -3
View File
@@ -32,13 +32,13 @@
display: flex;
align-items: center;
justify-content: center;
@include isMobile {
@media #{$is-mobile-query} {
flex-direction: column;
align-items: stretch;
}
}
@include isMobile {
@media #{$is-mobile-query} {
.RoomDialog-usernameLabel {
font-weight: bold;
}
@@ -51,7 +51,7 @@
min-width: 0;
flex: 1 1 auto;
margin-inline-start: 1em;
@include isMobile {
@media #{$is-mobile-query} {
margin-top: 0.5em;
margin-inline-start: 0;
}
+3 -16
View File
@@ -7,27 +7,12 @@ import {
stop,
share,
shareIOS,
shareWindows,
} from "../../components/icons";
import { ToolButton } from "../../components/ToolButton";
import { t } from "../../i18n";
import "./RoomDialog.scss";
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 = ({
handleClose,
activeRoomLink,
@@ -46,6 +31,8 @@ const RoomDialog = ({
setErrorMessage: (message: string) => void;
}) => {
const roomLinkInput = useRef<HTMLInputElement>(null);
const navigator = window.navigator as any;
const isAppleBrowser = /Apple/.test(navigator.vendor);
const copyRoomLink = async () => {
try {
@@ -106,7 +93,7 @@ const RoomDialog = ({
{"share" in navigator ? (
<ToolButton
type="button"
icon={getShareIcon()}
icon={isAppleBrowser ? shareIOS : share}
title={t("labels.share")}
aria-label={t("labels.share")}
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 { ExcalidrawElement } from "../../element/types";
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);
@@ -79,10 +80,8 @@ export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSour
_brand: "socketUpdateData";
};
const IV_LENGTH_BYTES = 12; // 96 bits
export const createIV = () => {
const arr = new Uint8Array(IV_LENGTH_BYTES);
const arr = new Uint8Array(12);
return window.crypto.getRandomValues(arr);
};
@@ -176,22 +175,6 @@ export const getImportedKey = (key: string, usage: KeyUsage) =>
[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 (
id: string | null,
privateKey?: string | null,
@@ -200,7 +183,6 @@ const importFromBackend = async (
const response = await fetch(
privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
);
if (!response.ok) {
window.alert(t("alerts.importBackendFailed"));
return {};
@@ -208,19 +190,16 @@ const importFromBackend = async (
let data: ImportedDataState;
if (privateKey) {
const buffer = await response.arrayBuffer();
let decrypted: ArrayBuffer;
try {
// Buffer should contain both the IV (fixed length) and encrypted data
const iv = buffer.slice(0, IV_LENGTH_BYTES);
const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
decrypted = await decryptImported(iv, encrypted, privateKey);
} catch (error) {
// Fixed IV (old format, backward compatibility)
const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
decrypted = await decryptImported(fixedIv, buffer, privateKey);
}
const key = await getImportedKey(privateKey, "decrypt");
const iv = new Uint8Array(12);
const decrypted = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
key,
buffer,
);
// We need to convert the decrypted array buffer to a string
const string = new window.TextDecoder("utf-8").decode(
new Uint8Array(decrypted) as any,
@@ -284,8 +263,9 @@ export const exportToBackend = async (
true, // extractable
["encrypt", "decrypt"],
);
const iv = createIV();
// The iv is set to 0. We are never going to reuse the same key so we don't
// 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
// includes checks that the ciphertext has not been modified by an attacker.
const encrypted = await window.crypto.subtle.encrypt(
@@ -296,11 +276,6 @@ export const exportToBackend = async (
key,
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 will hardcode the rest of the attributes when importing back the key.
const exportedKey = await window.crypto.subtle.exportKey("jwk", key);
@@ -308,7 +283,7 @@ export const exportToBackend = async (
try {
const response = await fetch(BACKEND_V2_POST, {
method: "POST",
body: payload,
body: encrypted,
});
const json = await response.json();
if (json.id) {
+4 -4
View File
@@ -101,7 +101,7 @@ export const importFromLocalStorage = () => {
export const getElementsStorageSize = () => {
try {
const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
const elementsSize = elements?.length || 0;
const elementsSize = elements ? JSON.stringify(elements).length : 0;
return elementsSize;
} catch (error) {
console.error(error);
@@ -117,9 +117,9 @@ export const getTotalStorageSize = () => {
APP_STORAGE_KEYS.LOCAL_STORAGE_LIBRARY,
);
const appStateSize = appState?.length || 0;
const collabSize = collab?.length || 0;
const librarySize = library?.length || 0;
const appStateSize = appState ? JSON.stringify(appState).length : 0;
const collabSize = collab ? JSON.stringify(collab).length : 0;
const librarySize = library ? JSON.stringify(library).length : 0;
return appStateSize + collabSize + librarySize + getElementsStorageSize();
} catch (error) {
+34 -42
View File
@@ -3,6 +3,7 @@ import React, {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
@@ -11,13 +12,7 @@ import { getDefaultAppState } from "../appState";
import { ExcalidrawImperativeAPI } from "../components/App";
import { ErrorDialog } from "../components/ErrorDialog";
import { TopErrorBoundary } from "../components/TopErrorBoundary";
import {
APP_NAME,
EVENT,
TITLE_TIMEOUT,
URL_HASH_KEYS,
VERSION_TIMEOUT,
} from "../constants";
import { APP_NAME, EVENT, TITLE_TIMEOUT, VERSION_TIMEOUT } from "../constants";
import { loadFromBlob } from "../data/blob";
import { DataState, ImportedDataState } from "../data/types";
import {
@@ -49,7 +44,6 @@ import {
importFromLocalStorage,
saveToLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
const languageDetector = new LanguageDetector();
languageDetector.init({
@@ -161,11 +155,31 @@ const initializeScene = async (opts: {
return null;
};
const ExcalidrawWrapper = () => {
function ExcalidrawWrapper() {
// dimensions
// ---------------------------------------------------------------------------
const [dimensions, setDimensions] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
const [errorMessage, setErrorMessage] = useState("");
const currentLangCode = languageDetector.detect() || defaultLang.code;
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
// ---------------------------------------------------------------------------
@@ -199,24 +213,12 @@ const ExcalidrawWrapper = () => {
initialStatePromiseRef.current.promise.resolve(scene);
});
const onHashChange = (event: HashChangeEvent) => {
event.preventDefault();
const hash = new URLSearchParams(window.location.hash.slice(1));
const libraryUrl = hash.get(URL_HASH_KEYS.addLibrary);
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 onHashChange = (_: HashChangeEvent) => {
initializeScene({ collabAPI }).then((scene) => {
if (scene) {
excalidrawAPI.updateScene(scene);
}
});
};
const titleTimeout = setTimeout(
@@ -303,19 +305,13 @@ const ExcalidrawWrapper = () => {
[langCode],
);
const renderCustomStats = () => {
return (
<CustomStats
setToastMessage={(message) => excalidrawAPI!.setToastMessage(message)}
/>
);
};
return (
<>
<Excalidraw
ref={excalidrawRefCallback}
onChange={onChange}
width={dimensions.width}
height={dimensions.height}
initialData={initialStatePromiseRef.current.promise}
onCollabButtonClick={collabAPI?.onCollabButtonClick}
isCollaborating={collabAPI?.isCollaborating()}
@@ -323,8 +319,6 @@ const ExcalidrawWrapper = () => {
onExportToBackend={onExportToBackend}
renderFooter={renderFooter}
langCode={langCode}
renderCustomStats={renderCustomStats}
detectScroll={false}
/>
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
{errorMessage && (
@@ -335,9 +329,9 @@ const ExcalidrawWrapper = () => {
)}
</>
);
};
}
const ExcalidrawApp = () => {
export default function ExcalidrawApp() {
return (
<TopErrorBoundary>
<CollabContextConsumer>
@@ -345,6 +339,4 @@ const ExcalidrawApp = () => {
</CollabContextConsumer>
</TopErrorBoundary>
);
};
export default ExcalidrawApp;
}
-2
View File
@@ -89,5 +89,3 @@ interface Blob {
handle?: import("browser-fs-acces").FileSystemHandle;
name?: string;
}
declare module "*.scss";
-2
View File
@@ -63,8 +63,6 @@ const canvas = exportToCanvas(
...getDefaultAppState(),
offsetTop: 0,
offsetLeft: 0,
width: 0,
height: 0,
},
{
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": "معماري",
"artist": "رسام",
"cartoonist": "كرتوني",
"fileTitle": "",
"fileTitle": "عنوان الملف",
"colorPicker": "اختيار الألوان",
"canvasBackground": "خلفية اللوحة",
"drawingCanvas": "لوحة الرسم",
@@ -77,7 +77,7 @@
"group": "تحديد مجموعة",
"ungroup": "إلغاء تحديد مجموعة",
"collaborators": "المتعاونون",
"showGrid": "إظهار الشبكة",
"showGrid": "",
"addToLibrary": "أضف إلى المكتبة",
"removeFromLibrary": "حذف من المكتبة",
"libraryLoadingMessage": "جارٍ تحميل المكتبة…",
@@ -92,11 +92,9 @@
"centerHorizontally": "توسيط أفقي",
"distributeHorizontally": "التوزيع الأفقي",
"distributeVertically": "التوزيع عمودياً",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "نمط العرض",
"viewMode": "",
"toggleExportColorScheme": "",
"share": "مشاركة"
"share": ""
},
"buttons": {
"clearReset": "إعادة تعيين اللوحة",
@@ -121,7 +119,7 @@
"edit": "تعديل",
"undo": "تراجع",
"redo": "إعادة تنفيذ",
"resetLibrary": "إعادة ضبط المكتبة",
"resetLibrary": "",
"createNewRoom": "إنشاء غرفة جديدة",
"fullScreen": "شاشة كاملة",
"darkMode": "الوضع المظلم",
@@ -140,7 +138,7 @@
"decryptFailed": "تعذر فك تشفير البيانات.",
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
"collabStopOverridePrompt": "إيقاف الجلسة سيؤدي إلى الكتابة فوق رسومك السابقة المخزنة داخليا. هل أنت متأكد؟\n\n(إذا كنت ترغب في الاحتفاظ برسمك المخزن داخليا، ببساطة أغلق علامة تبويب المتصفح بدلاً من ذلك.)",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
@@ -214,9 +212,9 @@
"curvedArrow": "",
"curvedLine": "",
"documentation": "",
"drag": "اسحب",
"editor": "المحرر",
"github": "عثرت على مشكلة؟ إرسال",
"drag": "",
"editor": "",
"github": "",
"howto": "",
"or": "",
"preventBinding": "",
@@ -230,8 +228,7 @@
"zoomToSelection": ""
},
"encrypted": {
"tooltip": "رسوماتك مشفرة من النهاية إلى النهاية حتى أن خوادم Excalidraw لن تراها أبدا.",
"link": ""
"tooltip": "رسوماتك مشفرة من النهاية إلى النهاية حتى أن خوادم Excalidraw لن تراها أبدا."
},
"stats": {
"angle": "الزاوية",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Архитект",
"artist": "Художник",
"cartoonist": "Карикатурист",
"fileTitle": "",
"fileTitle": "Заглавие на файл",
"colorPicker": "Избор на цвят",
"canvasBackground": "Фон на платно",
"drawingCanvas": "Платно за рисуване",
@@ -92,8 +92,6 @@
"centerHorizontally": "Центрирай хоризонтално",
"distributeHorizontally": "Разпредели хоризонтално",
"distributeVertically": "Разпредели вертикално",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "Изглед",
"toggleExportColorScheme": "",
"share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "Приближи селекцията"
},
"encrypted": {
"tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат.",
"link": ""
"tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат."
},
"stats": {
"angle": "Ъгъл",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Arquitecte",
"artist": "Artista",
"cartoonist": "Dibuixant",
"fileTitle": "",
"fileTitle": "Títol del fitxer",
"colorPicker": "Selector de colors",
"canvasBackground": "Fons del llenç",
"drawingCanvas": "Llenç de dibuix",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centrar horitzontalment",
"distributeHorizontally": "Distribuir horitzontalment",
"distributeVertically": "Distribuir verticalment",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "Mode de visualització",
"toggleExportColorScheme": "Canvia l'esquema de colors de l'exportació",
"share": "Compartir"
@@ -230,8 +228,7 @@
"zoomToSelection": "Zoom per veure la selecció"
},
"encrypted": {
"tooltip": "Els vostres dibuixos estan xifrats de punta a punta de manera que els servidors dExcalidraw no els veuran mai.",
"link": ""
"tooltip": "Els vostres dibuixos estan xifrats de punta a punta de manera que els servidors dExcalidraw no els veuran mai."
},
"stats": {
"angle": "Angle",
+1 -4
View File
@@ -92,8 +92,6 @@
"centerHorizontally": "Horizontal zentrieren",
"distributeHorizontally": "Horizontal verteilen",
"distributeVertically": "Vertikal verteilen",
"flipHorizontal": "Horizontal spiegeln",
"flipVertical": "Vertikal spiegeln",
"viewMode": "Ansichtsmodus",
"toggleExportColorScheme": "Farbschema für Export umschalten",
"share": "Teilen"
@@ -230,8 +228,7 @@
"zoomToSelection": "Auf Auswahl zoomen"
},
"encrypted": {
"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"
"tooltip": "Da deine Zeichnungen Ende-zu-Ende verschlüsselt werden, sehen auch unsere Excalidraw-Server sie niemals."
},
"stats": {
"angle": "Winkel",
+3 -6
View File
@@ -61,7 +61,7 @@
"architect": "Αρχιτέκτονας",
"artist": "Καλλιτέχνης",
"cartoonist": "Σκιτσογράφος",
"fileTitle": "Όνομα αρχείου",
"fileTitle": "Τίτλος αρχείου",
"colorPicker": "Επιλογή Χρώματος",
"canvasBackground": "Φόντο καμβά",
"drawingCanvas": "Σχεδίαση καμβά",
@@ -92,11 +92,9 @@
"centerHorizontally": "Κέντρο οριζόντια",
"distributeHorizontally": "Οριζόντια κατανομή",
"distributeVertically": "Κατακόρυφη κατανομή",
"flipHorizontal": "Οριζόντια αναστροφή",
"flipVertical": "Κατακόρυφη αναστροφή",
"viewMode": "Λειτουργία προβολής",
"toggleExportColorScheme": "Εναλλαγή εξαγωγής θέματος χρωμάτων",
"share": "Κοινοποίηση"
"share": ""
},
"buttons": {
"clearReset": "Επαναφορά του καμβά",
@@ -230,8 +228,7 @@
"zoomToSelection": "Ζουμ στην επιλογή"
},
"encrypted": {
"tooltip": "Τα σχέδιά σου είναι κρυπτογραφημένα από άκρο σε άκρο, έτσι δεν θα είναι ποτέ ορατά μέσα από τους διακομιστές του Excalidraw.",
"link": ""
"tooltip": "Τα σχέδιά σου είναι κρυπτογραφημένα από άκρο σε άκρο, έτσι δεν θα είναι ποτέ ορατά μέσα από τους διακομιστές του Excalidraw."
},
"stats": {
"angle": "Γωνία",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Architect",
"artist": "Artist",
"cartoonist": "Cartoonist",
"fileTitle": "File name",
"fileTitle": "File title",
"colorPicker": "Color picker",
"canvasBackground": "Canvas background",
"drawingCanvas": "Drawing canvas",
@@ -92,8 +92,6 @@
"centerHorizontally": "Center horizontally",
"distributeHorizontally": "Distribute horizontally",
"distributeVertically": "Distribute vertically",
"flipHorizontal": "Flip horizontal",
"flipVertical": "Flip vertical",
"viewMode": "View mode",
"toggleExportColorScheme": "Toggle export color scheme",
"share": "Share"
@@ -230,8 +228,7 @@
"zoomToSelection": "Zoom to selection"
},
"encrypted": {
"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"
"tooltip": "Your drawings are end-to-end encrypted so Excalidraw's servers will never see them."
},
"stats": {
"angle": "Angle",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Arquitecto",
"artist": "Artista",
"cartoonist": "Caricatura",
"fileTitle": "Nombre del archivo",
"fileTitle": "Título del archivo",
"colorPicker": "Selector de color",
"canvasBackground": "Fondo del lienzo",
"drawingCanvas": "Lienzo de dibujo",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centrar horizontalmente",
"distributeHorizontally": "Distribuir horizontalmente",
"distributeVertically": "Distribuir verticalmente",
"flipHorizontal": "Girar horizontalmente",
"flipVertical": "Girar verticalmente",
"viewMode": "Modo presentación",
"toggleExportColorScheme": "Cambiar el esquema de colores de exportación",
"share": "Compartir"
@@ -230,8 +228,7 @@
"zoomToSelection": "Hacer zoom a la selección"
},
"encrypted": {
"tooltip": "Tus dibujos están cifrados de punto a punto, por lo que los servidores de Excalidraw nunca los verán.",
"link": ""
"tooltip": "Tus dibujos están cifrados de punto a punto, por lo que los servidores de Excalidraw nunca los verán."
},
"stats": {
"angle": "Ángulo",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "معمار",
"artist": "هنرمند",
"cartoonist": "کارتونیست",
"fileTitle": "",
"fileTitle": "عنوان فایل",
"colorPicker": "انتخابگر رنگ",
"canvasBackground": "بوم",
"drawingCanvas": "بوم نقاشی",
@@ -92,8 +92,6 @@
"centerHorizontally": "وسط قرار دادن به صورت افقی",
"distributeHorizontally": "توزیع کردن به صورت افقی",
"distributeVertically": "توزیع کردن به صورت عمودی",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "",
"toggleExportColorScheme": "",
"share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "بزرگنمایی قسمت انتخاب شده"
},
"encrypted": {
"tooltip": "شما در یک محیط رمزگزاری شده دو طرفه در حال طراحی هستید پس Excalidraw هرگز طرح های شما را نمیبند.",
"link": ""
"tooltip": "شما در یک محیط رمزگزاری شده دو طرفه در حال طراحی هستید پس Excalidraw هرگز طرح های شما را نمیبند."
},
"stats": {
"angle": "زاویه",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Arkkitehti",
"artist": "Taiteilija",
"cartoonist": "Sarjakuva",
"fileTitle": "Tiedostonimi",
"fileTitle": "Tiedoston otsikko",
"colorPicker": "Värin valinta",
"canvasBackground": "Piirtoalueen tausta",
"drawingCanvas": "Piirtoalue",
@@ -92,8 +92,6 @@
"centerHorizontally": "Keskitä vaakasuunnassa",
"distributeHorizontally": "Jaa vaakasuunnassa",
"distributeVertically": "Jaa pystysuunnassa",
"flipHorizontal": "Käännä vaakasuunnassa",
"flipVertical": "Käännä pystysuunnassa",
"viewMode": "Katselutila",
"toggleExportColorScheme": "Vaihda viennin väriteema",
"share": "Jaa"
@@ -230,8 +228,7 @@
"zoomToSelection": "Näytä valinta"
},
"encrypted": {
"tooltip": "Piirroksesi ovat päästä-päähän-salattuja, joten Excalidrawin palvelimet eivät koskaan näe niitä.",
"link": ""
"tooltip": "Piirroksesi ovat päästä-päähän-salattuja, joten Excalidrawin palvelimet eivät koskaan näe niitä."
},
"stats": {
"angle": "Kulma",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Architecte",
"artist": "Artiste",
"cartoonist": "Caricaturiste",
"fileTitle": "Nom du fichier",
"fileTitle": "Titre du fichier",
"colorPicker": "Sélecteur de couleur",
"canvasBackground": "Arrière-plan du canevas",
"drawingCanvas": "Zone de dessin",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centrer horizontalement",
"distributeHorizontally": "Distribuer horizontalement",
"distributeVertically": "Distribuer verticalement",
"flipHorizontal": "Retourner horizontalement",
"flipVertical": "Retourner verticalement",
"viewMode": "Mode présentation",
"toggleExportColorScheme": "Activer/Désactiver l'export du thème de couleur",
"share": "Partager"
@@ -230,8 +228,7 @@
"zoomToSelection": "Zoomer sur la sélection"
},
"encrypted": {
"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"
"tooltip": "Vos dessins sont chiffrés de bout en bout, les serveurs d'Excalidraw ne les verront jamais."
},
"stats": {
"angle": "Angle",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "ארכיטקט",
"artist": "אמן",
"cartoonist": "קריקטוריסט",
"fileTitle": "",
"fileTitle": "כותרת הקובץ",
"colorPicker": "בחירת צבע",
"canvasBackground": "רקע הלוח",
"drawingCanvas": "לוח ציור",
@@ -92,8 +92,6 @@
"centerHorizontally": "מרכז אופקית",
"distributeHorizontally": "חלוקה אופקית",
"distributeVertically": "חלוקה אנכית",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "מצב תצוגה",
"toggleExportColorScheme": "",
"share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "התמקד בבחירה"
},
"encrypted": {
"tooltip": "הרישומים שלך מוצפנים מקצה לקצה כך שהשרתים של Excalidraw לא יראו אותם לעולם.",
"link": ""
"tooltip": "הרישומים שלך מוצפנים מקצה לקצה כך שהשרתים של Excalidraw לא יראו אותם לעולם."
},
"stats": {
"angle": "זווית",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "वास्तुकार",
"artist": "कलाकार",
"cartoonist": "व्यंग्य चित्रकार",
"fileTitle": "फ़ाइल का नाम",
"fileTitle": "फ़ाइल का शीर्षक",
"colorPicker": "रंग चयन",
"canvasBackground": "कैनवास बैकग्राउंड",
"drawingCanvas": "कैनवास बना रहे हैं",
@@ -92,8 +92,6 @@
"centerHorizontally": "क्षैतिज केन्द्रित",
"distributeHorizontally": "क्षैतिज रूप से वितरित करें",
"distributeVertically": "खड़ी रूप से वितरित करें",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "",
"toggleExportColorScheme": "",
"share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "चयन तक ज़ूम करे"
},
"encrypted": {
"tooltip": "आपके चित्र अंत-से-अंत एन्क्रिप्टेड हैं, इसलिए एक्सक्लूसिव्रॉव के सर्वर उन्हें कभी नहीं देखेंगे।",
"link": ""
"tooltip": "आपके चित्र अंत-से-अंत एन्क्रिप्टेड हैं, इसलिए एक्सक्लूसिव्रॉव के सर्वर उन्हें कभी नहीं देखेंगे।"
},
"stats": {
"angle": "कोण",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Tervezői",
"artist": "Művészi",
"cartoonist": "Karikatúrás",
"fileTitle": "",
"fileTitle": "Fájl címe",
"colorPicker": "Színválasztó",
"canvasBackground": "Vászon háttérszíne",
"drawingCanvas": "Rajzvászon",
@@ -92,8 +92,6 @@
"centerHorizontally": "Vízszintesen középre igazított",
"distributeHorizontally": "Vízszintes elosztás",
"distributeVertically": "Függőleges elosztás",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "",
"toggleExportColorScheme": "",
"share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": ""
},
"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.",
"link": ""
"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."
},
"stats": {
"angle": "Szög",
+3 -6
View File
@@ -61,7 +61,7 @@
"architect": "Arsitek",
"artist": "Artis",
"cartoonist": "Kartunis",
"fileTitle": "Nama file",
"fileTitle": "Judul file",
"colorPicker": "Pilihan Warna",
"canvasBackground": "Latar Kanvas",
"drawingCanvas": "Kanvas",
@@ -92,8 +92,6 @@
"centerHorizontally": "Pusatkan secara horizontal",
"distributeHorizontally": "Distribusikan horizontal",
"distributeVertically": "Distribusikan vertikal",
"flipHorizontal": "Balikkan horizontal",
"flipVertical": "Balikkan vertikal",
"viewMode": "Mode tampilan",
"toggleExportColorScheme": "Ubah skema warna ekspor",
"share": "Bagikan"
@@ -145,7 +143,7 @@
"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?",
"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?"
},
"toolBar": {
@@ -230,8 +228,7 @@
"zoomToSelection": "Perbesar ke seleksi"
},
"encrypted": {
"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"
"tooltip": "Gambar anda terenkripsi end-to-end sehingga server Excalidraw tidak akan pernah dapat melihatnya."
},
"stats": {
"angle": "Sudut",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Architetto",
"artist": "Artista",
"cartoonist": "Fumettista",
"fileTitle": "Nome del file",
"fileTitle": "Titolo del file",
"colorPicker": "Selettore colore",
"canvasBackground": "Sfondo tela",
"drawingCanvas": "Area di disegno",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centra orizzontalmente",
"distributeHorizontally": "Distribuisci orizzontalmente",
"distributeVertically": "Distribuisci verticalmente",
"flipHorizontal": "Capovolgi orizzontalmente",
"flipVertical": "Capovolgi verticalmente",
"viewMode": "Modalità visualizzazione",
"toggleExportColorScheme": "Cambia lo schema di colori in esportazione",
"share": "Condividi"
@@ -230,8 +228,7 @@
"zoomToSelection": "Zoom alla selezione"
},
"encrypted": {
"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"
"tooltip": "I tuoi disegni sono crittografati end-to-end in modo che i server di Excalidraw non li possano mai vedere."
},
"stats": {
"angle": "Angolo",
+3 -6
View File
@@ -92,8 +92,6 @@
"centerHorizontally": "横方向に中央揃え",
"distributeHorizontally": "水平方向に分散配置",
"distributeVertically": "垂直方向に分散配置",
"flipHorizontal": "水平方向に反転",
"flipVertical": "垂直方向に反転",
"viewMode": "閲覧モード",
"toggleExportColorScheme": "",
"share": ""
@@ -203,7 +201,7 @@
"desc_inProgressIntro": "共同編集セッションが有効になっています。",
"desc_shareLink": "下記URLを共同編集したい人に共有してください:",
"desc_exitSession": "セッションを終了すると部屋から切断されますが、手元の環境で編集を続けることができます。変更内容は他の人には反映されません。",
"shareTitle": "Excalidrawのライブコラボレーションセッションに参加する"
"shareTitle": ""
},
"errorDialog": {
"title": "エラー"
@@ -230,8 +228,7 @@
"zoomToSelection": "選択要素にズーム"
},
"encrypted": {
"tooltip": "描画内容はエンドツーエンド暗号化が施されており、Excalidrawサーバーが内容を見ることはできません。",
"link": "Excalidrawのエンドツーエンド暗号化に関するブログ記事"
"tooltip": "描画内容はエンドツーエンド暗号化が施されており、Excalidrawサーバーが内容を見ることはできません。"
},
"stats": {
"angle": "角度",
@@ -254,7 +251,7 @@
"copyToClipboardAsPng": "",
"fileSaved": "ファイルを保存しました",
"fileSavedToFilename": "{filename} に保存しました",
"canvas": "キャンバス",
"canvas": "",
"selection": ""
}
}
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Amasdag",
"artist": "Anaẓur",
"cartoonist": "",
"fileTitle": "Isem n ufaylu",
"fileTitle": "Azwel n ufaylu",
"colorPicker": "Amafran n yini",
"canvasBackground": "Agilal n teɣzut n usuneɣ",
"drawingCanvas": "Taɣzut n usuneɣ",
@@ -92,8 +92,6 @@
"centerHorizontally": "Di tlemmast s uglawi",
"distributeHorizontally": "Freq s uglawi",
"distributeVertically": "Freq s yibeddi",
"flipHorizontal": "Tuttya taglawant",
"flipVertical": "Tuttya tubdidt",
"viewMode": "Askar n tmuɣli",
"toggleExportColorScheme": "Sermed/sens asifeḍ usentel n yini",
"share": "Bḍu"
@@ -230,8 +228,7 @@
"zoomToSelection": "Simɣur ɣer tefrayt"
},
"encrypted": {
"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"
"tooltip": "Unuɣen-inek (m) ttuwgelhnen seg yixef s ixef dɣa iqeddacen n Excalidraw werǧin ad ten-walin. "
},
"stats": {
"angle": "Tiɣmeṛt",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "건축가",
"artist": "예술가",
"cartoonist": "만화가",
"fileTitle": "",
"fileTitle": "파일명",
"colorPicker": "색상 선택기",
"canvasBackground": "캔버스 배경",
"drawingCanvas": "캔버스 그리기",
@@ -92,8 +92,6 @@
"centerHorizontally": "수평으로 중앙 정렬",
"distributeHorizontally": "수평으로 분배",
"distributeVertically": "수직으로 분배",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "보기 모드",
"toggleExportColorScheme": "",
"share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "선택 영역으로 확대/축소"
},
"encrypted": {
"tooltip": "그림은 종단 간 암호화되므로 Excalidraw의 서버는 절대로 내용을 알 수 없습니다.",
"link": ""
"tooltip": "그림은 종단 간 암호화되므로 Excalidraw의 서버는 절대로 내용을 알 수 없습니다."
},
"stats": {
"angle": "각도",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "ဗိသုကာ",
"artist": "ပန်းချီ",
"cartoonist": "ကာတွန်း",
"fileTitle": "",
"fileTitle": "ခေါင်းစဉ်",
"colorPicker": "အရောင်ရွေး",
"canvasBackground": "ကားချပ်နောက်ခံ",
"drawingCanvas": "ပုံဆွဲကားချပ်",
@@ -92,8 +92,6 @@
"centerHorizontally": "အလျားလိုက်အလယ်ညှိ",
"distributeHorizontally": "အလျားလိုက်",
"distributeVertically": "ထောင်လိုက်",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "",
"toggleExportColorScheme": "",
"share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": ""
},
"encrypted": {
"tooltip": "ရေးဆွဲထားသောပုံများအား နှစ်ဘက်စွန်းတိုင်လျှို့ဝှက်ထားသဖြင့် Excalidraw ၏ဆာဗာများပင်လျှင်မြင်တွေ့ရမည်မဟုတ်ပါ။",
"link": ""
"tooltip": "ရေးဆွဲထားသောပုံများအား နှစ်ဘက်စွန်းတိုင်လျှို့ဝှက်ထားသဖြင့် Excalidraw ၏ဆာဗာများပင်လျှင်မြင်တွေ့ရမည်မဟုတ်ပါ။"
},
"stats": {
"angle": "ထောင့်",
+1 -4
View File
@@ -92,8 +92,6 @@
"centerHorizontally": "Midtstill horisontalt",
"distributeHorizontally": "Distribuer horisontalt",
"distributeVertically": "Distribuer vertikalt",
"flipHorizontal": "Snu horisontalt",
"flipVertical": "Snu vertikalt",
"viewMode": "Visningsmodus",
"toggleExportColorScheme": "Veksle eksport av fargepalett",
"share": "Del"
@@ -230,8 +228,7 @@
"zoomToSelection": "Zoom til utvalg"
},
"encrypted": {
"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"
"tooltip": "Dine tegninger er ende-til-ende-krypterte slik at Excalidraw sine servere aldri vil se dem."
},
"stats": {
"angle": "Vinkel",
+2 -5
View File
@@ -92,8 +92,6 @@
"centerHorizontally": "Horizontaal Centreren",
"distributeHorizontally": "Horizontaal verspreiden",
"distributeVertically": "Verticaal distribueren",
"flipHorizontal": "Horizontaal spiegelen",
"flipVertical": "Verticaal spiegelen",
"viewMode": "Weergavemodus",
"toggleExportColorScheme": "Kleurenschema exporteren aan/uit",
"share": "Deel"
@@ -140,7 +138,7 @@
"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.",
"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.",
"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?",
@@ -230,8 +228,7 @@
"zoomToSelection": "Inzoomen op selectie"
},
"encrypted": {
"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"
"tooltip": "Je tekeningen zijn beveiligd met end-to-end encryptie, dus Excalidraw's servers zullen nooit zien wat je tekent."
},
"stats": {
"angle": "Hoek",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Arkitekt",
"artist": "Kunstnar",
"cartoonist": "Teiknar",
"fileTitle": "",
"fileTitle": "Filnamn",
"colorPicker": "Fargeveljar",
"canvasBackground": "Lerretsbakgrunn",
"drawingCanvas": "Lerret",
@@ -92,8 +92,6 @@
"centerHorizontally": "Midtstill horisontalt",
"distributeHorizontally": "Sprei horisontalt",
"distributeVertically": "Sprei vertikalt",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "",
"toggleExportColorScheme": "",
"share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": ""
},
"encrypted": {
"tooltip": "Teikningane dine er ende-til-ende-krypterte slik at Excalidraw sine serverar aldri får sjå dei.",
"link": ""
"tooltip": "Teikningane dine er ende-til-ende-krypterte slik at Excalidraw sine serverar aldri får sjå dei."
},
"stats": {
"angle": "Vinkel",
+5 -8
View File
@@ -38,7 +38,7 @@
"fontSize": "Talha poliça",
"fontFamily": "Familha de poliça",
"onlySelected": "Seleccion sonque",
"withBackground": "Inclure lo rèireplan",
"withBackground": "Inclure rèireplan",
"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.",
"addWatermark": "Apondre « Fabricat amb Excalidraw »",
@@ -61,7 +61,7 @@
"architect": "Arquitècte",
"artist": "Artista",
"cartoonist": "Dessenhaire",
"fileTitle": "Nom del fichièr",
"fileTitle": "Títol del fichièr",
"colorPicker": "Selector de color",
"canvasBackground": "Rèireplan del canabàs",
"drawingCanvas": "Zòna de dessenh",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centrar orizontalament",
"distributeHorizontally": "Distribuir orizontalament",
"distributeVertically": "Distribuir verticalament",
"flipHorizontal": "Virar orizontalament",
"flipVertical": "Virar verticalament",
"viewMode": "Mòde de vista",
"toggleExportColorScheme": "Alternar lesquèma de color dexpòrt",
"share": "Partejar"
@@ -101,8 +99,8 @@
"buttons": {
"clearReset": "Reïnicializar lo canabàs",
"export": "Exportar",
"exportToPng": "Exportar en PNG",
"exportToSvg": "Exportar en SVG",
"exportToPng": "Export en PNG",
"exportToSvg": "Export en SVG",
"copyToClipboard": "Copiar al quichapapièrs",
"copyPngToClipboard": "Copiar PNG al quichapapièrs",
"scale": "Escala",
@@ -230,8 +228,7 @@
"zoomToSelection": "Zoomar la seleccion"
},
"encrypted": {
"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"
"tooltip": "Vòstres dessenhs son chifrats del cap a la fin en consequéncia los servidors dExcalidraw los veiràn pas jamai."
},
"stats": {
"angle": "Angle",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "ਭਵਨ ਨਿਰਮਾਣਕਾਰੀ",
"artist": "ਕਲਾਕਾਰ",
"cartoonist": "ਕਾਰਟੂਨਿਸਟ",
"fileTitle": "",
"fileTitle": "ਫਾਈਲ ਦਾ ਸਿਰਨਾਵਾਂ",
"colorPicker": "ਰੰਗ ਚੋਣਕਾਰ",
"canvasBackground": "ਕੈਨਵਸ ਦਾ ਬੈਕਗਰਾਉਂਡ",
"drawingCanvas": "ਡਰਾਇੰਗ ਕੈਨਵਸ",
@@ -92,8 +92,6 @@
"centerHorizontally": "ਖੜ੍ਹਵੇਂ ਵਿਚਕਾਰ ਕਰੋ",
"distributeHorizontally": "ਖੜ੍ਹਵੇਂ ਇਕਸਾਰ ਵੰਡੋ",
"distributeVertically": "ਲੇਟਵੇਂ ਇਕਸਾਰ ਵੰਡੋ",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "ਦੇਖੋ ਮੋਡ",
"toggleExportColorScheme": "",
"share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "ਚੋਣ ਤੱਕ ਜ਼ੂਮ ਕਰੋ"
},
"encrypted": {
"tooltip": "ਤੁਹਾਡੀ ਡਰਾਇੰਗਾਂ ਸਿਰੇ-ਤੋਂ-ਸਿਰੇ ਤੱਕ ਇਨਕਰਿਪਟ ਕੀਤੀਆਂ ਹੋਈਆਂ ਹਨ, ਇਸ ਲਈ Excalidraw ਦੇ ਸਰਵਰ ਉਹਨਾਂ ਨੂੰ ਕਦੇ ਵੀ ਨਹੀਂ ਦੇਖਣਗੇ।",
"link": ""
"tooltip": "ਤੁਹਾਡੀ ਡਰਾਇੰਗਾਂ ਸਿਰੇ-ਤੋਂ-ਸਿਰੇ ਤੱਕ ਇਨਕਰਿਪਟ ਕੀਤੀਆਂ ਹੋਈਆਂ ਹਨ, ਇਸ ਲਈ Excalidraw ਦੇ ਸਰਵਰ ਉਹਨਾਂ ਨੂੰ ਕਦੇ ਵੀ ਨਹੀਂ ਦੇਖਣਗੇ।"
},
"stats": {
"angle": "ਕੋਣ",
+24 -24
View File
@@ -1,37 +1,37 @@
{
"ar-SA": 85,
"bg-BG": 93,
"ca-ES": 98,
"ar-SA": 83,
"bg-BG": 94,
"ca-ES": 100,
"de-DE": 100,
"el-GR": 98,
"en": 100,
"es-ES": 99,
"fa-IR": 88,
"fi-FI": 99,
"es-ES": 100,
"fa-IR": 90,
"fi-FI": 100,
"fr-FR": 100,
"he-IL": 89,
"hi-IN": 91,
"hu-HU": 81,
"id-ID": 100,
"he-IL": 91,
"hi-IN": 93,
"hu-HU": 83,
"id-ID": 99,
"it-IT": 100,
"ja-JP": 97,
"ja-JP": 96,
"kab-KAB": 99,
"ko-KR": 92,
"my-MM": 76,
"ko-KR": 94,
"my-MM": 77,
"nb-NO": 100,
"nl-NL": 100,
"nn-NO": 83,
"nl-NL": 99,
"nn-NO": 85,
"oc-FR": 100,
"pa-IN": 94,
"pl-PL": 95,
"pa-IN": 95,
"pl-PL": 96,
"pt-BR": 100,
"pt-PT": 95,
"pt-PT": 97,
"ro-RO": 100,
"ru-RU": 98,
"sk-SK": 99,
"ru-RU": 100,
"sk-SK": 100,
"sv-SE": 100,
"tr-TR": 99,
"uk-UA": 99,
"zh-CN": 99,
"zh-TW": 99
"tr-TR": 83,
"uk-UA": 95,
"zh-CN": 100,
"zh-TW": 100
}
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Dokładny",
"artist": "Artystyczny",
"cartoonist": "Rysunkowy",
"fileTitle": "",
"fileTitle": "Tytuł pliku",
"colorPicker": "Paleta kolorów",
"canvasBackground": "Kolor dokumentu",
"drawingCanvas": "Obszar roboczy",
@@ -92,8 +92,6 @@
"centerHorizontally": "Wyśrodkuj w poziomie",
"distributeHorizontally": "Rozłóż poziomo",
"distributeVertically": "Rozłóż pionowo",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "Tryb widoku",
"toggleExportColorScheme": "",
"share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "Przybliż do zaznaczenia"
},
"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.",
"link": ""
"tooltip": "Twoje rysunki są zabezpieczone szyfrowaniem end-to-end, tak więc nawet w Excalidraw nie jesteśmy w stanie zobaczyć tego co tworzysz."
},
"stats": {
"angle": "Kąt",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Arquiteto",
"artist": "Artista",
"cartoonist": "Cartunista",
"fileTitle": "Nome do arquivo",
"fileTitle": "Título do arquivo",
"colorPicker": "Seletor de cores",
"canvasBackground": "Fundo da tela",
"drawingCanvas": "Tela de desenho",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centralizar horizontalmente",
"distributeHorizontally": "Distribuir horizontalmente",
"distributeVertically": "Distribuir verticalmente",
"flipHorizontal": "Inverter horizontalmente",
"flipVertical": "Inverter verticalmente",
"viewMode": "Modo de visualização",
"toggleExportColorScheme": "Alternar esquema de cores de exportação",
"share": "Compartilhar"
@@ -230,8 +228,7 @@
"zoomToSelection": "Ampliar a seleção"
},
"encrypted": {
"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"
"tooltip": "Seus desenhos são criptografados de ponta a ponta, então os servidores do Excalidraw nunca os verão."
},
"stats": {
"angle": "Ângulo",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Arquitecto",
"artist": "Artista",
"cartoonist": "Caricaturista",
"fileTitle": "",
"fileTitle": "Título do ficheiro",
"colorPicker": "Seletor de cores",
"canvasBackground": "Fundo da tela",
"drawingCanvas": "Tela de desenho",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centralizar horizontalmente",
"distributeHorizontally": "Distribuir horizontalmente",
"distributeVertically": "Distribuir verticalmente",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "Modo de visualização",
"toggleExportColorScheme": "Alternar esquema de cores de exportação",
"share": ""
@@ -230,8 +228,7 @@
"zoomToSelection": "Ampliar a seleção"
},
"encrypted": {
"tooltip": "Seus desenhos são criptografados de ponta a ponta, então os servidores do Excalidraw nunca os verão.",
"link": ""
"tooltip": "Seus desenhos são criptografados de ponta a ponta, então os servidores do Excalidraw nunca os verão."
},
"stats": {
"angle": "Ângulo",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Arhitect",
"artist": "Artist",
"cartoonist": "Caricaturist",
"fileTitle": "Nume de fișier",
"fileTitle": "Denumirea fișierului",
"colorPicker": "Selector de culoare",
"canvasBackground": "Fundalul pânzei",
"drawingCanvas": "Pânză pentru desenat",
@@ -92,8 +92,6 @@
"centerHorizontally": "Centrare orizontală",
"distributeHorizontally": "Distribuie orizontal",
"distributeVertically": "Distribuie vertical",
"flipHorizontal": "Răsturnare orizontală",
"flipVertical": "Răsturnare verticală",
"viewMode": "Mod de vizualizare",
"toggleExportColorScheme": "Comutare schemă de culori de export",
"share": "Distribuie"
@@ -230,8 +228,7 @@
"zoomToSelection": "Panoramare la selecție"
},
"encrypted": {
"tooltip": "Desenele tale sunt criptate integral, astfel că serverele Excalidraw nu le vor vedea niciodată.",
"link": "Articol de blog pe criptarea integrală din Excalidraw"
"tooltip": "Desenele tale sunt criptate integral, astfel că serverele Excalidraw nu le vor vedea niciodată."
},
"stats": {
"angle": "Unghi",
+2 -5
View File
@@ -61,7 +61,7 @@
"architect": "Архитектор",
"artist": "Художник",
"cartoonist": "Карикатурист",
"fileTitle": "Имя файла",
"fileTitle": "Название файла",
"colorPicker": "Выбор цвета",
"canvasBackground": "Фон холста",
"drawingCanvas": "Полотно",
@@ -92,8 +92,6 @@
"centerHorizontally": "Центрировать по горизонтали",
"distributeHorizontally": "Распределить по горизонтали",
"distributeVertically": "Распределить по вертикали",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "Вид",
"toggleExportColorScheme": "Экспортировать цветовую схему",
"share": "Поделиться"
@@ -230,8 +228,7 @@
"zoomToSelection": "Увеличить до выделенного"
},
"encrypted": {
"tooltip": "Ваши данные защищены сквозным (End-to-end) шифрованием. Серверы Excalidraw никогда не получат доступ к ним.",
"link": ""
"tooltip": "Ваши данные защищены сквозным (End-to-end) шифрованием. Серверы Excalidraw никогда не получат доступ к ним."
},
"stats": {
"angle": "Угол",

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