Compare commits

..

2 Commits

Author SHA1 Message Date
dwelle 88691b1c3c generate username on portal init instead of collab mount 2021-10-16 16:05:30 +02:00
dwelle 146c510faa feat: generate random username for new users during collab 2021-10-16 15:58:27 +02:00
151 changed files with 1563 additions and 9357 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
-1
View File
@@ -5,4 +5,3 @@ package-lock.json
firebase/
dist/
public/workbox
src/packages/excalidraw/types
+3 -8
View File
@@ -19,23 +19,20 @@
]
},
"dependencies": {
"@dwelle/browser-fs-access": "0.21.1",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.11.10",
"@testing-library/react": "11.2.6",
"@tldraw/vec": "0.0.106",
"@types/jest": "26.0.22",
"@types/pica": "5.1.3",
"@types/react": "17.0.3",
"@types/react-dom": "17.0.3",
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.21.0",
"clsx": "1.1.1",
"fake-indexeddb": "3.1.3",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.0",
"idb-keyval": "5.1.3",
"image-blob-reduce": "3.0.1",
"lodash.throttle": "4.1.1",
"nanoid": "3.1.22",
"open-color": "1.8.0",
@@ -57,11 +54,9 @@
"devDependencies": {
"@excalidraw/eslint-config": "1.0.0",
"@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.2.22",
"@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.1",
"@types/resize-observer-browser": "0.1.5",
"chai": "4.3.4",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.9.0",
@@ -83,7 +78,7 @@
},
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)"
],
"resetMocks": false
},
Binary file not shown.
-30
View File
@@ -11,33 +11,3 @@
src: url("Cascadia.woff2");
font-display: swap;
}
@font-face {
font-family: "REDACTED_REGULAR";
src: url("redacted-regular.woff2");
font-display: swap;
}
@font-face {
font-family: "REDACTED_SCRIPT_BOLD";
src: url("redacted-script-bold.woff2");
font-display: swap;
}
@font-face {
font-family: "REDACTED_SCRIPT_REGULAR";
src: url("redacted-script-regular.woff2");
font-display: swap;
}
@font-face {
font-family: "Scribble";
src: url("scribble-webfont.woff2");
font-display: swap;
}
@font-face {
font-family: "Blokk";
src: url("BLOKKNeue-Regular.woff2");
font-display: swap;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -4
View File
@@ -31,11 +31,9 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
const excalidrawPackageFiles = changedFiles.filter((file) => {
return (
(file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
!filesToIgnoreRegex.test(file)
);
return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
});
if (!excalidrawPackageFiles.length) {
process.exit(0);
}
@@ -48,5 +46,6 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
// update readme
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
publish();
});
+20 -9
View File
@@ -1,11 +1,14 @@
import { getDefaultAppState } from "../appState";
import { ColorPicker } from "../components/ColorPicker";
import { zoomIn, zoomOut } from "../components/icons";
import { trash, zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { THEME, ZOOM_STEP } from "../constants";
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 { CODES, KEYS } from "../keys";
import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll";
@@ -14,9 +17,6 @@ import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement";
import { getDefaultAppState } from "../appState";
import ClearCanvas from "../components/ClearCanvas";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@@ -47,15 +47,13 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
perform: (elements, appState, _, app) => {
app.imageCache.clear();
perform: (elements, appState: AppState) => {
return {
elements: elements.map((element) =>
newElementWith(element, { isDeleted: true }),
),
appState: {
...getDefaultAppState(),
files: {},
theme: appState.theme,
elementLocked: appState.elementLocked,
exportBackground: appState.exportBackground,
@@ -67,8 +65,21 @@ export const actionClearCanvas = register({
commitToHistory: true,
};
},
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={trash}
title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")}
showAriaLabel={useIsMobile()}
onClick={() => {
if (window.confirm(t("alerts.clearReset"))) {
updateData(null);
}
}}
data-testid="clear-canvas-button"
/>
),
});
export const actionZoomIn = register({
+2 -4
View File
@@ -9,8 +9,8 @@ import { t } from "../i18n";
export const actionCopy = register({
name: "copy",
perform: (elements, appState, _, app) => {
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
perform: (elements, appState) => {
copyToClipboard(getNonDeletedElements(elements), appState);
return {
commitToHistory: false,
@@ -50,7 +50,6 @@ export const actionCopyAsSvg = register({
? selectedElements
: getNonDeletedElements(elements),
appState,
app.files,
appState,
);
return {
@@ -89,7 +88,6 @@ export const actionCopyAsPng = register({
? selectedElements
: getNonDeletedElements(elements),
appState,
app.files,
appState,
);
return {
+9 -16
View File
@@ -128,13 +128,13 @@ export const actionChangeExportEmbedScene = register({
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
perform: async (elements, appState, value, app) => {
perform: async (elements, appState, value) => {
const fileHandleExists = !!appState.fileHandle;
try {
const { fileHandle } = isImageFileHandle(appState.fileHandle)
? await resaveAsImageWithScene(elements, appState, app.files)
: await saveAsJSON(elements, appState, app.files);
? await resaveAsImageWithScene(elements, appState)
: await saveAsJSON(elements, appState);
return {
commitToHistory: false,
@@ -170,16 +170,12 @@ export const actionSaveToActiveFile = register({
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
perform: async (elements, appState, value, app) => {
perform: async (elements, appState, value) => {
try {
const { fileHandle } = await saveAsJSON(
elements,
{
...appState,
fileHandle: null,
},
app.files,
);
const { fileHandle } = await saveAsJSON(elements, {
...appState,
fileHandle: null,
});
return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error) {
if (error?.name !== "AbortError") {
@@ -206,17 +202,15 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({
name: "loadScene",
perform: async (elements, appState, _, app) => {
perform: async (elements, appState) => {
try {
const {
elements: loadedElements,
appState: loadedAppState,
files,
} = await loadFromJSON(appState, elements);
return {
elements: loadedElements,
appState: loadedAppState,
files,
commitToHistory: true,
};
} catch (error) {
@@ -226,7 +220,6 @@ export const actionLoadScene = register({
return {
elements,
appState: { ...appState, errorMessage: error.message },
files: app.files,
commitToHistory: false,
};
}
-6
View File
@@ -49,11 +49,6 @@ export const actionFinalize = register({
}
let newElements = elements;
if (appState.pendingImageElement) {
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
}
if (window.document.activeElement instanceof HTMLElement) {
focusContainer();
}
@@ -157,7 +152,6 @@ export const actionFinalize = register({
[multiPointElement.id]: true,
}
: appState.selectedElementIds,
pendingImageElement: null,
},
commitToHistory: appState.elementType === "freedraw",
};
+4 -4
View File
@@ -93,13 +93,13 @@ const flipElements = (
appState: AppState,
flipDirection: "horizontal" | "vertical",
): ExcalidrawElement[] => {
elements.forEach((element) => {
flipElement(element, appState);
for (let i = 0; i < elements.length; i++) {
flipElement(elements[i], appState);
// If vertical flip, rotate an extra 180
if (flipDirection === "vertical") {
rotateElement(element, Math.PI);
rotateElement(elements[i], Math.PI);
}
});
}
return elements;
};
+41 -64
View File
@@ -6,16 +6,15 @@ import {
ArrowheadArrowIcon,
ArrowheadBarIcon,
ArrowheadDotIcon,
ArrowheadTriangleIcon,
ArrowheadNoneIcon,
EdgeRoundIcon,
EdgeSharpIcon,
FillCrossHatchIcon,
FillHachureIcon,
FillSolidIcon,
// FontFamilyCodeIcon,
// FontFamilyHandDrawnIcon,
// FontFamilyNormalIcon,
FontFamilyCodeIcon,
FontFamilyHandDrawnIcon,
FontFamilyNormalIcon,
FontSizeExtraLargeIcon,
FontSizeLargeIcon,
FontSizeMediumIcon,
@@ -34,7 +33,7 @@ import {
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
//FONT_FAMILY,
FONT_FAMILY,
} from "../constants";
import {
getNonDeletedElements,
@@ -48,7 +47,7 @@ import {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
//FontFamilyValues,
FontFamilyValues,
TextAlign,
} from "../element/types";
import { getLanguage, t } from "../i18n";
@@ -60,9 +59,7 @@ import {
getTargetElements,
isSomeElementSelected,
} from "../scene";
import { hasStrokeColor } from "../scene/comparisons";
import { register } from "./register";
import FontsList from "../components/FontList";
const changeProperty = (
elements: readonly ExcalidrawElement[],
@@ -106,13 +103,11 @@ export const actionChangeStrokeColor = register({
perform: (elements, appState, value) => {
return {
...(value.currentItemStrokeColor && {
elements: changeProperty(elements, appState, (el) => {
return hasStrokeColor(el.type)
? newElementWith(el, {
strokeColor: value.currentItemStrokeColor,
})
: el;
}),
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeColor: value.currentItemStrokeColor,
}),
),
}),
appState: {
...appState,
@@ -507,43 +502,41 @@ export const actionChangeFontFamily = register({
};
},
PanelComponent: ({ elements, appState, updateData }) => {
// const options: {
// value: FontFamilyValues;
// text: string;
// icon: JSX.Element;
// }[] = [
// {
// value: FONT_FAMILY.Virgil,
// text: t("labels.handDrawn"),
// icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
// },
// {
// value: FONT_FAMILY.Helvetica,
// text: t("labels.normal"),
// icon: <FontFamilyNormalIcon theme={appState.theme} />,
// },
// {
// value: FONT_FAMILY.Cascadia,
// text: t("labels.code"),
// icon: <FontFamilyCodeIcon theme={appState.theme} />,
// },
// ];
const options: {
value: FontFamilyValues;
text: string;
icon: JSX.Element;
}[] = [
{
value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"),
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
},
{
value: FONT_FAMILY.Helvetica,
text: t("labels.normal"),
icon: <FontFamilyNormalIcon theme={appState.theme} />,
},
{
value: FONT_FAMILY.Cascadia,
text: t("labels.code"),
icon: <FontFamilyCodeIcon theme={appState.theme} />,
},
];
return (
<fieldset>
<legend>{t("labels.fontFamily")}</legend>
<FontsList
onChange={(val) => {
updateData(val);
}}
currentFontFamily={
getFormValue(
elements,
appState,
(element) => isTextElement(element) && element.fontFamily,
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
) || DEFAULT_FONT_FAMILY
}
<ButtonIconSelect<FontFamilyValues | false>
group="font-family"
options={options}
value={getFormValue(
elements,
appState,
(element) => isTextElement(element) && element.fontFamily,
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
@@ -742,14 +735,6 @@ export const actionChangeArrowhead = register({
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
keyBinding: "r",
},
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: (
<ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
),
keyBinding: "t",
},
]}
value={getFormValue<Arrowhead | null>(
elements,
@@ -792,14 +777,6 @@ export const actionChangeArrowhead = register({
keyBinding: "r",
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
},
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: (
<ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} />
),
keyBinding: "t",
},
]}
value={getFormValue<Arrowhead | null>(
elements,
+13 -3
View File
@@ -8,8 +8,18 @@ import {
PanelComponentProps,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { AppProps, AppState } from "../types";
import { MODES } from "../constants";
import Library from "../data/library";
// This is the <App> component, but for now we don't care about anything but its
// `canvas` state.
type App = {
canvas: HTMLCanvasElement | null;
focusContainer: () => void;
props: AppProps;
library: Library;
};
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
@@ -18,13 +28,13 @@ export class ActionManager implements ActionsManagerInterface {
getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
app: AppClassProperties;
app: App;
constructor(
updater: UpdaterFn,
getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
app: AppClassProperties,
app: App,
) {
this.updater = (actionResult) => {
if (actionResult && "then" in actionResult) {
+9 -9
View File
@@ -1,11 +1,7 @@
import React from "react";
import { ExcalidrawElement } from "../element/types";
import {
AppClassProperties,
AppState,
ExcalidrawProps,
BinaryFiles,
} from "../types";
import { AppState, ExcalidrawProps } from "../types";
import Library from "../data/library";
import { ToolButtonSize } from "../components/ToolButton";
/** if false, the action should be prevented */
@@ -16,18 +12,22 @@ export type ActionResult =
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null;
commitToHistory: boolean;
syncHistory?: boolean;
replaceFiles?: boolean;
}
| false;
type AppAPI = {
canvas: HTMLCanvasElement | null;
focusContainer(): void;
library: Library;
};
type ActionFn = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
formData: any,
app: AppClassProperties,
app: AppAPI,
) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void;
+67 -83
View File
@@ -79,7 +79,6 @@ export const getDefaultAppState = (): Omit<
zenModeEnabled: false,
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
viewModeEnabled: false,
pendingImageElement: null,
};
};
@@ -93,87 +92,78 @@ const APP_STATE_STORAGE_CONF = (<
browser: boolean;
/** whether to keep when exporting to file/database */
export: boolean;
/** server (shareLink/collab/...) */
server: boolean;
},
T extends Record<keyof AppState, Values>
>(
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
) => config)({
theme: { browser: true, export: false, server: false },
collaborators: { browser: false, export: false, server: false },
currentChartType: { browser: true, export: false, server: false },
currentItemBackgroundColor: { browser: true, export: false, server: false },
currentItemEndArrowhead: { browser: true, export: false, server: false },
currentItemFillStyle: { browser: true, export: false, server: false },
currentItemFontFamily: { browser: true, export: false, server: false },
currentItemFontSize: { browser: true, export: false, server: false },
currentItemLinearStrokeSharpness: {
browser: true,
export: false,
server: false,
},
currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeSharpness: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
draggingElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false },
elementLocked: { browser: true, export: false, server: false },
elementType: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false },
exportBackground: { browser: true, export: false, server: false },
exportEmbedScene: { browser: true, export: false, server: false },
exportScale: { browser: true, export: false, server: false },
exportWithDarkMode: { browser: true, export: false, server: false },
fileHandle: { browser: false, export: false, server: false },
gridSize: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false },
isLibraryOpen: { browser: false, export: false, server: false },
isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false },
lastPointerDownWith: { browser: true, export: false, server: false },
multiElement: { browser: false, export: false, server: false },
name: { browser: true, export: false, server: false },
offsetLeft: { browser: false, export: false, server: false },
offsetTop: { browser: false, export: false, server: false },
openMenu: { browser: true, export: false, server: false },
openPopup: { browser: false, export: false, server: false },
pasteDialog: { browser: false, export: false, server: false },
previousSelectedElementIds: { browser: true, export: false, server: false },
resizingElement: { browser: false, export: false, server: false },
scrolledOutside: { browser: true, export: false, server: false },
scrollX: { browser: true, export: false, server: false },
scrollY: { browser: true, export: false, server: false },
selectedElementIds: { browser: true, export: false, server: false },
selectedGroupIds: { browser: true, export: false, server: false },
selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
showHelpDialog: { browser: false, export: false, server: false },
showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
toastMessage: { browser: false, export: false, server: false },
viewBackgroundColor: { browser: true, export: true, server: true },
width: { browser: false, export: false, server: false },
zenModeEnabled: { browser: true, export: false, server: false },
zoom: { browser: true, export: false, server: false },
viewModeEnabled: { browser: false, export: false, server: false },
pendingImageElement: { browser: false, export: false, server: false },
theme: { browser: true, export: false },
collaborators: { browser: false, export: false },
currentChartType: { browser: true, export: false },
currentItemBackgroundColor: { browser: true, export: false },
currentItemEndArrowhead: { browser: true, export: false },
currentItemFillStyle: { browser: true, export: false },
currentItemFontFamily: { browser: true, export: false },
currentItemFontSize: { browser: true, export: false },
currentItemLinearStrokeSharpness: { browser: true, export: false },
currentItemOpacity: { browser: true, export: false },
currentItemRoughness: { browser: true, export: false },
currentItemStartArrowhead: { browser: true, export: false },
currentItemStrokeColor: { browser: true, export: false },
currentItemStrokeSharpness: { browser: true, export: false },
currentItemStrokeStyle: { browser: true, export: false },
currentItemStrokeWidth: { browser: true, export: false },
currentItemTextAlign: { browser: true, export: false },
cursorButton: { browser: true, export: false },
draggingElement: { browser: false, export: false },
editingElement: { browser: false, export: false },
editingGroupId: { browser: true, export: false },
editingLinearElement: { browser: false, export: false },
elementLocked: { browser: true, export: false },
elementType: { browser: true, export: false },
errorMessage: { browser: false, export: false },
exportBackground: { browser: true, export: false },
exportEmbedScene: { browser: true, export: false },
exportScale: { browser: true, export: false },
exportWithDarkMode: { browser: true, export: false },
fileHandle: { browser: false, export: false },
gridSize: { browser: true, export: true },
height: { browser: false, export: false },
isBindingEnabled: { browser: false, export: false },
isLibraryOpen: { browser: false, export: false },
isLoading: { browser: false, export: false },
isResizing: { browser: false, export: false },
isRotating: { browser: false, export: false },
lastPointerDownWith: { browser: true, export: false },
multiElement: { browser: false, export: false },
name: { browser: true, export: false },
offsetLeft: { browser: false, export: false },
offsetTop: { browser: false, export: false },
openMenu: { browser: true, export: false },
openPopup: { browser: false, export: false },
pasteDialog: { browser: false, export: false },
previousSelectedElementIds: { browser: true, export: false },
resizingElement: { browser: false, export: false },
scrolledOutside: { browser: true, export: false },
scrollX: { browser: true, export: false },
scrollY: { browser: true, export: false },
selectedElementIds: { browser: true, export: false },
selectedGroupIds: { browser: true, export: false },
selectionElement: { browser: false, export: false },
shouldCacheIgnoreZoom: { browser: true, export: false },
showHelpDialog: { browser: false, export: false },
showStats: { browser: true, export: false },
startBoundElement: { browser: false, export: false },
suggestedBindings: { browser: false, export: false },
toastMessage: { browser: false, export: false },
viewBackgroundColor: { browser: true, export: true },
width: { browser: false, export: false },
zenModeEnabled: { browser: true, export: false },
zoom: { browser: true, export: false },
viewModeEnabled: { browser: false, export: false },
});
const _clearAppStateForStorage = <
ExportType extends "export" | "browser" | "server"
>(
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
appState: Partial<AppState>,
exportType: ExportType,
) => {
@@ -186,10 +176,8 @@ const _clearAppStateForStorage = <
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
const propConfig = APP_STATE_STORAGE_CONF[key];
if (propConfig?.[exportType]) {
const nextValue = appState[key];
// https://github.com/microsoft/TypeScript/issues/31445
(stateForExport as any)[key] = nextValue;
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
stateForExport[key] = appState[key];
}
}
return stateForExport;
@@ -202,7 +190,3 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "export");
};
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "server");
};
+6 -20
View File
@@ -3,22 +3,19 @@ import {
NonDeletedExcalidrawElement,
} from "./element/types";
import { getSelectedElements } from "./scene";
import { AppState, BinaryFiles } from "./types";
import { AppState } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks";
import { EXPORT_DATA_TYPES } from "./constants";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
elements: ExcalidrawElement[];
files: BinaryFiles | undefined;
};
export interface ClipboardData {
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
files?: BinaryFiles;
text?: string;
errorMessage?: string;
}
@@ -40,7 +37,7 @@ export const probablySupportsClipboardBlob =
const clipboardContainsElements = (
contents: any,
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
): contents is { elements: ExcalidrawElement[] } => {
if (
[
EXPORT_DATA_TYPES.excalidraw,
@@ -56,18 +53,10 @@ const clipboardContainsElements = (
export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const selectedElements = getSelectedElements(elements, appState);
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: selectedElements,
files: selectedElements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles),
elements: getSelectedElements(elements, appState),
};
const json = JSON.stringify(contents);
CLIPBOARD = json;
@@ -149,10 +138,7 @@ export const parseClipboard = async (
try {
const systemClipboardData = JSON.parse(systemClipboard);
if (clipboardContainsElements(systemClipboardData)) {
return {
elements: systemClipboardData.elements,
files: systemClipboardData.files,
};
return { elements: systemClipboardData.elements };
}
return appClipboardData;
} catch {
@@ -167,7 +153,7 @@ export const parseClipboard = async (
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
await navigator.clipboard.write([
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
new window.ClipboardItem({ "image/png": blob }),
]);
};
+8 -26
View File
@@ -1,7 +1,7 @@
import React from "react";
import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import {
@@ -18,7 +18,6 @@ import { AppState, Zoom } from "../types";
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
export const SelectedShapeActions = ({
appState,
@@ -49,22 +48,9 @@ export const SelectedShapeActions = ({
hasBackground(elementType) ||
targetElements.some((element) => hasBackground(element.type));
let commonSelectedType: string | null = targetElements[0]?.type || null;
for (const element of targetElements) {
if (element.type !== commonSelectedType) {
commonSelectedType = null;
break;
}
}
return (
<div className="panelColumn">
{((hasStrokeColor(elementType) &&
elementType !== "image" &&
commonSelectedType !== "image") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")}
{renderAction("changeStrokeColor")}
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")}
@@ -169,20 +155,18 @@ export const ShapesSwitcher = ({
canvas,
elementType,
setAppState,
onImageAction,
}: {
canvas: HTMLCanvasElement | null;
elementType: ExcalidrawElement["type"];
setAppState: React.Component<any, AppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
}) => (
<>
{SHAPES.map(({ value, icon, key }, index) => {
const label = t(`toolBar.${value}`);
const letter = key && (typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
: `${index + 1}`;
const letter = typeof key === "string" ? key : key[0];
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
index + 1
}`;
return (
<ToolButton
className="Shape"
@@ -196,16 +180,14 @@ export const ShapesSwitcher = ({
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={value}
onChange={({ pointerType }) => {
onChange={() => {
setAppState({
elementType: value,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(canvas, value);
if (value === "image") {
onImageAction({ pointerType });
}
setAppState({});
}}
/>
);
+57 -678
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -48,10 +48,6 @@
.ToolIcon__label {
color: $oc-white;
}
.Spinner {
--spinner-color: #fff;
}
}
}
}
-42
View File
@@ -1,42 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.clear-canvas {
&-buttons {
display: flex;
padding: 0.2rem 0;
justify-content: flex-end;
.ToolIcon__icon {
min-width: 2.5rem;
width: auto;
font-size: 1rem;
}
.ToolIcon_type_button {
margin-left: 1.5rem;
padding: 0 0.5rem;
}
}
&__content {
font-size: 1rem;
}
&--confirm.ToolIcon_type_button {
background-color: $oc-red-6;
&:hover {
background-color: $oc-red-8;
}
.ToolIcon__icon {
color: $oc-white;
}
}
&--cancel.ToolIcon_type_button {
background-color: $oc-gray-2;
}
}
}
-67
View File
@@ -1,67 +0,0 @@
import { useState } from "react";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { Dialog } from "./Dialog";
import { trash } from "./icons";
import { ToolButton } from "./ToolButton";
import "./ClearCanvas.scss";
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
const [showDialog, setShowDialog] = useState(false);
const toggleDialog = () => {
setShowDialog(!showDialog);
};
return (
<>
<ToolButton
type="button"
icon={trash}
title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")}
showAriaLabel={useIsMobile()}
onClick={toggleDialog}
data-testid="clear-canvas-button"
/>
{showDialog && (
<Dialog
onCloseRequest={toggleDialog}
title={t("clearCanvasDialog.title")}
className="clear-canvas"
small={true}
>
<>
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
<div className="clear-canvas-buttons">
<ToolButton
type="button"
title={t("buttons.clear")}
aria-label={t("buttons.clear")}
label={t("buttons.clear")}
onClick={() => {
onConfirm();
toggleDialog();
}}
data-testid="confirm-clear-canvas-button"
className="clear-canvas--confirm"
/>
<ToolButton
type="button"
title={t("buttons.cancel")}
aria-label={t("buttons.cancel")}
label={t("buttons.cancel")}
onClick={toggleDialog}
data-testid="cancel-clear-canvas-button"
className="clear-canvas--cancel"
/>
</div>
</>
</Dialog>
)}
</>
);
};
export default ClearCanvas;
-53
View File
@@ -1,53 +0,0 @@
import { FONT_FAMILY } from "../constants";
import { FontFamilyValues } from "../element/types";
const FontsList = ({
onChange,
currentFontFamily,
}: {
onChange: (val: FontFamilyValues) => void;
currentFontFamily: FontFamilyValues;
}) => {
return (
<select
className="dropdown-select"
onChange={(event) => {
onChange(Number(event.target.value));
}}
value={currentFontFamily}
>
<option key="virgil" value={FONT_FAMILY.Virgil}>
Hand-Drawn
</option>
<option key="helvetica" value={FONT_FAMILY.Helvetica}>
Normal
</option>
<option key="cascadia" value={FONT_FAMILY.Cascadia}>
code
</option>
<option key="redacted-regular" value={FONT_FAMILY.REDACTED_REGULAR}>
Redacted Regular
</option>
<option
key="redacted-script-regular"
value={FONT_FAMILY.REDACTED_SCRIPT_REGULAR}
>
Redacted Script
</option>
<option
key="redacted-script-bold"
value={FONT_FAMILY.REDACTED_SCRIPT_BOLD}
>
Redacted Script BOLD
</option>
<option key="Scribble" value={FONT_FAMILY.SCRIBBLE}>
Scribble
</option>
<option key="Blokk" value={FONT_FAMILY.BLOKK}>
Blokk
</option>
</select>
);
};
export default FontsList;
-2
View File
@@ -157,8 +157,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={["Shift+P", "7"]}
/>
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
<Shortcut
label={t("helpDialog.editSelectedShape")}
shortcuts={[
+2 -12
View File
@@ -4,11 +4,7 @@ import { getSelectedElements } from "../scene";
import "./HintViewer.scss";
import { AppState } from "../types";
import {
isImageElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
import { isLinearElement, isTextElement } from "../element/typeChecks";
import { getShortcutKey } from "../utils";
interface Hint {
@@ -34,10 +30,6 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.text");
}
if (appState.elementType === "image" && appState.pendingImageElement) {
return t("hints.placeImage");
}
const selectedElements = getSelectedElements(elements, appState);
if (
isResizing &&
@@ -48,9 +40,7 @@ const getHints = ({ appState, elements }: Hint) => {
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
return t("hints.lockAngle");
}
return isImageElement(targetElement)
? t("hints.resizeImage")
: t("hints.resize");
return t("hints.resize");
}
if (isRotating && lastPointerDownWith === "mouse") {
+1 -1
View File
@@ -90,7 +90,7 @@
.picker-content {
padding: 0.5rem;
display: grid;
grid-template-columns: repeat(3, auto);
grid-auto-flow: column;
grid-gap: 0.5rem;
border-radius: 4px;
:root[dir="rtl"] & {
+21 -22
View File
@@ -9,7 +9,7 @@ import { t } from "../i18n";
import { useIsMobile } from "./App";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
import { AppState, BinaryFiles } from "../types";
import { AppState } from "../types";
import { Dialog } from "./Dialog";
import { clipboard, exportImage } from "./icons";
import Stack from "./Stack";
@@ -79,7 +79,6 @@ const ExportButton: React.FC<{
const ImageExportModal = ({
elements,
appState,
files,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager,
onExportToPng,
@@ -88,7 +87,6 @@ const ImageExportModal = ({
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number;
actionManager: ActionsManagerInterface;
onExportToPng: ExportCB;
@@ -114,25 +112,29 @@ const ImageExportModal = ({
if (!previewNode) {
return;
}
exportToCanvas(exportedElements, appState, files, {
exportBackground,
viewBackgroundColor,
exportPadding,
})
.then((canvas) => {
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
return canvasToBlob(canvas).then(() => {
renderPreview(canvas, previewNode);
});
})
.catch((error) => {
console.error(error);
renderPreview(new CanvasError(), previewNode);
try {
const canvas = exportToCanvas(exportedElements, appState, {
exportBackground,
viewBackgroundColor,
exportPadding,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
canvasToBlob(canvas)
.then(() => {
renderPreview(canvas, previewNode);
})
.catch((error) => {
console.error(error);
renderPreview(new CanvasError(), previewNode);
});
} catch (error) {
console.error(error);
renderPreview(new CanvasError(), previewNode);
}
}, [
appState,
files,
exportedElements,
exportBackground,
exportPadding,
@@ -218,7 +220,6 @@ const ImageExportModal = ({
export const ImageExportDialog = ({
elements,
appState,
files,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager,
onExportToPng,
@@ -227,7 +228,6 @@ export const ImageExportDialog = ({
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number;
actionManager: ActionsManagerInterface;
onExportToPng: ExportCB;
@@ -258,7 +258,6 @@ export const ImageExportDialog = ({
<ImageExportModal
elements={elements}
appState={appState}
files={files}
exportPadding={exportPadding}
actionManager={actionManager}
onExportToPng={onExportToPng}
+4 -11
View File
@@ -3,7 +3,7 @@ import { ActionsManagerInterface } from "../actions/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { AppState, ExportOpts, BinaryFiles } from "../types";
import { AppState, ExportOpts } from "../types";
import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons";
import { ToolButton } from "./ToolButton";
@@ -21,13 +21,11 @@ export type ExportCB = (
const JSONExportModal = ({
elements,
appState,
files,
actionManager,
exportOpts,
canvas,
}: {
appState: AppState;
files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
onCloseRequest: () => void;
@@ -70,14 +68,12 @@ const JSONExportModal = ({
title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")}
showAriaLabel={true}
onClick={() =>
onExportToBackend(elements, appState, files, canvas)
}
onClick={() => onExportToBackend(elements, appState, canvas)}
/>
</Card>
)}
{exportOpts.renderCustomUI &&
exportOpts.renderCustomUI(elements, appState, files, canvas)}
exportOpts.renderCustomUI(elements, appState, canvas)}
</div>
</div>
);
@@ -86,14 +82,12 @@ const JSONExportModal = ({
export const JSONExportDialog = ({
elements,
appState,
files,
actionManager,
exportOpts,
canvas,
}: {
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
@@ -122,7 +116,6 @@ export const JSONExportDialog = ({
<JSONExportModal
elements={elements}
appState={appState}
files={files}
actionManager={actionManager}
onCloseRequest={handleClose}
exportOpts={exportOpts}
+8 -46
View File
@@ -20,7 +20,6 @@ import {
AppProps,
AppState,
ExcalidrawProps,
BinaryFiles,
LibraryItem,
LibraryItems,
} from "../types";
@@ -54,7 +53,6 @@ import { isImageFileHandle } from "../data/blob";
interface LayerUIProps {
actionManager: ActionManager;
appState: AppState;
files: BinaryFiles;
canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
@@ -67,10 +65,7 @@ interface LayerUIProps {
toggleZenMode: () => void;
langCode: Language["code"];
isCollaborating: boolean;
renderTopRightUI?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
@@ -78,7 +73,6 @@ interface LayerUIProps {
focusContainer: () => void;
library: Library;
id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
}
const useOnClickOutside = (
@@ -121,7 +115,6 @@ const LibraryMenuItems = ({
libraryReturnUrl,
focusContainer,
library,
files,
id,
}: {
libraryItems: LibraryItems;
@@ -130,7 +123,6 @@ const LibraryMenuItems = ({
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: (elements: LibraryItem) => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
setLibraryItems: (library: LibraryItems) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
@@ -226,7 +218,6 @@ const LibraryMenuItems = ({
<Stack.Col key={x}>
<LibraryUnit
elements={libraryItems[y + x]}
files={files}
pendingElements={
shouldAddPendingElements ? pendingElements : undefined
}
@@ -261,7 +252,6 @@ const LibraryMenu = ({
onAddToLibrary,
theme,
setAppState,
files,
libraryReturnUrl,
focusContainer,
library,
@@ -272,7 +262,6 @@ const LibraryMenu = ({
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: () => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
@@ -294,12 +283,12 @@ const LibraryMenu = ({
"preloading" | "loading" | "ready"
>("preloading");
const loadingTimerRef = useRef<number | null>(null);
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
Promise.race([
new Promise((resolve) => {
loadingTimerRef.current = window.setTimeout(() => {
loadingTimerRef.current = setTimeout(() => {
resolve("loading");
}, 100);
}),
@@ -332,12 +321,6 @@ const LibraryMenu = ({
const addToLibrary = useCallback(
async (elements: LibraryItem) => {
if (elements.some((element) => element.type === "image")) {
return setAppState({
errorMessage: "Support for adding images to the library coming soon!",
});
}
const items = await library.loadLibrary();
const nextItems = [...items, elements];
onAddToLibrary();
@@ -369,7 +352,6 @@ const LibraryMenu = ({
focusContainer={focusContainer}
library={library}
theme={theme}
files={files}
id={id}
/>
)}
@@ -380,7 +362,6 @@ const LibraryMenu = ({
const LayerUI = ({
actionManager,
appState,
files,
setAppState,
canvas,
elements,
@@ -400,7 +381,6 @@ const LayerUI = ({
focusContainer,
library,
id,
onImageAction,
}: LayerUIProps) => {
const isMobile = useIsMobile();
@@ -413,7 +393,6 @@ const LayerUI = ({
<JSONExportDialog
elements={elements}
appState={appState}
files={files}
actionManager={actionManager}
exportOpts={UIOptions.canvasActions.export}
canvas={canvas}
@@ -429,17 +408,11 @@ const LayerUI = ({
const createExporter = (type: ExportType): ExportCB => async (
exportedElements,
) => {
const fileHandle = await exportCanvas(
type,
exportedElements,
appState,
files,
{
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
},
)
const fileHandle = await exportCanvas(type, exportedElements, appState, {
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
})
.catch(muteFSAbortError)
.catch((error) => {
console.error(error);
@@ -459,7 +432,6 @@ const LayerUI = ({
<ImageExportDialog
elements={elements}
appState={appState}
files={files}
actionManager={actionManager}
onExportToPng={createExporter("png")}
onExportToSvg={createExporter("svg")}
@@ -493,7 +465,6 @@ const LayerUI = ({
</Section>
);
};
const renderCanvasActions = () => (
<Section
heading="canvasActions"
@@ -586,7 +557,6 @@ const LayerUI = ({
focusContainer={focusContainer}
library={library}
theme={appState.theme}
files={files}
id={id}
/>
) : null;
@@ -631,11 +601,6 @@ const LayerUI = ({
canvas={canvas}
elementType={appState.elementType}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
/>
</Stack.Row>
</Island>
@@ -725,7 +690,6 @@ const LayerUI = ({
>
{renderCustomFooter?.(false, appState)}
</div>
<div
className={clsx(
"layer-ui__wrapper__footer-right zen-mode-transition",
@@ -797,8 +761,6 @@ const LayerUI = ({
renderCustomFooter={renderCustomFooter}
viewModeEnabled={viewModeEnabled}
showThemeBtn={showThemeBtn}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
/>
</>
) : (
+2 -2
View File
@@ -26,7 +26,7 @@ export const LibraryButton: React.FC<{
"zen-mode-visibility--hidden": appState.zenModeEnabled,
},
)}
title={`${capitalizeString(t("toolBar.library"))}0`}
title={`${capitalizeString(t("toolBar.library"))}9`}
style={{ marginInlineStart: "var(--space-factor)" }}
>
<input
@@ -38,7 +38,7 @@ export const LibraryButton: React.FC<{
}}
checked={appState.isLibraryOpen}
aria-label={capitalizeString(t("toolBar.library"))}
aria-keyshortcuts="0"
aria-keyshortcuts="9"
/>
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
</label>
+19 -19
View File
@@ -6,7 +6,7 @@ import { MIME_TYPES } from "../constants";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import { exportToSvg } from "../scene/export";
import { BinaryFiles, LibraryItem } from "../types";
import { LibraryItem } from "../types";
import "./LibraryUnit.scss";
// fa-plus
@@ -21,44 +21,44 @@ const PLUS_ICON = (
export const LibraryUnit = ({
elements,
files,
pendingElements,
onRemoveFromLibrary,
onClick,
}: {
elements?: LibraryItem;
files: BinaryFiles;
pendingElements?: LibraryItem;
onRemoveFromLibrary: () => void;
onClick: () => void;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const node = ref.current;
if (!node) {
const elementsToRender = elements || pendingElements;
if (!elementsToRender) {
return;
}
let svg: SVGSVGElement;
const current = ref.current!;
(async () => {
const elementsToRender = elements || pendingElements;
if (!elementsToRender) {
return;
svg = await exportToSvg(elementsToRender, {
exportBackground: false,
viewBackgroundColor: oc.white,
});
for (const child of ref.current!.children) {
if (child.tagName !== "svg") {
continue;
}
current!.removeChild(child);
}
const svg = await exportToSvg(
elementsToRender,
{
exportBackground: false,
viewBackgroundColor: oc.white,
},
files,
);
node.innerHTML = svg.outerHTML;
current!.appendChild(svg);
})();
return () => {
node.innerHTML = "";
if (svg) {
current.removeChild(svg);
}
};
}, [elements, pendingElements, files]);
}, [elements, pendingElements]);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useIsMobile();
-13
View File
@@ -33,11 +33,6 @@ type MobileMenuProps = {
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
viewModeEnabled: boolean;
showThemeBtn: boolean;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
};
export const MobileMenu = ({
@@ -55,8 +50,6 @@ export const MobileMenu = ({
renderCustomFooter,
viewModeEnabled,
showThemeBtn,
onImageAction,
renderTopRightUI,
}: MobileMenuProps) => {
const renderToolbar = () => {
return (
@@ -72,15 +65,9 @@ export const MobileMenu = ({
canvas={canvas}
elementType={appState.elementType}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
/>
</Stack.Row>
</Island>
{renderTopRightUI && renderTopRightUI(true, appState)}
<LockButton
checked={appState.elementLocked}
onChange={onLockToggle}
+4 -8
View File
@@ -38,14 +38,10 @@ const ChartPreviewBtn = (props: {
const previewNode = previewRef.current!;
(async () => {
svg = await exportToSvg(
elements,
{
exportBackground: false,
viewBackgroundColor: oc.white,
},
null, // files
);
svg = await exportToSvg(elements, {
exportBackground: false,
viewBackgroundColor: oc.white,
});
previewNode.appendChild(svg);
-48
View File
@@ -1,48 +0,0 @@
@import "open-color/open-color.scss";
$duration: 1.6s;
.excalidraw {
.Spinner {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
margin-left: auto;
margin-right: auto;
--spinner-color: var(--icon-fill-color);
svg {
animation: rotate $duration linear infinite;
transform-origin: center center;
}
circle {
stroke: var(--spinner-color);
animation: dash $duration linear 0s infinite;
stroke-linecap: round;
}
}
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes dash {
0% {
stroke-dasharray: 1, 300;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 150, 300;
stroke-dashoffset: -200;
}
100% {
stroke-dasharray: 1, 300;
stroke-dashoffset: -280;
}
}
}
-28
View File
@@ -1,28 +0,0 @@
import React from "react";
import "./Spinner.scss";
const Spinner = ({
size = "1em",
circleWidth = 8,
}: {
size?: string | number;
circleWidth?: number;
}) => {
return (
<div className="Spinner">
<svg viewBox="0 0 100 100" style={{ width: size, height: size }}>
<circle
cx="50"
cy="50"
r={50 - circleWidth / 2}
strokeWidth={circleWidth}
fill="none"
strokeMiterlimit="10"
/>
</svg>
</div>
);
};
export default Spinner;
+7 -58
View File
@@ -1,11 +1,8 @@
import "./ToolIcon.scss";
import React, { useEffect, useRef, useState } from "react";
import React from "react";
import clsx from "clsx";
import { useExcalidrawContainer } from "./App";
import { AbortError } from "../errors";
import Spinner from "./Spinner";
import { PointerType } from "../element/types";
export type ToolButtonSize = "small" | "medium";
@@ -31,7 +28,7 @@ type ToolButtonProps =
| (ToolButtonBaseProps & {
type: "button";
children?: React.ReactNode;
onClick?(event: React.MouseEvent): void;
onClick?(): void;
})
| (ToolButtonBaseProps & {
type: "icon";
@@ -41,7 +38,7 @@ type ToolButtonProps =
| (ToolButtonBaseProps & {
type: "radio";
checked: boolean;
onChange?(data: { pointerType: PointerType | null }): void;
onChange?(): void;
});
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
@@ -50,38 +47,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${props.size}`;
const [isLoading, setIsLoading] = useState(false);
const isMountedRef = useRef(true);
const onClick = async (event: React.MouseEvent) => {
const ret = "onClick" in props && props.onClick?.(event);
if (ret && "then" in ret) {
try {
setIsLoading(true);
await ret;
} catch (error) {
if (!(error instanceof AbortError)) {
throw error;
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}
};
useEffect(
() => () => {
isMountedRef.current = false;
},
[],
);
const lastPointerTypeRef = useRef<PointerType | null>(null);
if (props.type === "button" || props.type === "icon") {
return (
<button
@@ -103,9 +68,8 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
title={props.title}
aria-label={props["aria-label"]}
type="button"
onClick={onClick}
onClick={props.onClick}
ref={innerRef}
disabled={isLoading}
>
{(props.icon || props.label) && (
<div className="ToolIcon__icon" aria-hidden="true">
@@ -118,9 +82,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
</div>
)}
{props.showAriaLabel && (
<div className="ToolIcon__label">
{props["aria-label"]} {isLoading && <Spinner />}
</div>
<div className="ToolIcon__label">{props["aria-label"]}</div>
)}
{props.children}
</button>
@@ -128,18 +90,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
}
return (
<label
className={clsx("ToolIcon", props.className)}
title={props.title}
onPointerDown={(event) => {
lastPointerTypeRef.current = event.pointerType || null;
}}
onPointerUp={() => {
requestAnimationFrame(() => {
lastPointerTypeRef.current = null;
});
}}
>
<label className={clsx("ToolIcon", props.className)} title={props.title}>
<input
className={`ToolIcon_type_radio ${sizeCn}`}
type="radio"
@@ -148,9 +99,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
aria-keyshortcuts={props["aria-keyshortcuts"]}
data-testid={props["data-testid"]}
id={`${excalId}-${props.id}`}
onChange={() => {
props.onChange?.({ pointerType: lastPointerTypeRef.current });
}}
onChange={props.onChange}
checked={props.checked}
ref={innerRef}
/>
-6
View File
@@ -54,16 +54,10 @@
}
.ToolIcon__label {
display: flex;
align-items: center;
color: var(--icon-fill-color);
font-family: var(--ui-font);
margin: 0 0.8em;
text-overflow: ellipsis;
.Spinner {
margin-left: 0.6em;
}
}
.ToolIcon_size_small .ToolIcon__icon {
-27
View File
@@ -752,21 +752,6 @@ export const ArrowheadBarIcon = React.memo(
),
);
export const ArrowheadTriangleIcon = React.memo(
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
createIcon(
<g
stroke={iconFillColor(theme)}
fill={iconFillColor(theme)}
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
>
<path d="M32 10L6 10" strokeWidth={2} />
<path d="M27.5 5.5L34.5 10L27.5 14.5L27.5 5.5" />
</g>,
{ width: 40, height: 20 },
),
);
export const FontSizeSmallIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
@@ -847,18 +832,6 @@ export const FontFamilyCodeIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const FontFamilyWireframeIcon = React.memo(
({ theme }: { theme: Theme }) =>
createIcon(
<>
<path
fill={iconFillColor(theme)}
d="M448 75.2v361.7c0 24.3-19 43.2-43.2 43.2H43.2C19.3 480 0 461.4 0 436.8V75.2C0 51.1 18.8 32 43.2 32h361.7c24 0 43.1 18.8 43.1 43.2zm-37.3 361.6V75.2c0-3-2.6-5.8-5.8-5.8h-9.3L285.3 144 224 94.1 162.8 144 52.5 69.3h-9.3c-3.2 0-5.8 2.8-5.8 5.8v361.7c0 3 2.6 5.8 5.8 5.8h361.7c3.2.1 5.8-2.7 5.8-5.8zM150.2 186v37H76.7v-37h73.5zm0 74.4v37.3H76.7v-37.3h73.5zm11.1-147.3l54-43.7H96.8l64.5 43.7zm210 72.9v37h-196v-37h196zm0 74.4v37.3h-196v-37.3h196zm-84.6-147.3l64.5-43.7H232.8l53.9 43.7zM371.3 335v37.3h-99.4V335h99.4z"
></path>
</>,
),
);
export const TextAlignLeftIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
-25
View File
@@ -68,11 +68,6 @@ export const FONT_FAMILY = {
Virgil: 1,
Helvetica: 2,
Cascadia: 3,
REDACTED_REGULAR: 4,
REDACTED_SCRIPT_REGULAR: 5,
REDACTED_SCRIPT_BOLD: 6,
SCRIBBLE: 7,
BLOKK: 8,
};
export const THEME = {
@@ -95,12 +90,6 @@ export const GRID_SIZE = 20; // TODO make it configurable?
export const MIME_TYPES = {
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
json: "application/json",
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
gif: "image/gif",
binary: "application/octet-stream",
} as const;
export const EXPORT_DATA_TYPES = {
@@ -116,7 +105,6 @@ export const STORAGE_KEYS = {
} as const;
// time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000;
@@ -166,16 +154,3 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const ALLOWED_IMAGE_MIME_TYPES = [
MIME_TYPES.png,
MIME_TYPES.jpg,
MIME_TYPES.svg,
MIME_TYPES.gif,
] as const;
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg";
+13 -130
View File
@@ -1,16 +1,11 @@
import { nanoid } from "nanoid";
import { cleanAppStateForExport } from "../appState";
import {
ALLOWED_IMAGE_MIME_TYPES,
EXPORT_DATA_TYPES,
MIME_TYPES,
} from "../constants";
import { EXPORT_DATA_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types";
import { ExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { AppState, DataURL } from "../types";
import { AppState } from "../types";
import { FileSystemHandle } from "./filesystem";
import { isValidExcalidrawData } from "./json";
import { restore } from "./restore";
@@ -19,22 +14,16 @@ import { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File) => {
let contents: string;
if (blob.type === MIME_TYPES.png) {
if (blob.type === "image/png") {
try {
return await (
await import(/* webpackChunkName: "image" */ "./image")
).decodePngMetadata(blob);
} catch (error) {
if (error.message === "INVALID") {
throw new DOMException(
t("alerts.imageDoesNotContainScene"),
"EncodingError",
);
throw new Error(t("alerts.imageDoesNotContainScene"));
} else {
throw new DOMException(
t("alerts.cannotRestoreFromImage"),
"EncodingError",
);
throw new Error(t("alerts.cannotRestoreFromImage"));
}
}
} else {
@@ -51,7 +40,7 @@ const parseFileContents = async (blob: Blob | File) => {
};
});
}
if (blob.type === MIME_TYPES.svg) {
if (blob.type === "image/svg+xml") {
try {
return await (
await import(/* webpackChunkName: "image" */ "./image")
@@ -60,15 +49,9 @@ const parseFileContents = async (blob: Blob | File) => {
});
} catch (error) {
if (error.message === "INVALID") {
throw new DOMException(
t("alerts.imageDoesNotContainScene"),
"EncodingError",
);
throw new Error(t("alerts.imageDoesNotContainScene"));
} else {
throw new DOMException(
t("alerts.cannotRestoreFromImage"),
"EncodingError",
);
throw new Error(t("alerts.cannotRestoreFromImage"));
}
}
}
@@ -87,13 +70,13 @@ export const getMimeType = (blob: Blob | string): string => {
name = blob.name || "";
}
if (/\.(excalidraw|json)$/.test(name)) {
return MIME_TYPES.json;
return "application/json";
} else if (/\.png$/.test(name)) {
return MIME_TYPES.png;
return "image/png";
} else if (/\.jpe?g$/.test(name)) {
return MIME_TYPES.jpg;
return "image/jpeg";
} else if (/\.svg$/.test(name)) {
return MIME_TYPES.svg;
return "image/svg+xml";
}
return "";
};
@@ -117,15 +100,6 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
return type === "png" || type === "svg";
};
export const isSupportedImageFile = (
blob: Blob | null | undefined,
): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => {
const { type } = blob || {};
return (
!!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
);
};
export const loadFromBlob = async (
blob: Blob,
/** @see restore.localAppState */
@@ -149,7 +123,6 @@ export const loadFromBlob = async (
? calculateScrollCenter(data.elements || [], localAppState, null)
: {}),
},
files: data.files,
},
localAppState,
localElements,
@@ -192,93 +165,3 @@ export const canvasToBlob = async (
}
});
};
/** generates SHA-1 digest from supplied file (if not supported, falls back
to a 40-char base64 random id) */
export const generateIdFromFile = async (file: File) => {
let id: FileId;
try {
const hashBuffer = await window.crypto.subtle.digest(
"SHA-1",
await file.arrayBuffer(),
);
id =
// convert buffer to byte array
Array.from(new Uint8Array(hashBuffer))
// convert to hex string
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("") as FileId;
} catch (error) {
console.error(error);
// length 40 to align with the HEX length of SHA-1 (which is 160 bit)
id = nanoid(40) as FileId;
}
return id;
};
export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const dataURL = reader.result as DataURL;
resolve(dataURL);
};
reader.onerror = (error) => reject(error);
reader.readAsDataURL(file);
});
};
export const dataURLToFile = (dataURL: DataURL, filename = "") => {
const dataIndexStart = dataURL.indexOf(",");
const byteString = atob(dataURL.slice(dataIndexStart + 1));
const mimeType = dataURL.slice(0, dataIndexStart).split(":")[1].split(";")[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new File([ab], filename, { type: mimeType });
};
export const resizeImageFile = async (
file: File,
maxWidthOrHeight: number,
): Promise<File> => {
// SVG files shouldn't a can't be resized
if (file.type === MIME_TYPES.svg) {
return file;
}
const [pica, imageBlobReduce] = await Promise.all([
import("pica").then((res) => res.default),
// a wrapper for pica for better API
import("image-blob-reduce").then((res) => res.default),
]);
// CRA's minification settings break pica in WebWorkers, so let's disable
// them for now
// https://github.com/nodeca/image-blob-reduce/issues/21#issuecomment-757365513
const reduce = imageBlobReduce({
pica: pica({ features: ["js", "wasm"] }),
});
const fileType = file.type;
if (!isSupportedImageFile(file)) {
throw new Error(t("errors.unsupportedFileType"));
}
return new File(
[await reduce.toBlob(file, { max: maxWidthOrHeight })],
file.name,
{ type: fileType },
);
};
export const SVGStringToFile = (SVGString: string, filename: string = "") => {
return new File([new TextEncoder().encode(SVGString)], filename, {
type: MIME_TYPES.svg,
}) as File & { type: typeof MIME_TYPES.svg };
};
+4 -267
View File
@@ -1,19 +1,16 @@
import { deflate, inflate } from "pako";
import { encryptData, decryptData } from "./encryption";
// -----------------------------------------------------------------------------
// byte (binary) strings
// -----------------------------------------------------------------------------
// fast, Buffer-compatible implem
export const toByteString = (
data: string | Uint8Array | ArrayBuffer,
): Promise<string> => {
export const toByteString = (data: string | Uint8Array): Promise<string> => {
return new Promise((resolve, reject) => {
const blob =
typeof data === "string"
? new Blob([new TextEncoder().encode(data)])
: new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
: new Blob([data]);
const reader = new FileReader();
reader.onload = (event) => {
if (!event.target || typeof event.target.result !== "string") {
@@ -47,14 +44,12 @@ const byteStringToString = (byteString: string) => {
* due to reencoding
*/
export const stringToBase64 = async (str: string, isByteString = false) => {
return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
return isByteString ? btoa(str) : btoa(await toByteString(str));
};
// async to align with stringToBase64
export const base64ToString = async (base64: string, isByteString = false) => {
return isByteString
? window.atob(base64)
: byteStringToString(window.atob(base64));
return isByteString ? atob(base64) : byteStringToString(atob(base64));
};
// -----------------------------------------------------------------------------
@@ -119,261 +114,3 @@ export const decode = async (data: EncodedData): Promise<string> => {
return decoded;
};
// -----------------------------------------------------------------------------
// binary encoding
// -----------------------------------------------------------------------------
type FileEncodingInfo = {
/* version 2 is the version we're shipping the initial image support with.
version 1 was a PR version that a lot of people were using anyway.
Thus, if there are issues we can check whether they're not using the
unoffic version */
version: 1 | 2;
compression: "pako@1" | null;
encryption: "AES-GCM" | null;
};
// -----------------------------------------------------------------------------
const CONCAT_BUFFERS_VERSION = 1;
/** how many bytes we use to encode how many bytes the next chunk has.
* Corresponds to DataView setter methods (setUint32, setUint16, etc).
*
* NOTE ! values must not be changed, which would be backwards incompatible !
*/
const VERSION_DATAVIEW_BYTES = 4;
const NEXT_CHUNK_SIZE_DATAVIEW_BYTES = 4;
// -----------------------------------------------------------------------------
const DATA_VIEW_BITS_MAP = { 1: 8, 2: 16, 4: 32 } as const;
// getter
function dataView(buffer: Uint8Array, bytes: 1 | 2 | 4, offset: number): number;
// setter
function dataView(
buffer: Uint8Array,
bytes: 1 | 2 | 4,
offset: number,
value: number,
): Uint8Array;
/**
* abstraction over DataView that serves as a typed getter/setter in case
* you're using constants for the byte size and want to ensure there's no
* discrepenancy in the encoding across refactors.
*
* DataView serves for an endian-agnostic handling of numbers in ArrayBuffers.
*/
function dataView(
buffer: Uint8Array,
bytes: 1 | 2 | 4,
offset: number,
value?: number,
): Uint8Array | number {
if (value != null) {
if (value > Math.pow(2, DATA_VIEW_BITS_MAP[bytes]) - 1) {
throw new Error(
`attempting to set value higher than the allocated bytes (value: ${value}, bytes: ${bytes})`,
);
}
const method = `setUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
new DataView(buffer.buffer)[method](offset, value);
return buffer;
}
const method = `getUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
return new DataView(buffer.buffer)[method](offset);
}
// -----------------------------------------------------------------------------
/**
* Resulting concatenated buffer has this format:
*
* [
* VERSION chunk (4 bytes)
* LENGTH chunk 1 (4 bytes)
* DATA chunk 1 (up to 2^32 bits)
* LENGTH chunk 2 (4 bytes)
* DATA chunk 2 (up to 2^32 bits)
* ...
* ]
*
* @param buffers each buffer (chunk) must be at most 2^32 bits large (~4GB)
*/
const concatBuffers = (...buffers: Uint8Array[]) => {
const bufferView = new Uint8Array(
VERSION_DATAVIEW_BYTES +
NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length +
buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0),
);
let cursor = 0;
// as the first chunk we'll encode the version for backwards compatibility
dataView(bufferView, VERSION_DATAVIEW_BYTES, cursor, CONCAT_BUFFERS_VERSION);
cursor += VERSION_DATAVIEW_BYTES;
for (const buffer of buffers) {
dataView(
bufferView,
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
cursor,
buffer.byteLength,
);
cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
bufferView.set(buffer, cursor);
cursor += buffer.byteLength;
}
return bufferView;
};
/** can only be used on buffers created via `concatBuffers()` */
const splitBuffers = (concatenatedBuffer: Uint8Array) => {
const buffers = [];
let cursor = 0;
// first chunk is the version (ignored for now)
cursor += VERSION_DATAVIEW_BYTES;
while (true) {
const chunkSize = dataView(
concatenatedBuffer,
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
cursor,
);
cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
buffers.push(concatenatedBuffer.slice(cursor, cursor + chunkSize));
cursor += chunkSize;
if (cursor >= concatenatedBuffer.byteLength) {
break;
}
}
return buffers;
};
// helpers for (de)compressing data with JSON metadata including encryption
// -----------------------------------------------------------------------------
/** @private */
const _encryptAndCompress = async (
data: Uint8Array | string,
encryptionKey: string,
) => {
const { encryptedBuffer, iv } = await encryptData(
encryptionKey,
deflate(data),
);
return { iv, buffer: new Uint8Array(encryptedBuffer) };
};
/**
* The returned buffer has following format:
* `[]` refers to a buffers wrapper (see `concatBuffers`)
*
* [
* encodingMetadataBuffer,
* iv,
* [
* contentsMetadataBuffer
* contentsBuffer
* ]
* ]
*/
export const compressData = async <T extends Record<string, any> = never>(
dataBuffer: Uint8Array,
options: {
encryptionKey: string;
} & ([T] extends [never]
? {
metadata?: T;
}
: {
metadata: T;
}),
): Promise<Uint8Array> => {
const fileInfo: FileEncodingInfo = {
version: 2,
compression: "pako@1",
encryption: "AES-GCM",
};
const encodingMetadataBuffer = new TextEncoder().encode(
JSON.stringify(fileInfo),
);
const contentsMetadataBuffer = new TextEncoder().encode(
JSON.stringify(options.metadata || null),
);
const { iv, buffer } = await _encryptAndCompress(
concatBuffers(contentsMetadataBuffer, dataBuffer),
options.encryptionKey,
);
return concatBuffers(encodingMetadataBuffer, iv, buffer);
};
/** @private */
const _decryptAndDecompress = async (
iv: Uint8Array,
decryptedBuffer: Uint8Array,
decryptionKey: string,
isCompressed: boolean,
) => {
decryptedBuffer = new Uint8Array(
await decryptData(iv, decryptedBuffer, decryptionKey),
);
if (isCompressed) {
return inflate(decryptedBuffer);
}
return decryptedBuffer;
};
export const decompressData = async <T extends Record<string, any>>(
bufferView: Uint8Array,
options: { decryptionKey: string },
) => {
// first chunk is encoding metadata (ignored for now)
const [encodingMetadataBuffer, iv, buffer] = splitBuffers(bufferView);
const encodingMetadata: FileEncodingInfo = JSON.parse(
new TextDecoder().decode(encodingMetadataBuffer),
);
try {
const [contentsMetadataBuffer, contentsBuffer] = splitBuffers(
await _decryptAndDecompress(
iv,
buffer,
options.decryptionKey,
!!encodingMetadata.compression,
),
);
const metadata = JSON.parse(
new TextDecoder().decode(contentsMetadataBuffer),
) as T;
return {
/** metadata source is always JSON so we can decode it here */
metadata,
/** data can be anything so the caller must decode it */
data: contentsBuffer,
};
} catch (error) {
console.error(
`Error during decompressing and decrypting the file.`,
encodingMetadata,
);
throw error;
}
};
// -----------------------------------------------------------------------------
-79
View File
@@ -1,79 +0,0 @@
export const IV_LENGTH_BYTES = 12;
export const createIV = () => {
const arr = new Uint8Array(IV_LENGTH_BYTES);
return window.crypto.getRandomValues(arr);
};
export const generateEncryptionKey = async () => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 128,
},
true, // extractable
["encrypt", "decrypt"],
);
return (await window.crypto.subtle.exportKey("jwk", key)).k;
};
export const getImportedKey = (key: string, usage: KeyUsage) =>
window.crypto.subtle.importKey(
"jwk",
{
alg: "A128GCM",
ext: true,
k: key,
key_ops: ["encrypt", "decrypt"],
kty: "oct",
},
{
name: "AES-GCM",
length: 128,
},
false, // extractable
[usage],
);
export const encryptData = async (
key: string,
data: Uint8Array | ArrayBuffer | Blob | File | string,
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
const importedKey = await getImportedKey(key, "encrypt");
const iv = createIV();
const buffer: ArrayBuffer | Uint8Array =
typeof data === "string"
? new TextEncoder().encode(data)
: data instanceof Uint8Array
? data
: data instanceof Blob
? await data.arrayBuffer()
: data;
const encryptedBuffer = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
buffer as ArrayBuffer | Uint8Array,
);
return { encryptedBuffer, iv };
};
export const decryptData = async (
iv: Uint8Array,
encrypted: Uint8Array | ArrayBuffer,
privateKey: string,
): Promise<ArrayBuffer> => {
const key = await getImportedKey(privateKey, "decrypt");
return window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
key,
encrypted,
);
};
+11 -3
View File
@@ -4,13 +4,12 @@ import {
fileSave as _fileSave,
FileSystemHandle,
supported as nativeFileSystemSupported,
} from "browser-fs-access";
} from "@dwelle/browser-fs-access";
import { EVENT, MIME_TYPES } from "../constants";
import { AbortError } from "../errors";
import { debounce } from "../utils";
type FILE_EXTENSION =
| "gif"
| "jpg"
| "png"
| "svg"
@@ -18,6 +17,15 @@ type FILE_EXTENSION =
| "excalidraw"
| "excalidrawlib";
const FILE_TYPE_TO_MIME_TYPE: Record<FILE_EXTENSION, string> = {
jpg: "image/jpeg",
png: "image/png",
svg: "image/svg+xml",
json: "application/json",
excalidraw: MIME_TYPES.excalidraw,
excalidrawlib: MIME_TYPES.excalidrawlib,
};
const INPUT_CHANGE_INTERVAL_MS = 500;
export const fileOpen = <M extends boolean | undefined = false>(opts: {
@@ -33,7 +41,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
: FileWithHandle[];
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
mimeTypes.push(MIME_TYPES[type]);
mimeTypes.push(FILE_TYPE_TO_MIME_TYPE[type]);
return mimeTypes;
}, [] as string[]);
+1 -1
View File
@@ -57,7 +57,7 @@ export const encodePngMetadata = async ({
// insert metadata before last chunk (iEND)
chunks.splice(-1, 0, metadataChunk);
return new Blob([encodePng(chunks)], { type: MIME_TYPES.png });
return new Blob([encodePng(chunks)], { type: "image/png" });
};
export const decodePngMetadata = async (blob: Blob) => {
+13 -18
View File
@@ -2,12 +2,12 @@ import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
} from "../clipboard";
import { DEFAULT_EXPORT_PADDING, MIME_TYPES } from "../constants";
import { DEFAULT_EXPORT_PADDING } from "../constants";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { exportToCanvas, exportToSvg } from "../scene/export";
import { ExportType } from "../scene/types";
import { AppState, BinaryFiles } from "../types";
import { AppState } from "../types";
import { canvasToBlob } from "./blob";
import { fileSave, FileSystemHandle } from "./filesystem";
import { serializeAsJSON } from "./json";
@@ -19,7 +19,6 @@ export const exportCanvas = async (
type: ExportType,
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
{
exportBackground,
exportPadding = DEFAULT_EXPORT_PADDING,
@@ -38,21 +37,17 @@ export const exportCanvas = async (
throw new Error(t("alerts.cannotExportEmptyCanvas"));
}
if (type === "svg" || type === "clipboard-svg") {
const tempSvg = await exportToSvg(
elements,
{
exportBackground,
exportWithDarkMode: appState.exportWithDarkMode,
viewBackgroundColor,
exportPadding,
exportScale: appState.exportScale,
exportEmbedScene: appState.exportEmbedScene && type === "svg",
},
files,
);
const tempSvg = await exportToSvg(elements, {
exportBackground,
exportWithDarkMode: appState.exportWithDarkMode,
viewBackgroundColor,
exportPadding,
exportScale: appState.exportScale,
exportEmbedScene: appState.exportEmbedScene && type === "svg",
});
if (type === "svg") {
return await fileSave(
new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }),
new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }),
{
name,
extension: "svg",
@@ -65,7 +60,7 @@ export const exportCanvas = async (
}
}
const tempCanvas = await exportToCanvas(elements, appState, files, {
const tempCanvas = exportToCanvas(elements, appState, {
exportBackground,
viewBackgroundColor,
exportPadding,
@@ -81,7 +76,7 @@ export const exportCanvas = async (
await import(/* webpackChunkName: "image" */ "./image")
).encodePngMetadata({
blob,
metadata: serializeAsJSON(elements, appState, files, "local"),
metadata: serializeAsJSON(elements, appState),
});
}
+15 -42
View File
@@ -1,9 +1,9 @@
import { fileOpen, fileSave } from "./filesystem";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
import { cleanAppStateForExport } from "../appState";
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
import { clearElementsForDatabase, clearElementsForExport } from "../element";
import { clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles } from "../types";
import { AppState } from "../types";
import { isImageFileHandle, loadFromBlob } from "./blob";
import {
@@ -13,50 +13,16 @@ import {
} from "./types";
import Library from "./library";
/**
* Strips out files which are only referenced by deleted elements
*/
const filterOutDeletedFiles = (
elements: readonly ExcalidrawElement[],
files: BinaryFiles,
) => {
const nextFiles: BinaryFiles = {};
for (const element of elements) {
if (
!element.isDeleted &&
"fileId" in element &&
element.fileId &&
files[element.fileId]
) {
nextFiles[element.fileId] = files[element.fileId];
}
}
return nextFiles;
};
export const serializeAsJSON = (
elements: readonly ExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
type: "local" | "database",
): string => {
const data: ExportedDataState = {
type: EXPORT_DATA_TYPES.excalidraw,
version: 2,
source: EXPORT_SOURCE,
elements:
type === "local"
? clearElementsForExport(elements)
: clearElementsForDatabase(elements),
appState:
type === "local"
? cleanAppStateForExport(appState)
: clearAppStateForDatabase(appState),
files:
type === "local"
? filterOutDeletedFiles(elements, files)
: // will be stripped from JSON
undefined,
elements: clearElementsForExport(elements),
appState: cleanAppStateForExport(appState),
};
return JSON.stringify(data, null, 2);
@@ -65,9 +31,8 @@ export const serializeAsJSON = (
export const saveAsJSON = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const serialized = serializeAsJSON(elements, appState, files, "local");
const serialized = serializeAsJSON(elements, appState);
const blob = new Blob([serialized], {
type: MIME_TYPES.excalidraw,
});
@@ -91,7 +56,15 @@ export const loadFromJSON = async (
description: "Excalidraw files",
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
// extensions: ["json", "excalidraw", "png", "svg"],
/*
extensions: [".json", ".excalidraw", ".png", ".svg"],
mimeTypes: [
MIME_TYPES.excalidraw,
"application/json",
"image/png",
"image/svg+xml",
],
*/
});
return loadFromBlob(blob, localAppState, localElements);
};
+1 -3
View File
@@ -1,5 +1,5 @@
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles } from "../types";
import { AppState } from "../types";
import { exportCanvas } from ".";
import { getNonDeletedElements } from "../element";
import { getFileHandleType, isImageFileHandleType } from "./blob";
@@ -7,7 +7,6 @@ import { getFileHandleType, isImageFileHandleType } from "./blob";
export const resaveAsImageWithScene = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const { exportBackground, viewBackgroundColor, name, fileHandle } = appState;
@@ -27,7 +26,6 @@ export const resaveAsImageWithScene = async (
fileHandleType,
getNonDeletedElements(elements),
appState,
files,
{
exportBackground,
viewBackgroundColor,
+11 -23
View File
@@ -3,7 +3,7 @@ import {
ExcalidrawSelectionElement,
FontFamilyValues,
} from "../element/types";
import { AppState, BinaryFiles, NormalizedZoomValue } from "../types";
import { AppState, NormalizedZoomValue } from "../types";
import { ImportedDataState } from "./types";
import {
getElementMap,
@@ -37,7 +37,6 @@ export const AllowedExcalidrawElementTypes: Record<
diamond: true,
ellipse: true,
line: true,
image: true,
arrow: true,
freedraw: true,
};
@@ -45,7 +44,6 @@ export const AllowedExcalidrawElementTypes: Record<
export type RestoredDataState = {
elements: ExcalidrawElement[];
appState: RestoredAppState;
files: BinaryFiles;
};
const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
@@ -59,19 +57,16 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
const restoreElementWithProperties = <
T extends ExcalidrawElement,
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>
K extends keyof Omit<
Required<T>,
Exclude<keyof ExcalidrawElement, "type" | "x" | "y">
>
>(
element: Required<T>,
extra: Pick<
T,
// This extra Pick<T, keyof K> ensure no excess properties are passed.
// @ts-ignore TS complains here but type checks the call sites fine.
keyof K
> &
Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
extra: Pick<T, K>,
): T => {
const base: Pick<T, keyof ExcalidrawElement> = {
type: extra.type || element.type,
type: (extra as Partial<T>).type || element.type,
// all elements must have version > 0 so getSceneVersion() will pick up
// newly added elements
version: element.version || 1,
@@ -84,8 +79,8 @@ const restoreElementWithProperties = <
roughness: element.roughness ?? 1,
opacity: element.opacity == null ? 100 : element.opacity,
angle: element.angle || 0,
x: extra.x ?? element.x ?? 0,
y: extra.y ?? element.y ?? 0,
x: (extra as Partial<T>).x ?? element.x ?? 0,
y: (extra as Partial<T>).y ?? element.y ?? 0,
strokeColor: element.strokeColor,
backgroundColor: element.backgroundColor,
width: element.width || 0,
@@ -107,7 +102,7 @@ const restoreElementWithProperties = <
const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
): typeof element | null => {
): typeof element => {
switch (element.type) {
case "text":
let fontSize = element.fontSize;
@@ -136,12 +131,6 @@ const restoreElement = (
pressures: element.pressures,
});
}
case "image":
return restoreElementWithProperties(element, {
status: element.status || "pending",
fileId: element.fileId,
scale: element.scale || [1, 1],
});
case "line":
// @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough
@@ -205,7 +194,7 @@ export const restoreElements = (
// filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
let migratedElement: ExcalidrawElement | null = restoreElement(element);
let migratedElement: ExcalidrawElement = restoreElement(element);
if (migratedElement) {
const localElement = localElementsMap?.[element.id];
if (localElement && localElement.version > migratedElement.version) {
@@ -271,6 +260,5 @@ export const restore = (
return {
elements: restoreElements(data?.elements, localElements),
appState: restoreAppState(data?.appState, localAppState || null),
files: data?.files || {},
};
};
+1 -3
View File
@@ -1,5 +1,5 @@
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles, LibraryItems } from "../types";
import { AppState, LibraryItems } from "../types";
import type { cleanAppStateForExport } from "../appState";
export interface ExportedDataState {
@@ -8,7 +8,6 @@ export interface ExportedDataState {
source: string;
elements: readonly ExcalidrawElement[];
appState: ReturnType<typeof cleanAppStateForExport>;
files: BinaryFiles | undefined;
}
export interface ImportedDataState {
@@ -19,7 +18,6 @@ export interface ImportedDataState {
appState?: Readonly<Partial<AppState>> | null;
scrollToContent?: boolean;
libraryItems?: LibraryItems;
files?: BinaryFiles;
}
export interface ExportedLibraryData {
-2
View File
@@ -258,7 +258,6 @@ export const getArrowheadPoints = (
arrow: 30,
bar: 15,
dot: 15,
triangle: 15,
}[arrowhead]; // pixels (will differ for each arrowhead)
let length = 0;
@@ -295,7 +294,6 @@ export const getArrowheadPoints = (
const angle = {
arrow: 20,
bar: 90,
triangle: 25,
}[arrowhead]; // degrees
// Return points
+3 -13
View File
@@ -23,7 +23,6 @@ import {
ExcalidrawEllipseElement,
NonDeleted,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
} from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@@ -31,7 +30,6 @@ import { Point } from "../types";
import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement";
import { isImageElement } from "./typeChecks";
const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement,
@@ -49,7 +47,8 @@ const isElementDraggableFromInside = (
if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points);
}
return isDraggableFromInside || isImageElement(element);
return isDraggableFromInside;
};
export const hitTest = (
@@ -162,7 +161,6 @@ type HitTestArgs = {
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
switch (args.element.type) {
case "rectangle":
case "image":
case "text":
case "diamond":
case "ellipse":
@@ -197,7 +195,6 @@ export const distanceToBindableElement = (
): number => {
switch (element.type) {
case "rectangle":
case "image":
case "text":
return distanceToRectangle(element, point);
case "diamond":
@@ -227,8 +224,7 @@ const distanceToRectangle = (
element:
| ExcalidrawRectangleElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement,
| ExcalidrawFreeDrawElement,
point: Point,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@@ -490,7 +486,6 @@ export const determineFocusDistance = (
const nabs = Math.abs(n);
switch (element.type) {
case "rectangle":
case "image":
case "text":
return c / (hwidth * (nabs + q * mabs));
case "diamond":
@@ -521,7 +516,6 @@ export const determineFocusPoint = (
let point;
switch (element.type) {
case "rectangle":
case "image":
case "text":
case "diamond":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
@@ -571,7 +565,6 @@ const getSortedElementLineIntersections = (
let intersections: GA.Point[];
switch (element.type) {
case "rectangle":
case "image":
case "text":
case "diamond":
const corners = getCorners(element);
@@ -605,7 +598,6 @@ const getSortedElementLineIntersections = (
const getCorners = (
element:
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement,
scale: number = 1,
@@ -614,7 +606,6 @@ const getCorners = (
const hy = (scale * element.height) / 2;
switch (element.type) {
case "rectangle":
case "image":
case "text":
return [
GA.point(hx, hy),
@@ -756,7 +747,6 @@ export const findFocusPointForEllipse = (
export const findFocusPointForRectangulars = (
element:
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement,
// Between -1 and 1 for how far away should the focus point be relative
+11 -18
View File
@@ -62,32 +62,25 @@ export const dragNewElement = (
y: number,
width: number,
height: number,
shouldMaintainAspectRatio: boolean,
shouldResizeFromCenter: boolean,
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */
widthAspectRatio?: number | null,
isResizeWithSidesSameLength: boolean,
isResizeCenterPoint: boolean,
) => {
if (shouldMaintainAspectRatio) {
if (widthAspectRatio) {
height = width / widthAspectRatio;
} else {
({ width, height } = getPerfectElementSize(
elementType,
width,
y < originY ? -height : height,
));
if (isResizeWithSidesSameLength) {
({ width, height } = getPerfectElementSize(
elementType,
width,
y < originY ? -height : height,
));
if (height < 0) {
height = -height;
}
if (height < 0) {
height = -height;
}
}
let newX = x < originX ? originX - width : originX;
let newY = y < originY ? originY - height : originY;
if (shouldResizeFromCenter) {
if (isResizeCenterPoint) {
width += width;
height += height;
newX = originX - width / 2;
-111
View File
@@ -1,111 +0,0 @@
// -----------------------------------------------------------------------------
// ExcalidrawImageElement & related helpers
// -----------------------------------------------------------------------------
import { MIME_TYPES, SVG_NS } from "../constants";
import { t } from "../i18n";
import { AppClassProperties, DataURL, BinaryFiles } from "../types";
import { isInitializedImageElement } from "./typeChecks";
import {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
} from "./types";
export const loadHTMLImageElement = (dataURL: DataURL) => {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => {
resolve(image);
};
image.onerror = (error) => {
reject(error);
};
image.src = dataURL;
});
};
/** NOTE: updates cache even if already populated with given image. Thus,
* you should filter out the images upstream if you want to optimize this. */
export const updateImageCache = async ({
fileIds,
files,
imageCache,
}: {
fileIds: FileId[];
files: BinaryFiles;
imageCache: AppClassProperties["imageCache"];
}) => {
const updatedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
await Promise.all(
fileIds.reduce((promises, fileId) => {
const fileData = files[fileId as string];
if (fileData && !updatedFiles.has(fileId)) {
updatedFiles.set(fileId, true);
return promises.concat(
(async () => {
try {
if (fileData.mimeType === MIME_TYPES.binary) {
throw new Error("Only images can be added to ImageCache");
}
const imagePromise = loadHTMLImageElement(fileData.dataURL);
const data = {
image: imagePromise,
mimeType: fileData.mimeType,
} as const;
// store the promise immediately to indicate there's an in-progress
// initialization
imageCache.set(fileId, data);
const image = await imagePromise;
imageCache.set(fileId, { ...data, image });
} catch (error) {
erroredFiles.set(fileId, true);
}
})(),
);
}
return promises;
}, [] as Promise<any>[]),
);
return {
imageCache,
/** includes errored files because they cache was updated nonetheless */
updatedFiles,
/** files that failed when creating HTMLImageElement */
erroredFiles,
};
};
export const getInitializedImageElements = (
elements: readonly ExcalidrawElement[],
) =>
elements.filter((element) =>
isInitializedImageElement(element),
) as InitializedExcalidrawImageElement[];
export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
// lower-casing due to XML/HTML convention differences
// https://johnresig.com/blog/nodename-case-sensitivity
return node?.nodeName.toLowerCase() === "svg";
};
export const normalizeSVG = async (SVGString: string) => {
const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
const svg = doc.querySelector("svg");
const errorNode = doc.querySelector("parsererror");
if (errorNode || !isHTMLSVGElement(svg)) {
throw new Error(t("errors.invalidSVGString"));
} else {
if (!svg.hasAttribute("xmlns")) {
svg.setAttribute("xmlns", SVG_NS);
}
return svg.outerHTML;
}
};
-5
View File
@@ -11,7 +11,6 @@ export {
newTextElement,
updateTextElement,
newLinearElement,
newImageElement,
duplicateElement,
} from "./newElement";
export {
@@ -94,10 +93,6 @@ const _clearElements = (
: element,
);
export const clearElementsForDatabase = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
export const clearElementsForExport = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
+10 -26
View File
@@ -17,13 +17,12 @@ type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
informMutation = true,
): TElement => {
) => {
let didChange = false;
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fileId } = updates as any;
const { points } = updates as any;
if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(points), ...updates };
@@ -34,23 +33,13 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update because its attrs could have changed
// (except for specific keys we handle below)
(typeof value !== "object" ||
value === null ||
key === "groupIds" ||
key === "scale")
// if object, always update in case its deep prop was mutated
(typeof value !== "object" || value === null || key === "groupIds")
) {
continue;
}
if (key === "scale") {
const prevScale = (element as any)[key];
const nextScale = value;
if (prevScale[0] === nextScale[0] && prevScale[1] === nextScale[1]) {
continue;
}
} else if (key === "points") {
if (key === "points") {
const prevPoints = (element as any)[key];
const nextPoints = value;
if (prevPoints.length === nextPoints.length) {
@@ -77,14 +66,14 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
didChange = true;
}
}
if (!didChange) {
return element;
return;
}
if (
typeof updates.height !== "undefined" ||
typeof updates.width !== "undefined" ||
typeof fileId != "undefined" ||
typeof points !== "undefined"
) {
invalidateShapeForElement(element);
@@ -92,12 +81,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element.version++;
element.versionNonce = randomInteger();
if (informMutation) {
Scene.getScene(element)?.informMutation();
}
return element;
Scene.getScene(element)?.informMutation();
};
export const newElementWith = <TElement extends ExcalidrawElement>(
@@ -110,8 +94,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update because its attrs could have changed
(typeof value !== "object" || value === null)
// if object, always update in case its deep prop was mutated
(typeof value !== "object" || value === null || key === "groupIds")
) {
continue;
}
-17
View File
@@ -1,6 +1,5 @@
import {
ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawTextElement,
ExcalidrawLinearElement,
ExcalidrawGenericElement,
@@ -249,22 +248,6 @@ export const newLinearElement = (
};
};
export const newImageElement = (
opts: {
type: ExcalidrawImageElement["type"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawImageElement> => {
return {
..._newElementBase<ExcalidrawImageElement>("image", opts),
// in the future we'll support changing stroke color for some SVG elements,
// and `transparent` will likely mean "use original colors of the image"
strokeColor: "transparent",
status: "pending",
fileId: null,
scale: [1, 1],
};
};
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
//
+28 -40
View File
@@ -47,9 +47,9 @@ export const transformElements = (
transformHandleType: MaybeTransformHandleType,
selectedElements: readonly NonDeletedExcalidrawElement[],
resizeArrowDirection: "origin" | "end",
shouldRotateWithDiscreteAngle: boolean,
shouldResizeFromCenter: boolean,
shouldMaintainAspectRatio: boolean,
isRotateWithDiscreteAngle: boolean,
isResizeCenterPoint: boolean,
shouldKeepSidesRatio: boolean,
pointerX: number,
pointerY: number,
centerX: number,
@@ -62,7 +62,7 @@ export const transformElements = (
element,
pointerX,
pointerY,
shouldRotateWithDiscreteAngle,
isRotateWithDiscreteAngle,
);
updateBoundElements(element);
} else if (
@@ -76,7 +76,7 @@ export const transformElements = (
reshapeSingleTwoPointElement(
element,
resizeArrowDirection,
shouldRotateWithDiscreteAngle,
isRotateWithDiscreteAngle,
pointerX,
pointerY,
);
@@ -90,7 +90,7 @@ export const transformElements = (
resizeSingleTextElement(
element,
transformHandleType,
shouldResizeFromCenter,
isResizeCenterPoint,
pointerX,
pointerY,
);
@@ -98,10 +98,10 @@ export const transformElements = (
} else if (transformHandleType) {
resizeSingleElement(
pointerDownState.originalElements.get(element.id) as typeof element,
shouldMaintainAspectRatio,
shouldKeepSidesRatio,
element,
transformHandleType,
shouldResizeFromCenter,
isResizeCenterPoint,
pointerX,
pointerY,
);
@@ -115,7 +115,7 @@ export const transformElements = (
selectedElements,
pointerX,
pointerY,
shouldRotateWithDiscreteAngle,
isRotateWithDiscreteAngle,
centerX,
centerY,
);
@@ -142,13 +142,13 @@ const rotateSingleElement = (
element: NonDeletedExcalidrawElement,
pointerX: number,
pointerY: number,
shouldRotateWithDiscreteAngle: boolean,
isRotateWithDiscreteAngle: boolean,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
if (shouldRotateWithDiscreteAngle) {
if (isRotateWithDiscreteAngle) {
angle += SHIFT_LOCKING_ANGLE / 2;
angle -= angle % SHIFT_LOCKING_ANGLE;
}
@@ -187,7 +187,7 @@ const getPerfectElementSizeWithRotation = (
export const reshapeSingleTwoPointElement = (
element: NonDeleted<ExcalidrawLinearElement>,
resizeArrowDirection: "origin" | "end",
shouldRotateWithDiscreteAngle: boolean,
isRotateWithDiscreteAngle: boolean,
pointerX: number,
pointerY: number,
) => {
@@ -212,7 +212,7 @@ export const reshapeSingleTwoPointElement = (
element.x + element.points[1][0] - rotatedX,
element.y + element.points[1][1] - rotatedY,
];
if (shouldRotateWithDiscreteAngle) {
if (isRotateWithDiscreteAngle) {
[width, height] = getPerfectElementSizeWithRotation(
element.type,
width,
@@ -281,28 +281,28 @@ const measureFontSizeFromWH = (
const getSidesForTransformHandle = (
transformHandleType: TransformHandleType,
shouldResizeFromCenter: boolean,
isResizeFromCenter: boolean,
) => {
return {
n:
/^(n|ne|nw)$/.test(transformHandleType) ||
(shouldResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
(isResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
s:
/^(s|se|sw)$/.test(transformHandleType) ||
(shouldResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
(isResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
w:
/^(w|nw|sw)$/.test(transformHandleType) ||
(shouldResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
(isResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
e:
/^(e|ne|se)$/.test(transformHandleType) ||
(shouldResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
(isResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
};
};
const resizeSingleTextElement = (
element: NonDeleted<ExcalidrawTextElement>,
transformHandleType: "nw" | "ne" | "sw" | "se",
shouldResizeFromCenter: boolean,
isResizeFromCenter: boolean,
pointerX: number,
pointerY: number,
) => {
@@ -361,7 +361,7 @@ const resizeSingleTextElement = (
const deltaX2 = (x2 - nextX2) / 2;
const deltaY2 = (y2 - nextY2) / 2;
const [nextElementX, nextElementY] = adjustXYWithRotation(
getSidesForTransformHandle(transformHandleType, shouldResizeFromCenter),
getSidesForTransformHandle(transformHandleType, isResizeFromCenter),
element.x,
element.y,
element.angle,
@@ -383,10 +383,10 @@ const resizeSingleTextElement = (
export const resizeSingleElement = (
stateAtResizeStart: NonDeletedExcalidrawElement,
shouldMaintainAspectRatio: boolean,
shouldKeepSidesRatio: boolean,
element: NonDeletedExcalidrawElement,
transformHandleDirection: TransformHandleDirection,
shouldResizeFromCenter: boolean,
isResizeFromCenter: boolean,
pointerX: number,
pointerY: number,
) => {
@@ -444,13 +444,13 @@ export const resizeSingleElement = (
let eleNewHeight = element.height * scaleY;
// adjust dimensions for resizing from center
if (shouldResizeFromCenter) {
if (isResizeFromCenter) {
eleNewWidth = 2 * eleNewWidth - eleInitialWidth;
eleNewHeight = 2 * eleNewHeight - eleInitialHeight;
}
// adjust dimensions to keep sides ratio
if (shouldMaintainAspectRatio) {
if (shouldKeepSidesRatio) {
const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth;
const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight;
if (transformHandleDirection.length === 1) {
@@ -495,7 +495,7 @@ export const resizeSingleElement = (
}
// Keeps opposite handle fixed during resize
if (shouldMaintainAspectRatio) {
if (shouldKeepSidesRatio) {
if (["s", "n"].includes(transformHandleDirection)) {
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
}
@@ -523,7 +523,7 @@ export const resizeSingleElement = (
}
}
if (shouldResizeFromCenter) {
if (isResizeFromCenter) {
newTopLeft[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2;
newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2;
}
@@ -558,18 +558,6 @@ export const resizeSingleElement = (
...rescaledPoints,
};
if ("scale" in element && "scale" in stateAtResizeStart) {
mutateElement(element, {
scale: [
// defaulting because scaleX/Y can be 0/-0
(Math.sign(scaleX) || stateAtResizeStart.scale[0]) *
stateAtResizeStart.scale[0],
(Math.sign(scaleY) || stateAtResizeStart.scale[1]) *
stateAtResizeStart.scale[1],
],
});
}
if (
resizedElement.width !== 0 &&
resizedElement.height !== 0 &&
@@ -704,13 +692,13 @@ const rotateMultipleElements = (
elements: readonly NonDeletedExcalidrawElement[],
pointerX: number,
pointerY: number,
shouldRotateWithDiscreteAngle: boolean,
isRotateWithDiscreteAngle: boolean,
centerX: number,
centerY: number,
) => {
let centerAngle =
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
if (shouldRotateWithDiscreteAngle) {
if (isRotateWithDiscreteAngle) {
centerAngle += SHIFT_LOCKING_ANGLE / 2;
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
}
+1 -3
View File
@@ -319,9 +319,7 @@ export const textWysiwyg = ({
// prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => {
if (event.target instanceof HTMLSelectElement) {
handleSubmit();
} else if (
if (
(event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
-14
View File
@@ -5,8 +5,6 @@ import {
ExcalidrawBindableElement,
ExcalidrawGenericElement,
ExcalidrawFreeDrawElement,
InitializedExcalidrawImageElement,
ExcalidrawImageElement,
} from "./types";
export const isGenericElement = (
@@ -21,18 +19,6 @@ export const isGenericElement = (
);
};
export const isInitializedImageElement = (
element: ExcalidrawElement | null,
): element is InitializedExcalidrawImageElement => {
return !!element && element.type === "image" && !!element.fileId;
};
export const isImageElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawImageElement => {
return !!element && element.type === "image";
};
export const isTextElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawTextElement => {
+4 -23
View File
@@ -63,21 +63,6 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
type: "ellipse";
};
export type ExcalidrawImageElement = _ExcalidrawElementBase &
Readonly<{
type: "image";
fileId: FileId | null;
/** whether respective file is persisted */
status: "pending" | "saved" | "error";
/** X and Y scale factors <-1, 1>, used for image axis flipping */
scale: [number, number];
}>;
export type InitializedExcalidrawImageElement = MarkNonNullable<
ExcalidrawImageElement,
"fileId"
>;
/**
* These are elements that don't have any additional properties.
*/
@@ -96,11 +81,10 @@ export type ExcalidrawElement =
| ExcalidrawGenericElement
| ExcalidrawTextElement
| ExcalidrawLinearElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement;
| ExcalidrawFreeDrawElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: boolean;
isDeleted: false;
};
export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
@@ -120,8 +104,7 @@ export type ExcalidrawBindableElement =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawTextElement
| ExcalidrawImageElement;
| ExcalidrawTextElement;
export type PointBinding = {
elementId: ExcalidrawBindableElement["id"];
@@ -129,7 +112,7 @@ export type PointBinding = {
gap: number;
};
export type Arrowhead = "arrow" | "bar" | "dot" | "triangle";
export type Arrowhead = "arrow" | "bar" | "dot";
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
@@ -150,5 +133,3 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
simulatePressure: boolean;
lastCommittedPoint: Point | null;
}>;
export type FileId = string & { _brand: "FileId" };
-11
View File
@@ -1,14 +1,8 @@
// time constants (ms)
export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
export const FILE_UPLOAD_TIMEOUT = 300;
export const LOAD_IMAGES_TIMEOUT = 500;
export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
export const BROADCAST = {
SERVER_VOLATILE: "server-volatile-broadcast",
SERVER: "server-broadcast",
@@ -18,8 +12,3 @@ export enum SCENE {
INIT = "SCENE_INIT",
UPDATE = "SCENE_UPDATE",
}
export const FIREBASE_STORAGE_PREFIXES = {
shareLinkFiles: `/files/shareLinks`,
collabFiles: `/files/rooms`,
};
+83 -153
View File
@@ -4,22 +4,15 @@ import { ExcalidrawImperativeAPI } from "../../types";
import { ErrorDialog } from "../../components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../constants";
import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types";
import {
ExcalidrawElement,
InitializedExcalidrawImageElement,
} from "../../element/types";
import { getSceneVersion } from "../../packages/excalidraw/index";
getElementMap,
getSceneVersion,
} from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../types";
import { resolvablePromise, withBatchedUpdates } from "../../utils";
import {
preventUnload,
resolvablePromise,
withBatchedUpdates,
} from "../../utils";
import {
FILE_UPLOAD_MAX_BYTES,
FIREBASE_STORAGE_PREFIXES,
INITIAL_SCENE_UPDATE_TIMEOUT,
LOAD_IMAGES_TIMEOUT,
SCENE,
SYNC_FULL_SCENE_INTERVAL_MS,
} from "../app_constants";
@@ -32,9 +25,7 @@ import {
} from "../data";
import {
isSavedToFirebase,
loadFilesFromFirebase,
loadFromFirebase,
saveFilesToFirebase,
saveToFirebase,
} from "../data/firebase";
import {
@@ -50,21 +41,7 @@ import { UserIdleState } from "../../types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
import { trackEvent } from "../../analytics";
import { isInvisiblySmallElement } from "../../element";
import {
encodeFilesForUpload,
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { AbortError } from "../../errors";
import {
isImageElement,
isInitializedImageElement,
} from "../../element/typeChecks";
import { mutateElement } from "../../element/mutateElement";
import {
ReconciledElements,
reconcileElements as _reconcileElements,
} from "./reconciliation";
import { getRandomUsername } from "@excalidraw/random-username";
interface CollabState {
modalIsShown: boolean;
@@ -85,12 +62,14 @@ export interface CollabAPI {
initializeSocketClient: CollabInstance["initializeSocketClient"];
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
broadcastElements: CollabInstance["broadcastElements"];
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
}
type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements";
};
interface Props {
excalidrawAPI: ExcalidrawImperativeAPI;
onRoomClose?: () => void;
}
const {
@@ -103,13 +82,12 @@ export { CollabContext, CollabContextConsumer };
class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal;
fileManager: FileManager;
excalidrawAPI: Props["excalidrawAPI"];
isCollaborating: boolean = false;
activeIntervalId: number | null;
idleTimeoutId: number | null;
private socketInitializationTimer?: number;
private socketInitializationTimer?: NodeJS.Timeout;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<string, Collaborator>();
@@ -123,31 +101,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
activeRoomLink: "",
};
this.portal = new Portal(this);
this.fileManager = new FileManager({
getFiles: async (fileIds) => {
const { roomId, roomKey } = this.portal;
if (!roomId || !roomKey) {
throw new AbortError();
}
return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds);
},
saveFiles: async ({ addedFiles }) => {
const { roomId, roomKey } = this.portal;
if (!roomId || !roomKey) {
throw new AbortError();
}
return saveFilesToFirebase({
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
files: await encodeFilesForUpload({
files: addedFiles,
encryptionKey: roomKey,
maxBytes: FILE_UPLOAD_MAX_BYTES,
}),
});
},
});
this.excalidrawAPI = props.excalidrawAPI;
this.activeIntervalId = null;
this.idleTimeoutId = null;
@@ -200,14 +153,15 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
if (
this.isCollaborating &&
(this.fileManager.shouldPreventUnload(syncableElements) ||
!isSavedToFirebase(this.portal, syncableElements))
!isSavedToFirebase(this.portal, syncableElements)
) {
// this won't run in time if user decides to leave the site, but
// the purpose is to run in immediately after user decides to stay
this.saveCollabRoomToFirebase(syncableElements);
preventUnload(event);
event.preventDefault();
// NOTE: modern browsers no longer allow showing a custom message here
event.returnValue = "";
}
if (this.isCollaborating || this.portal.roomId) {
@@ -224,7 +178,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
});
saveCollabRoomToFirebase = async (
syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements(
syncableElements: ExcalidrawElement[] = this.getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
) => {
@@ -246,22 +200,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
window.history.pushState({}, APP_NAME, window.location.origin);
this.destroySocketClient();
trackEvent("share", "room closed");
this.props.onRoomClose?.();
const elements = this.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (isImageElement(element) && element.status === "saved") {
return mutateElement(element, { status: "pending" }, false);
}
return element;
});
this.excalidrawAPI.updateScene({
elements,
commitToHistory: false,
});
}
};
@@ -276,26 +214,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
});
this.isCollaborating = false;
}
this.lastBroadcastedOrReceivedSceneVersion = -1;
this.portal.close();
this.fileManager.reset();
};
private fetchImageFilesFromFirebase = async (scene: {
elements: readonly ExcalidrawElement[];
}) => {
const unfetchedImages = scene.elements
.filter((element) => {
return (
isInitializedImageElement(element) &&
!this.fileManager.isFileHandled(element.fileId) &&
!element.isDeleted &&
element.status === "saved"
);
})
.map((element) => (element as InitializedExcalidrawImageElement).fileId);
return await this.fileManager.getFiles(unfetchedImages);
};
private initializeSocketClient = async (
@@ -305,6 +224,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
return null;
}
if (!this.state.username) {
this.updateUsername(getRandomUsername());
}
let roomId;
let roomKey;
@@ -349,16 +272,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
console.error(error);
}
} else {
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
if (isImageElement(element) && element.status === "saved") {
return mutateElement(
element,
{ status: "pending" },
/* informMutation */ false,
);
}
return element;
});
const elements = this.excalidrawAPI.getSceneElements();
// remove deleted elements from elements array & history to ensure we don't
// expose potentially sensitive user data in case user manually deletes
// existing elements (or clears scene), which would otherwise be persisted
@@ -368,16 +282,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
elements,
commitToHistory: true,
});
this.broadcastElements(elements);
const syncableElements = this.getSyncableElements(elements);
this.saveCollabRoomToFirebase(syncableElements);
}
// fallback in case you're not alone in the room but still don't receive
// initial SCENE_UPDATE message
this.socketInitializationTimer = window.setTimeout(() => {
this.socketInitializationTimer = setTimeout(() => {
this.initializeSocket();
scenePromise.resolve(null);
}, INITIAL_SCENE_UPDATE_TIMEOUT);
@@ -481,45 +390,67 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
};
private reconcileElements = (
remoteElements: readonly ExcalidrawElement[],
elements: readonly ExcalidrawElement[],
): ReconciledElements => {
const localElements = this.getSceneElementsIncludingDeleted();
const currentElements = this.getSceneElementsIncludingDeleted();
// create a map of ids so we don't have to iterate
// over the array more than once.
const localElementMap = getElementMap(currentElements);
const appState = this.excalidrawAPI.getAppState();
const reconciledElements = _reconcileElements(
localElements,
remoteElements,
appState,
);
// Reconcile
const newElements: readonly ExcalidrawElement[] = elements
.reduce((elements, element) => {
// if the remote element references one that's currently
// edited on local, skip it (it'll be added in the next step)
if (
element.id === appState.editingElement?.id ||
element.id === appState.resizingElement?.id ||
element.id === appState.draggingElement?.id
) {
return elements;
}
if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version > element.version
) {
elements.push(localElementMap[element.id]);
delete localElementMap[element.id];
} else if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version === element.version &&
localElementMap[element.id].versionNonce !== element.versionNonce
) {
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
if (localElementMap[element.id].versionNonce < element.versionNonce) {
elements.push(localElementMap[element.id]);
} else {
// it should be highly unlikely that the two versionNonces are the same. if we are
// really worried about this, we can replace the versionNonce with the socket id.
elements.push(element);
}
delete localElementMap[element.id];
} else {
elements.push(element);
delete localElementMap[element.id];
}
return elements;
}, [] as Mutable<typeof elements>)
// add local elements that weren't deleted or on remote
.concat(...Object.values(localElementMap));
// Avoid broadcasting to the rest of the collaborators the scene
// we just received!
// Note: this needs to be set before updating the scene as it
// synchronously calls render.
this.setLastBroadcastedOrReceivedSceneVersion(
getSceneVersion(reconciledElements),
);
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
return reconciledElements;
return newElements as ReconciledElements;
};
private loadImageFiles = throttle(async () => {
const {
loadedFiles,
erroredFiles,
} = await this.fetchImageFilesFromFirebase({
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
});
this.excalidrawAPI.addFiles(loadedFiles);
updateStaleImageStatuses({
excalidrawAPI: this.excalidrawAPI,
erroredFiles,
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
});
}, LOAD_IMAGES_TIMEOUT);
private handleRemoteSceneUpdate = (
elements: ReconciledElements,
{ init = false }: { init?: boolean } = {},
@@ -534,8 +465,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
// right now we think this is the right tradeoff.
this.excalidrawAPI.history.clear();
this.loadImageFiles();
};
private onPointerMove = () => {
@@ -639,7 +568,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion()
) {
this.portal.broadcastScene(SCENE.UPDATE, elements, false);
this.portal.broadcastScene(
SCENE.UPDATE,
this.getSyncableElements(elements),
false,
);
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
this.queueBroadcastAllElements();
}
@@ -648,7 +581,9 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
queueBroadcastAllElements = throttle(() => {
this.portal.broadcastScene(
SCENE.UPDATE,
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
this.getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
true,
);
const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
@@ -663,7 +598,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.setState({ modalIsShown: false });
};
onUsernameChange = (username: string) => {
updateUsername = (username: string) => {
this.setState({ username });
saveUsernameToLocalStorage(username);
};
@@ -674,12 +609,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
});
};
isSyncableElement = (element: ExcalidrawElement) => {
return element.isDeleted || !isInvisiblySmallElement(element);
};
getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
elements.filter((element) => this.isSyncableElement(element));
elements.filter((el) => el.isDeleted || !isInvisiblySmallElement(el));
/** PRIVATE. Use `this.getContextValue()` instead. */
private contextValue: CollabAPI | null = null;
@@ -696,7 +627,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.contextValue.initializeSocketClient = this.initializeSocketClient;
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
this.contextValue.broadcastElements = this.broadcastElements;
this.contextValue.fetchImageFilesFromFirebase = this.fetchImageFilesFromFirebase;
return this.contextValue;
};
@@ -710,7 +640,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
handleClose={this.handleClose}
activeRoomLink={activeRoomLink}
username={username}
onUsernameChange={this.onUsernameChange}
onUsernameChange={this.updateUsername}
onRoomCreate={this.openPortal}
onRoomDestroy={this.closePortal}
setErrorMessage={(errorMessage) => {
+16 -63
View File
@@ -7,12 +7,9 @@ import {
import CollabWrapper from "./CollabWrapper";
import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants";
import { BROADCAST, SCENE } from "../app_constants";
import { UserIdleState } from "../../types";
import { trackEvent } from "../../analytics";
import { throttle } from "lodash";
import { mutateElement } from "../../element/mutateElement";
import { BroadcastedExcalidrawElement } from "./reconciliation";
class Portal {
collab: CollabWrapper;
@@ -41,7 +38,9 @@ class Portal {
this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene(
SCENE.INIT,
this.collab.getSceneElementsIncludingDeleted(),
this.collab.getSyncableElements(
this.collab.getSceneElementsIncludingDeleted(),
),
/* syncAll */ true,
);
});
@@ -88,70 +87,26 @@ class Portal {
}
}
queueFileUpload = throttle(async () => {
try {
await this.collab.fileManager.saveFiles({
elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
files: this.collab.excalidrawAPI.getFiles(),
});
} catch (error) {
this.collab.excalidrawAPI.updateScene({
appState: {
errorMessage: error.message,
},
});
}
this.collab.excalidrawAPI.updateScene({
elements: this.collab.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
// this will signal collaborators to pull image data from server
// (using mutation instead of newElementWith otherwise it'd break
// in-progress dragging)
return mutateElement(
element,
{ status: "saved" },
/* informMutation */ false,
);
}
return element;
}),
});
}, FILE_UPLOAD_TIMEOUT);
broadcastScene = async (
sceneType: SCENE.INIT | SCENE.UPDATE,
allElements: readonly ExcalidrawElement[],
syncableElements: ExcalidrawElement[],
syncAll: boolean,
) => {
if (sceneType === SCENE.INIT && !syncAll) {
throw new Error("syncAll must be true when sending SCENE.INIT");
}
// sync out only the elements we think we need to to save bandwidth.
// periodically we'll resync the whole thing to make sure no one diverges
// due to a dropped message (server goes down etc).
const syncableElements = allElements.reduce(
(acc, element: BroadcastedExcalidrawElement, idx, elements) => {
if (
(syncAll ||
!this.broadcastedElementVersions.has(element.id) ||
element.version >
this.broadcastedElementVersions.get(element.id)!) &&
this.collab.isSyncableElement(element)
) {
acc.push({
...element,
// z-index info for the reconciler
parent: idx === 0 ? "^" : elements[idx - 1]?.id,
});
}
return acc;
},
[] as BroadcastedExcalidrawElement[],
);
if (!syncAll) {
// sync out only the elements we think we need to to save bandwidth.
// periodically we'll resync the whole thing to make sure no one diverges
// due to a dropped message (server goes down etc).
syncableElements = syncableElements.filter(
(syncableElement) =>
!this.broadcastedElementVersions.has(syncableElement.id) ||
syncableElement.version >
this.broadcastedElementVersions.get(syncableElement.id)!,
);
}
const data: SocketUpdateDataSource[typeof sceneType] = {
type: sceneType,
@@ -171,8 +126,6 @@ class Portal {
data as SocketUpdateData,
);
this.queueFileUpload();
if (syncAll && this.collab.isCollaborating) {
await Promise.all([
broadcastPromise,
+7 -1
View File
@@ -14,6 +14,7 @@ import { t } from "../../i18n";
import "./RoomDialog.scss";
import Stack from "../../components/Stack";
import { AppState } from "../../types";
import { getRandomUsername } from "@excalidraw/random-username";
const getShareIcon = () => {
const navigator = window.navigator as any;
@@ -137,9 +138,14 @@ const RoomDialog = ({
</label>
<input
id="username"
value={username || ""}
value={username}
className="RoomDialog-username TextInput"
onChange={(event) => onUsernameChange(event.target.value)}
onBlur={(event) => {
if (!event.target.value.trim()) {
onUsernameChange(getRandomUsername());
}
}}
onKeyPress={(event) => event.key === "Enter" && handleClose()}
/>
</div>
-162
View File
@@ -1,162 +0,0 @@
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
export type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements";
};
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
parent?: string;
};
const shouldDiscardRemoteElement = (
localAppState: AppState,
local: ExcalidrawElement | undefined,
remote: BroadcastedExcalidrawElement,
): boolean => {
if (
local &&
// local element is being edited
(local.id === localAppState.editingElement?.id ||
local.id === localAppState.resizingElement?.id ||
local.id === localAppState.draggingElement?.id ||
// local element is newer
local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with
// the lowest versionNonce
(local.version === remote.version &&
local.versionNonce < remote.versionNonce))
) {
return true;
}
return false;
};
const getElementsMapWithIndex = <T extends ExcalidrawElement>(
elements: readonly T[],
) =>
elements.reduce(
(
acc: {
[key: string]: [element: T, index: number] | undefined;
},
element: T,
idx,
) => {
acc[element.id] = [element, idx];
return acc;
},
{},
);
export const reconcileElements = (
localElements: readonly ExcalidrawElement[],
remoteElements: readonly BroadcastedExcalidrawElement[],
localAppState: AppState,
): ReconciledElements => {
const localElementsData = getElementsMapWithIndex<ExcalidrawElement>(
localElements,
);
const reconciledElements: ExcalidrawElement[] = localElements.slice();
const duplicates = new WeakMap<ExcalidrawElement, true>();
let cursor = 0;
let offset = 0;
let remoteElementIdx = -1;
for (const remoteElement of remoteElements) {
remoteElementIdx++;
const local = localElementsData[remoteElement.id];
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
if (remoteElement.parent) {
delete remoteElement.parent;
}
continue;
}
if (local) {
// mark for removal since it'll be replaced with the remote element
duplicates.set(local[0], true);
}
// parent may not be defined in case the remote client is running an older
// excalidraw version
const parent =
remoteElement.parent || remoteElements[remoteElementIdx - 1]?.id || null;
if (parent != null) {
delete remoteElement.parent;
// ^ indicates the element is the first in elements array
if (parent === "^") {
offset++;
if (cursor === 0) {
reconciledElements.unshift(remoteElement);
localElementsData[remoteElement.id] = [
remoteElement,
cursor - offset,
];
} else {
reconciledElements.splice(cursor + 1, 0, remoteElement);
localElementsData[remoteElement.id] = [
remoteElement,
cursor + 1 - offset,
];
cursor++;
}
} else {
let idx = localElementsData[parent]
? localElementsData[parent]![1]
: null;
if (idx != null) {
idx += offset;
}
if (idx != null && idx >= cursor) {
reconciledElements.splice(idx + 1, 0, remoteElement);
offset++;
localElementsData[remoteElement.id] = [
remoteElement,
idx + 1 - offset,
];
cursor = idx + 1;
} else if (idx != null) {
reconciledElements.splice(cursor + 1, 0, remoteElement);
offset++;
localElementsData[remoteElement.id] = [
remoteElement,
cursor + 1 - offset,
];
cursor++;
} else {
reconciledElements.push(remoteElement);
localElementsData[remoteElement.id] = [
remoteElement,
reconciledElements.length - 1 - offset,
];
}
}
// no parent z-index information, local element exists → replace in place
} else if (local) {
reconciledElements[local[1]] = remoteElement;
localElementsData[remoteElement.id] = [remoteElement, local[1]];
// otherwise push to the end
} else {
reconciledElements.push(remoteElement);
localElementsData[remoteElement.id] = [
remoteElement,
reconciledElements.length - 1 - offset,
];
}
}
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
(element) => !duplicates.has(element),
);
return ret as ReconciledElements;
};
@@ -2,81 +2,69 @@ import React from "react";
import { Card } from "../../components/Card";
import { ToolButton } from "../../components/ToolButton";
import { serializeAsJSON } from "../../data/json";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import { FileId, NonDeletedExcalidrawElement } from "../../element/types";
import { AppState, BinaryFileData, BinaryFiles } from "../../types";
import { getImportedKey, createIV, generateEncryptionKey } from "../data";
import { loadFirebaseStorage } from "../data/firebase";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import { nanoid } from "nanoid";
import { t } from "../../i18n";
import { excalidrawPlusIcon } from "./icons";
import { encryptData, generateEncryptionKey } from "../../data/encryption";
import { isInitializedImageElement } from "../../element/typeChecks";
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
import { encodeFilesForUpload } from "../data/FileManager";
import { MIME_TYPES } from "../../constants";
const encryptData = async (
key: string,
json: string,
): Promise<{ blob: Blob; iv: Uint8Array }> => {
const importedKey = await getImportedKey(key, "encrypt");
const iv = createIV();
const encoded = new TextEncoder().encode(json);
const ciphertext = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
encoded,
);
return { blob: new Blob([new Uint8Array(ciphertext)]), iv };
};
const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const firebase = await loadFirebaseStorage();
const id = `${nanoid(12)}`;
const encryptionKey = (await generateEncryptionKey())!;
const key = (await generateEncryptionKey())!;
const encryptedData = await encryptData(
encryptionKey,
serializeAsJSON(elements, appState, files, "database"),
key,
serializeAsJSON(elements, appState),
);
const blob = new Blob(
[encryptedData.iv, new Uint8Array(encryptedData.encryptedBuffer)],
{
type: MIME_TYPES.binary,
},
);
const blob = new Blob([encryptedData.iv, encryptedData.blob], {
type: "application/octet-stream",
});
await firebase
.storage()
.ref(`/migrations/scenes/${id}`)
.put(blob, {
customMetadata: {
data: JSON.stringify({ version: 2, name: appState.name }),
data: JSON.stringify({ version: 1, name: appState.name }),
created: Date.now().toString(),
},
});
const filesMap = new Map<FileId, BinaryFileData>();
for (const element of elements) {
if (isInitializedImageElement(element) && files[element.fileId]) {
filesMap.set(element.fileId, files[element.fileId]);
}
}
if (filesMap.size) {
const filesToUpload = await encodeFilesForUpload({
files: filesMap,
encryptionKey,
maxBytes: FILE_UPLOAD_MAX_BYTES,
});
await saveFilesToFirebase({
prefix: `/migrations/files/scenes/${id}`,
files: filesToUpload,
});
}
window.open(
`https://plus.excalidraw.com/import?excalidraw=${id},${encryptionKey}`,
);
window.open(`https://plus.excalidraw.com/import?excalidraw=${id},${key}`);
};
export const ExportToExcalidrawPlus: React.FC<{
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
files: BinaryFiles;
onError: (error: Error) => void;
}> = ({ elements, appState, files, onError }) => {
}> = ({ elements, appState, onError }) => {
return (
<Card color="indigo">
<div className="Card-icon">{excalidrawPlusIcon}</div>
@@ -92,7 +80,7 @@ export const ExportToExcalidrawPlus: React.FC<{
showAriaLabel={true}
onClick={async () => {
try {
await exportToExcalidrawPlus(elements, appState, files);
await exportToExcalidrawPlus(elements, appState);
} catch (error) {
console.error(error);
onError(new Error(t("exportDialog.excalidrawplus_exportError")));
-249
View File
@@ -1,249 +0,0 @@
import { compressData } from "../../data/encode";
import { mutateElement } from "../../element/mutateElement";
import { isInitializedImageElement } from "../../element/typeChecks";
import {
ExcalidrawElement,
ExcalidrawImageElement,
FileId,
InitializedExcalidrawImageElement,
} from "../../element/types";
import { t } from "../../i18n";
import {
BinaryFileData,
BinaryFileMetadata,
ExcalidrawImperativeAPI,
BinaryFiles,
} from "../../types";
export class FileManager {
/** files being fetched */
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
/** files being saved */
private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
/* files already saved to persistent storage */
private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private _getFiles;
private _saveFiles;
constructor({
getFiles,
saveFiles,
}: {
getFiles: (
fileIds: FileId[],
) => Promise<{
loadedFiles: BinaryFileData[];
erroredFiles: Map<FileId, true>;
}>;
saveFiles: (data: {
addedFiles: Map<FileId, BinaryFileData>;
}) => Promise<{
savedFiles: Map<FileId, true>;
erroredFiles: Map<FileId, true>;
}>;
}) {
this._getFiles = getFiles;
this._saveFiles = saveFiles;
}
/**
* returns whether file is already saved or being processed
*/
isFileHandled = (id: FileId) => {
return (
this.savedFiles.has(id) ||
this.fetchingFiles.has(id) ||
this.savingFiles.has(id) ||
this.erroredFiles.has(id)
);
};
isFileSaved = (id: FileId) => {
return this.savedFiles.has(id);
};
saveFiles = async ({
elements,
files,
}: {
elements: readonly ExcalidrawElement[];
files: BinaryFiles;
}) => {
const addedFiles: Map<FileId, BinaryFileData> = new Map();
for (const element of elements) {
if (
isInitializedImageElement(element) &&
files[element.fileId] &&
!this.isFileHandled(element.fileId)
) {
addedFiles.set(element.fileId, files[element.fileId]);
this.savingFiles.set(element.fileId, true);
}
}
try {
const { savedFiles, erroredFiles } = await this._saveFiles({
addedFiles,
});
for (const [fileId] of savedFiles) {
this.savedFiles.set(fileId, true);
}
return {
savedFiles,
erroredFiles,
};
} finally {
for (const [fileId] of addedFiles) {
this.savingFiles.delete(fileId);
}
}
};
getFiles = async (
ids: FileId[],
): Promise<{
loadedFiles: BinaryFileData[];
erroredFiles: Map<FileId, true>;
}> => {
if (!ids.length) {
return {
loadedFiles: [],
erroredFiles: new Map(),
};
}
for (const id of ids) {
this.fetchingFiles.set(id, true);
}
try {
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
for (const file of loadedFiles) {
this.savedFiles.set(file.id, true);
}
for (const [fileId] of erroredFiles) {
this.erroredFiles.set(fileId, true);
}
return { loadedFiles, erroredFiles };
} finally {
for (const id of ids) {
this.fetchingFiles.delete(id);
}
}
};
/** a file element prevents unload only if it's being saved regardless of
* its `status`. This ensures that elements who for any reason haven't
* beed set to `saved` status don't prevent unload in future sessions.
* Technically we should prevent unload when the origin client haven't
* yet saved the `status` update to storage, but that should be taken care
* of during regular beforeUnload unsaved files check.
*/
shouldPreventUnload = (elements: readonly ExcalidrawElement[]) => {
return elements.some((element) => {
return (
isInitializedImageElement(element) &&
!element.isDeleted &&
this.savingFiles.has(element.fileId)
);
});
};
/**
* helper to determine if image element status needs updating
*/
shouldUpdateImageElementStatus = (
element: ExcalidrawElement,
): element is InitializedExcalidrawImageElement => {
return (
isInitializedImageElement(element) &&
this.isFileSaved(element.fileId) &&
element.status === "pending"
);
};
reset() {
this.fetchingFiles.clear();
this.savingFiles.clear();
this.savedFiles.clear();
this.erroredFiles.clear();
}
}
export const encodeFilesForUpload = async ({
files,
maxBytes,
encryptionKey,
}: {
files: Map<FileId, BinaryFileData>;
maxBytes: number;
encryptionKey: string;
}) => {
const processedFiles: {
id: FileId;
buffer: Uint8Array;
}[] = [];
for (const [id, fileData] of files) {
const buffer = new TextEncoder().encode(fileData.dataURL);
const encodedFile = await compressData<BinaryFileMetadata>(buffer, {
encryptionKey,
metadata: {
id,
mimeType: fileData.mimeType,
created: Date.now(),
},
});
if (buffer.byteLength > maxBytes) {
throw new Error(
t("errors.fileTooBig", {
maxSize: `${Math.trunc(maxBytes / 1024 / 1024)}MB`,
}),
);
}
processedFiles.push({
id,
buffer: encodedFile,
});
}
return processedFiles;
};
export const updateStaleImageStatuses = (params: {
excalidrawAPI: ExcalidrawImperativeAPI;
erroredFiles: Map<FileId, true>;
elements: readonly ExcalidrawElement[];
}) => {
if (!params.erroredFiles.size) {
return;
}
params.excalidrawAPI.updateScene({
elements: params.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (
isInitializedImageElement(element) &&
params.erroredFiles.has(element.fileId)
) {
return mutateElement(
element,
{
status: "error",
},
false,
);
}
return element;
}),
});
};
+12 -118
View File
@@ -1,45 +1,26 @@
import { ExcalidrawElement, FileId } from "../../element/types";
import { getImportedKey } from "../data";
import { createIV } from "./index";
import { ExcalidrawElement } from "../../element/types";
import { getSceneVersion } from "../../element";
import Portal from "../collab/Portal";
import { restoreElements } from "../../data/restore";
import { BinaryFileData, BinaryFileMetadata, DataURL } from "../../types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { decompressData } from "../../data/encode";
import { getImportedKey, createIV } from "../../data/encryption";
import { MIME_TYPES } from "../../constants";
// private
// -----------------------------------------------------------------------------
const FIREBASE_CONFIG = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
let firebasePromise: Promise<
typeof import("firebase/app").default
> | null = null;
let firestorePromise: Promise<any> | null | true = null;
let firebaseStoragePromise: Promise<any> | null | true = null;
let isFirebaseInitialized = false;
let firestorePromise: Promise<any> | null = null;
let firebseStoragePromise: Promise<any> | null = null;
const _loadFirebase = async () => {
const firebase = (
await import(/* webpackChunkName: "firebase" */ "firebase/app")
).default;
if (!isFirebaseInitialized) {
try {
firebase.initializeApp(FIREBASE_CONFIG);
} catch (error) {
// trying initialize again throws. Usually this is harmless, and happens
// mainly in dev (HMR)
if (error.code === "app/duplicate-app") {
console.warn(error.name, error.code);
} else {
throw error;
}
}
isFirebaseInitialized = true;
}
const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
firebase.initializeApp(firebaseConfig);
return firebase;
};
@@ -61,24 +42,18 @@ const loadFirestore = async () => {
firestorePromise = import(
/* webpackChunkName: "firestore" */ "firebase/firestore"
);
}
if (firestorePromise !== true) {
await firestorePromise;
firestorePromise = true;
}
return firebase;
};
export const loadFirebaseStorage = async () => {
const firebase = await _getFirebase();
if (!firebaseStoragePromise) {
firebaseStoragePromise = import(
if (!firebseStoragePromise) {
firebseStoragePromise = import(
/* webpackChunkName: "storage" */ "firebase/storage"
);
}
if (firebaseStoragePromise !== true) {
await firebaseStoragePromise;
firebaseStoragePromise = true;
await firebseStoragePromise;
}
return firebase;
};
@@ -112,7 +87,7 @@ const encryptElements = async (
const decryptElements = async (
key: string,
iv: Uint8Array,
ciphertext: ArrayBuffer | Uint8Array,
ciphertext: ArrayBuffer,
): Promise<readonly ExcalidrawElement[]> => {
const importedKey = await getImportedKey(key, "decrypt");
const decrypted = await window.crypto.subtle.decrypt(
@@ -125,7 +100,7 @@ const decryptElements = async (
);
const decodedData = new TextDecoder("utf-8").decode(
new Uint8Array(decrypted),
new Uint8Array(decrypted) as any,
);
return JSON.parse(decodedData);
};
@@ -138,7 +113,6 @@ export const isSavedToFirebase = (
): boolean => {
if (portal.socket && portal.roomId && portal.roomKey) {
const sceneVersion = getSceneVersion(elements);
return firebaseSceneVersionCache.get(portal.socket) === sceneVersion;
}
// if no room exists, consider the room saved so that we don't unnecessarily
@@ -146,42 +120,6 @@ export const isSavedToFirebase = (
return true;
};
export const saveFilesToFirebase = async ({
prefix,
files,
}: {
prefix: string;
files: { id: FileId; buffer: Uint8Array }[];
}) => {
const firebase = await loadFirebaseStorage();
const erroredFiles = new Map<FileId, true>();
const savedFiles = new Map<FileId, true>();
await Promise.all(
files.map(async ({ id, buffer }) => {
try {
await firebase
.storage()
.ref(`${prefix}/${id}`)
.put(
new Blob([buffer], {
type: MIME_TYPES.binary,
}),
{
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
},
);
savedFiles.set(id, true);
} catch (error) {
erroredFiles.set(id, true);
}
}),
);
return { savedFiles, erroredFiles };
};
export const saveToFirebase = async (
portal: Portal,
elements: readonly ExcalidrawElement[],
@@ -260,47 +198,3 @@ export const loadFromFirebase = async (
return restoreElements(elements, null);
};
export const loadFilesFromFirebase = async (
prefix: string,
decryptionKey: string,
filesIds: readonly FileId[],
) => {
const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>();
await Promise.all(
[...new Set(filesIds)].map(async (id) => {
try {
const url = `https://firebasestorage.googleapis.com/v0/b/${
FIREBASE_CONFIG.storageBucket
}/o/${encodeURIComponent(prefix.replace(/^\//, ""))}%2F${id}`;
const response = await fetch(`${url}?alt=media`);
if (response.status < 400) {
const arrayBuffer = await response.arrayBuffer();
const { data, metadata } = await decompressData<BinaryFileMetadata>(
new Uint8Array(arrayBuffer),
{
decryptionKey,
},
);
const dataURL = new TextDecoder().decode(data) as DataURL;
loadedFiles.push({
mimeType: metadata.mimeType || MIME_TYPES.binary,
id,
dataURL,
created: metadata?.created || Date.now(),
});
}
} catch (error) {
erroredFiles.set(id, true);
console.error(error);
}
}),
);
return { loadedFiles, erroredFiles };
};
+47 -51
View File
@@ -1,24 +1,9 @@
import {
createIV,
generateEncryptionKey,
getImportedKey,
IV_LENGTH_BYTES,
} from "../../data/encryption";
import { serializeAsJSON } from "../../data/json";
import { restore } from "../../data/restore";
import { ImportedDataState } from "../../data/types";
import { isInitializedImageElement } from "../../element/typeChecks";
import { ExcalidrawElement, FileId } from "../../element/types";
import { ExcalidrawElement } from "../../element/types";
import { t } from "../../i18n";
import {
AppState,
BinaryFileData,
BinaryFiles,
UserIdleState,
} from "../../types";
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
import { encodeFilesForUpload } from "./FileManager";
import { saveFilesToFirebase } from "./firebase";
import { AppState, UserIdleState } from "../../types";
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
@@ -32,6 +17,18 @@ const generateRandomID = async () => {
return Array.from(arr, byteToHex).join("");
};
export const generateEncryptionKey = async () => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 128,
},
true, // extractable
["encrypt", "decrypt"],
);
return (await window.crypto.subtle.exportKey("jwk", key)).k;
};
export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
export type EncryptedData = {
@@ -82,6 +79,13 @@ 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);
return window.crypto.getRandomValues(arr);
};
export const encryptAESGEM = async (
data: Uint8Array,
key: string,
@@ -118,7 +122,7 @@ export const decryptAESGEM = async (
);
const decodedData = new TextDecoder("utf-8").decode(
new Uint8Array(decrypted),
new Uint8Array(decrypted) as any,
);
return JSON.parse(decodedData);
} catch (error) {
@@ -158,8 +162,26 @@ export const getCollaborationLink = (data: {
return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
};
export const getImportedKey = (key: string, usage: KeyUsage) =>
window.crypto.subtle.importKey(
"jwk",
{
alg: "A128GCM",
ext: true,
k: key,
key_ops: ["encrypt", "decrypt"],
kty: "oct",
},
{
name: "AES-GCM",
length: 128,
},
false, // extractable
[usage],
);
export const decryptImported = async (
iv: ArrayBuffer | Uint8Array,
iv: ArrayBuffer,
encrypted: ArrayBuffer,
privateKey: string,
): Promise<ArrayBuffer> => {
@@ -205,7 +227,7 @@ const importFromBackend = async (
// We need to convert the decrypted array buffer to a string
const string = new window.TextDecoder("utf-8").decode(
new Uint8Array(decrypted),
new Uint8Array(decrypted) as any,
);
data = JSON.parse(string);
} else {
@@ -248,10 +270,6 @@ export const loadScene = async (
return {
elements: data.elements,
appState: data.appState,
// note: this will always be empty because we're not storing files
// in the scene database/localStorage, and instead fetch them async
// from a different database
files: data.files,
commitToHistory: false,
};
};
@@ -259,12 +277,11 @@ export const loadScene = async (
export const exportToBackend = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const json = serializeAsJSON(elements, appState, files, "database");
const json = serializeAsJSON(elements, appState);
const encoded = new TextEncoder().encode(json);
const cryptoKey = await window.crypto.subtle.generateKey(
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 128,
@@ -281,7 +298,7 @@ export const exportToBackend = async (
name: "AES-GCM",
iv,
},
cryptoKey,
key,
encoded,
);
@@ -291,24 +308,9 @@ export const exportToBackend = async (
// 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", cryptoKey);
const exportedKey = await window.crypto.subtle.exportKey("jwk", key);
try {
const filesMap = new Map<FileId, BinaryFileData>();
for (const element of elements) {
if (isInitializedImageElement(element) && files[element.fileId]) {
filesMap.set(element.fileId, files[element.fileId]);
}
}
const encryptionKey = exportedKey.k!;
const filesToUpload = await encodeFilesForUpload({
files: filesMap,
encryptionKey,
maxBytes: FILE_UPLOAD_MAX_BYTES,
});
const response = await fetch(BACKEND_V2_POST, {
method: "POST",
body: payload,
@@ -318,14 +320,8 @@ export const exportToBackend = async (
const url = new URL(window.location.href);
// We need to store the key (and less importantly the id) as hash instead
// of queryParam in order to never send it to the server
url.hash = `json=${json.id},${encryptionKey}`;
url.hash = `json=${json.id},${exportedKey.k!}`;
const urlString = url.toString();
await saveFilesToFirebase({
prefix: `/files/shareLinks/${json.id}`,
files: filesToUpload,
});
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
} else if (json.error_class === "RequestTooLargeError") {
window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
+39 -262
View File
@@ -16,7 +16,6 @@ import { loadFromBlob } from "../data/blob";
import { ImportedDataState } from "../data/types";
import {
ExcalidrawElement,
FileId,
NonDeletedExcalidrawElement,
} from "../element/types";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
@@ -25,24 +24,14 @@ import Excalidraw, {
defaultLang,
languages,
} from "../packages/excalidraw/index";
import {
AppState,
LibraryItems,
ExcalidrawImperativeAPI,
BinaryFileData,
BinaryFiles,
} from "../types";
import { AppState, LibraryItems, ExcalidrawImperativeAPI } from "../types";
import {
debounce,
getVersion,
preventUnload,
ResolvablePromise,
resolvablePromise,
} from "../utils";
import {
FIREBASE_STORAGE_PREFIXES,
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
} from "./app_constants";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
import CollabWrapper, {
CollabAPI,
CollabContext,
@@ -62,64 +51,6 @@ import { shield } from "../components/icons";
import "./index.scss";
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
import { getMany, set, del, keys, createStore } from "idb-keyval";
import { FileManager, updateStaleImageStatuses } from "./data/FileManager";
import { mutateElement } from "../element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase";
const filesStore = createStore("files-db", "files-store");
const clearObsoleteFilesFromIndexedDB = async (opts: {
currentFileIds: FileId[];
}) => {
const allIds = await keys(filesStore);
for (const id of allIds) {
if (!opts.currentFileIds.includes(id as FileId)) {
del(id, filesStore);
}
}
};
const localFileStorage = new FileManager({
getFiles(ids) {
return getMany(ids, filesStore).then(
(filesData: (BinaryFileData | undefined)[]) => {
const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>();
filesData.forEach((data, index) => {
const id = ids[index];
if (data) {
loadedFiles.push(data);
} else {
erroredFiles.set(id, true);
}
});
return { loadedFiles, erroredFiles };
},
);
},
async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
await Promise.all(
[...addedFiles].map(async ([id, fileData]) => {
try {
await set(id, fileData, filesStore);
savedFiles.set(id, true);
} catch (error) {
console.error(error);
erroredFiles.set(id, true);
}
}),
);
return { savedFiles, erroredFiles };
},
});
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {
@@ -130,20 +61,8 @@ languageDetector.init({
});
const saveDebounced = debounce(
async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
onFilesSaved: () => void,
) => {
saveToLocalStorage(elements, appState);
await localFileStorage.saveFiles({
elements,
files,
});
onFilesSaved();
(elements: readonly ExcalidrawElement[], state: AppState) => {
saveToLocalStorage(elements, state);
},
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
);
@@ -154,12 +73,7 @@ const onBlur = () => {
const initializeScene = async (opts: {
collabAPI: CollabAPI;
}): Promise<
{ scene: ImportedDataState | null } & (
| { isExternalScene: true; id: string; key: string }
| { isExternalScene: false; id?: null; key?: null }
)
> => {
}): Promise<ImportedDataState | null> => {
const searchParams = new URLSearchParams(window.location.search);
const id = searchParams.get("id");
const jsonBackendMatch = window.location.hash.match(
@@ -226,38 +140,23 @@ const initializeScene = async (opts: {
!scene.elements.length ||
window.confirm(t("alerts.loadSceneOverridePrompt"))
) {
return { scene: data, isExternalScene };
return data;
}
} catch (error) {
return {
scene: {
appState: {
errorMessage: t("alerts.invalidSceneUrl"),
},
appState: {
errorMessage: t("alerts.invalidSceneUrl"),
},
isExternalScene,
};
}
}
if (roomLinkData) {
return {
scene: await opts.collabAPI.initializeSocketClient(roomLinkData),
isExternalScene: true,
id: roomLinkData.roomId,
key: roomLinkData.roomKey,
};
return opts.collabAPI.initializeSocketClient(roomLinkData);
} else if (scene) {
return isExternalScene && jsonBackendMatch
? {
scene,
isExternalScene,
id: jsonBackendMatch[1],
key: jsonBackendMatch[2],
}
: { scene, isExternalScene: false };
return scene;
}
return { scene: null, isExternalScene: false };
return null;
};
const PlusLinkJSX = (
@@ -308,84 +207,20 @@ const ExcalidrawWrapper = () => {
return;
}
const loadImages = (
data: ResolutionType<typeof initializeScene>,
isInitialLoad = false,
) => {
if (!data.scene) {
return;
}
if (collabAPI.isCollaborating()) {
if (data.scene.elements) {
collabAPI
.fetchImageFilesFromFirebase({
elements: data.scene.elements,
})
.then(({ loadedFiles, erroredFiles }) => {
excalidrawAPI.addFiles(loadedFiles);
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
});
}
} else {
const fileIds =
data.scene.elements?.reduce((acc, element) => {
if (isInitializedImageElement(element)) {
return acc.concat(element.fileId);
}
return acc;
}, [] as FileId[]) || [];
if (data.isExternalScene) {
loadFilesFromFirebase(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key,
fileIds,
).then(({ loadedFiles, erroredFiles }) => {
excalidrawAPI.addFiles(loadedFiles);
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
});
} else if (isInitialLoad) {
if (fileIds.length) {
localFileStorage
.getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) {
excalidrawAPI.addFiles(loadedFiles);
}
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
});
}
// on fresh load, clear unused files from IDB (from previous
// session)
clearObsoleteFilesFromIndexedDB({ currentFileIds: fileIds });
initializeScene({ collabAPI }).then((scene) => {
if (scene) {
try {
scene.libraryItems =
JSON.parse(
localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_LIBRARY,
) as string,
) || [];
} catch (e) {
console.error(e);
}
}
try {
data.scene.libraryItems =
JSON.parse(
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
) || [];
} catch (e) {
console.error(e);
}
};
initializeScene({ collabAPI }).then((data) => {
loadImages(data, /* isInitialLoad */ true);
initialStatePromiseRef.current.promise.resolve(data.scene);
initialStatePromiseRef.current.promise.resolve(scene);
});
const onHashChange = (event: HashChangeEvent) => {
@@ -400,12 +235,11 @@ const ExcalidrawWrapper = () => {
window.history.replaceState({}, "", event.oldURL);
excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
} else {
initializeScene({ collabAPI }).then((data) => {
loadImages(data);
if (data.scene) {
initializeScene({ collabAPI }).then((scene) => {
if (scene) {
excalidrawAPI.updateScene({
...data.scene,
appState: restoreAppState(data.scene.appState, null),
...scene,
appState: restoreAppState(scene.appState, null),
});
}
});
@@ -427,23 +261,6 @@ const ExcalidrawWrapper = () => {
};
}, [collabAPI, excalidrawAPI]);
useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => {
saveDebounced.flush();
if (
excalidrawAPI &&
localFileStorage.shouldPreventUnload(excalidrawAPI.getSceneElements())
) {
preventUnload(event);
}
};
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
return () => {
window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
};
}, [excalidrawAPI]);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
@@ -451,43 +268,20 @@ const ExcalidrawWrapper = () => {
const onChange = (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
if (collabAPI?.isCollaborating()) {
collabAPI.broadcastElements(elements);
} else {
saveDebounced(elements, appState, files, () => {
if (excalidrawAPI) {
let didChange = false;
const elements = excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (localFileStorage.shouldUpdateImageElementStatus(element)) {
didChange = true;
return mutateElement(
element,
{ status: "saved" },
/* informMutation */ false,
);
}
return element;
});
if (didChange) {
excalidrawAPI.updateScene({
elements,
});
}
}
});
// collab scenes are persisted to the server, so we don't have to persist
// them locally, which has the added benefit of not overwriting whatever
// the user was working on before joining
saveDebounced(elements, appState);
}
};
const onExportToBackend = async (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
canvas: HTMLCanvasElement | null,
) => {
if (exportedElements.length === 0) {
@@ -495,16 +289,12 @@ const ExcalidrawWrapper = () => {
}
if (canvas) {
try {
await exportToBackend(
exportedElements,
{
...appState,
viewBackgroundColor: appState.exportBackground
? appState.viewBackgroundColor
: getDefaultAppState().viewBackgroundColor,
},
files,
);
await exportToBackend(exportedElements, {
...appState,
viewBackgroundColor: appState.exportBackground
? appState.viewBackgroundColor
: getDefaultAppState().viewBackgroundColor,
});
} catch (error) {
if (error.name !== "AbortError") {
const { width, height } = canvas;
@@ -517,9 +307,6 @@ const ExcalidrawWrapper = () => {
const renderTopRightUI = useCallback(
(isMobile: boolean, appState: AppState) => {
if (isMobile) {
return null;
}
return (
<div
style={{
@@ -619,10 +406,6 @@ const ExcalidrawWrapper = () => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
};
const onRoomClose = useCallback(() => {
localFileStorage.reset();
}, []);
return (
<>
<Excalidraw
@@ -636,12 +419,11 @@ const ExcalidrawWrapper = () => {
canvasActions: {
export: {
onExportToBackend,
renderCustomUI: (elements, appState, files) => {
renderCustomUI: (elements, appState) => {
return (
<ExportToExcalidrawPlus
elements={elements}
appState={appState}
files={files}
onError={(error) => {
excalidrawAPI?.updateScene({
appState: {
@@ -664,12 +446,7 @@ const ExcalidrawWrapper = () => {
onLibraryChange={onLibraryChange}
autoFocus={true}
/>
{excalidrawAPI && (
<CollabWrapper
excalidrawAPI={excalidrawAPI}
onRoomClose={onRoomClose}
/>
)}
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
{errorMessage && (
<ErrorDialog
message={errorMessage}
-39
View File
@@ -47,11 +47,6 @@ type MarkOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
Required<Pick<T, RK>>;
type MarkNonNullable<T, K extends keyof T> = {
[P in K]-?: P extends K ? NonNullable<T[P]> : T[P];
} &
{ [P in keyof T]: T[P] };
// PNG encoding/decoding
// -----------------------------------------------------------------------------
type TEXtChunk = { name: "tEXt"; data: Uint8Array };
@@ -96,37 +91,3 @@ interface Blob {
}
declare module "*.scss";
// --------------------------------------------------------------------------—
// ensure Uint8Array isn't assignable to ArrayBuffer
// (due to TS structural typing)
// https://github.com/microsoft/TypeScript/issues/31311#issuecomment-490690695
interface ArrayBuffer {
private _brand?: "ArrayBuffer";
}
interface Uint8Array {
private _brand?: "Uint8Array";
}
// --------------------------------------------------------------------------—
// https://github.com/nodeca/image-blob-reduce/issues/23#issuecomment-783271848
declare module "image-blob-reduce" {
import { PicaResizeOptions } from "pica";
namespace ImageBlobReduce {
interface ImageBlobReduce {
toBlob(file: File, options: ImageBlobReduceOptions): Promise<Blob>;
}
interface ImageBlobReduceStatic {
new (options?: any): ImageBlobReduce;
(options?: any): ImageBlobReduce;
}
interface ImageBlobReduceOptions extends PicaResizeOptions {
max: number;
}
}
const reduce: ImageBlobReduce.ImageBlobReduceStatic;
export = reduce;
}
-1
View File
@@ -66,7 +66,6 @@ const canvas = exportToCanvas(
width: 0,
height: 0,
},
{}, // files
{
exportBackground: true,
viewBackgroundColor: "#ffffff",
+6 -6
View File
@@ -45,7 +45,6 @@ export const KEYS = {
D: "d",
E: "e",
G: "g",
I: "i",
L: "l",
O: "o",
P: "p",
@@ -67,12 +66,13 @@ export const isArrowKey = (key: string) =>
key === KEYS.ARROW_DOWN ||
key === KEYS.ARROW_UP;
export const shouldResizeFromCenter = (event: MouseEvent | KeyboardEvent) =>
export const getResizeCenterPointKey = (event: MouseEvent | KeyboardEvent) =>
event.altKey;
export const shouldMaintainAspectRatio = (event: MouseEvent | KeyboardEvent) =>
event.shiftKey;
export const shouldRotateWithDiscreteAngle = (
export const getResizeWithSidesSameLengthKey = (
event: MouseEvent | KeyboardEvent,
) => event.shiftKey;
export const getRotateWithDiscreteAngleKey = (
event: MouseEvent | KeyboardEvent,
) => event.shiftKey;
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "الوضع المظلم",
"lightMode": "الوضع المضيء",
"zenMode": "وضع التأمل",
"exitZenMode": "إلغاء الوضع الليلى",
"cancel": "",
"clear": ""
"exitZenMode": "إلغاء الوضع الليلى"
},
"alerts": {
"clearReset": "هذا سيُزيل كامل اللوحة. هل أنت متأكد؟",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "تعذر إضافة العنصر للمكتبة",
"errorRemovingFromLibrary": "تعذر إزالة العنصر من المكتبة",
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
"imageDoesNotContainScene": "",
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "تحديد",
"image": "",
"rectangle": "مستطيل",
"diamond": "مضلع",
"ellipse": "دائرة",
@@ -196,12 +186,10 @@
"linearElementMulti": "انقر فوق النقطة الأخيرة أو اضغط على Esc أو Enter للإنهاء",
"lockAngle": "يمكنك تقييد الزاوية بالضغط على SHIFT",
"resize": "يمكنك تقييد النسب بالضغط على SHIFT أثناء تغيير الحجم،\nاضغط على ALT لتغيير الحجم من المركز",
"resizeImage": "",
"rotate": "يمكنك تقييد الزوايا من خلال الضغط على SHIFT أثناء الدوران",
"lineEditor_info": "انقر نقراً مزدوجاً أو اضغط Enter لتعديل النقاط",
"lineEditor_pointSelected": "اضغط على حذف لإزالة النقطة، Ctrl Or Cmd+D للتكرار، أو اسحب للانتقال",
"lineEditor_nothingSelected": "حدد نقطة لتحريك أو إزالتها، أو اضغط Alt ثم انقر لإضافة نقاط جديدة",
"placeImage": ""
"lineEditor_nothingSelected": "حدد نقطة لتحريك أو إزالتها، أو اضغط Alt ثم انقر لإضافة نقاط جديدة"
},
"canvasError": {
"cannotShowPreview": "تعذر عرض المعاينة",
@@ -268,9 +256,6 @@
"zoomToFit": "تكبير للملائمة",
"zoomToSelection": "تكبير للعنصر المحدد"
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "رسوماتك مشفرة من النهاية إلى النهاية حتى أن خوادم Excalidraw لن تراها أبدا.",
"link": "مشاركة المدونة في التشفير من النهاية إلى النهاية في Excalidraw"
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "Тъмен режим",
"lightMode": "Светъл режим",
"zenMode": "Режим Zen",
"exitZenMode": "Спиране на Zen режим",
"cancel": "",
"clear": ""
"exitZenMode": "Спиране на Zen режим"
},
"alerts": {
"clearReset": "Това ще изчисти цялото платно. Сигурни ли сте?",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?",
"imageDoesNotContainScene": "",
"imageDoesNotContainScene": "Импортирането на картинки не се поддържва в момента.\n\nИскате да импортнете сцена? Тази картинка не съдържа данни от сцена. Разрешили ли сте последното при експортирането?",
"cannotRestoreFromImage": "Не може да бъде възстановена сцена от този файл",
"invalidSceneUrl": "",
"resetLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "Селекция",
"image": "",
"rectangle": "Правоъгълник",
"diamond": "Диамант",
"ellipse": "Елипс",
@@ -196,12 +186,10 @@
"linearElementMulti": "Кликнете върху последната точка или натиснете Escape или Enter, за да завършите",
"lockAngle": "Можете да ограничите ъгъла, като задържите SHIFT",
"resize": "Може да ограничите при преоразмеряване като задържите SHIFT,\nзадръжте ALT за преоразмерите през центъра",
"resizeImage": "",
"rotate": "Можете да ограничите ъглите, като държите SHIFT, докато се въртите",
"lineEditor_info": "Кликнете два пъти или натиснете Enter за да промените точките",
"lineEditor_pointSelected": "Натиснете Delete за да изтриете точка, CtrlOrCmd+D за дуплициране, или извлачете за да преместите",
"lineEditor_nothingSelected": "Изберете точка за местене или изтриване, или пък задръжте Alt и натиснете за да добавите нови точки",
"placeImage": ""
"lineEditor_nothingSelected": "Изберете точка за местене или изтриване, или пък задръжте Alt и натиснете за да добавите нови точки"
},
"canvasError": {
"cannotShowPreview": "Невъзможност за показване на preview",
@@ -268,9 +256,6 @@
"zoomToFit": "Приближи докато се виждат всички елементи",
"zoomToSelection": "Приближи селекцията"
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат.",
"link": ""
-349
View File
@@ -1,349 +0,0 @@
{
"labels": {
"paste": "",
"pasteCharts": "",
"selectAll": "",
"multiSelect": "",
"moveCanvas": "",
"cut": "",
"copy": "",
"copyAsPng": "",
"copyAsSvg": "",
"bringForward": "",
"sendToBack": "",
"bringToFront": "",
"sendBackward": "",
"delete": "",
"copyStyles": "",
"pasteStyles": "",
"stroke": "",
"background": "",
"fill": "",
"strokeWidth": "",
"strokeShape": "",
"strokeShape_gel": "",
"strokeShape_fountain": "",
"strokeShape_brush": "",
"strokeStyle": "",
"strokeStyle_solid": "",
"strokeStyle_dashed": "",
"strokeStyle_dotted": "",
"sloppiness": "",
"opacity": "",
"textAlign": "",
"edges": "",
"sharp": "",
"round": "",
"arrowheads": "",
"arrowhead_none": "",
"arrowhead_arrow": "",
"arrowhead_bar": "",
"arrowhead_dot": "",
"fontSize": "",
"fontFamily": "",
"onlySelected": "",
"withBackground": "",
"exportEmbedScene": "",
"exportEmbedScene_details": "",
"addWatermark": "",
"handDrawn": "",
"normal": "",
"code": "",
"small": "",
"medium": "",
"large": "",
"veryLarge": "",
"solid": "",
"hachure": "",
"crossHatch": "",
"thin": "",
"bold": "",
"left": "",
"center": "",
"right": "",
"extraBold": "",
"architect": "",
"artist": "",
"cartoonist": "",
"fileTitle": "",
"colorPicker": "",
"canvasBackground": "",
"drawingCanvas": "",
"layers": "",
"actions": "",
"language": "",
"liveCollaboration": "",
"duplicateSelection": "",
"untitled": "",
"name": "",
"yourName": "",
"madeWithExcalidraw": "",
"group": "",
"ungroup": "",
"collaborators": "",
"showGrid": "",
"addToLibrary": "",
"removeFromLibrary": "",
"libraryLoadingMessage": "",
"libraries": "",
"loadingScene": "",
"align": "",
"alignTop": "",
"alignBottom": "",
"alignLeft": "",
"alignRight": "",
"centerVertically": "",
"centerHorizontally": "",
"distributeHorizontally": "",
"distributeVertically": "",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "",
"toggleExportColorScheme": "",
"share": "",
"showStroke": "",
"showBackground": "",
"toggleTheme": ""
},
"buttons": {
"clearReset": "",
"exportJSON": "",
"exportImage": "",
"export": "",
"exportToPng": "",
"exportToSvg": "",
"copyToClipboard": "",
"copyPngToClipboard": "",
"scale": "",
"save": "",
"saveAs": "",
"load": "",
"getShareableLink": "",
"close": "",
"selectLanguage": "",
"scrollBackToContent": "",
"zoomIn": "",
"zoomOut": "",
"resetZoom": "",
"menu": "",
"done": "",
"edit": "",
"undo": "",
"redo": "",
"resetLibrary": "",
"createNewRoom": "",
"fullScreen": "",
"darkMode": "",
"lightMode": "",
"zenMode": "",
"exitZenMode": "",
"cancel": "",
"clear": ""
},
"alerts": {
"clearReset": "",
"couldNotCreateShareableLink": "",
"couldNotCreateShareableLinkTooBig": "",
"couldNotLoadInvalidFile": "",
"importBackendFailed": "",
"cannotExportEmptyCanvas": "",
"couldNotCopyToClipboard": "",
"decryptFailed": "",
"uploadedSecurly": "",
"loadSceneOverridePrompt": "",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "",
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "",
"imageDoesNotContainScene": "",
"cannotRestoreFromImage": "",
"invalidSceneUrl": "",
"resetLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "",
"image": "",
"rectangle": "",
"diamond": "",
"ellipse": "",
"arrow": "",
"line": "",
"freedraw": "",
"text": "",
"library": "",
"lock": ""
},
"headings": {
"canvasActions": "",
"selectedShapeActions": "",
"shapes": ""
},
"hints": {
"linearElement": "",
"freeDraw": "",
"text": "",
"text_selected": "",
"text_editing": "",
"linearElementMulti": "",
"lockAngle": "",
"resize": "",
"resizeImage": "",
"rotate": "",
"lineEditor_info": "",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "",
"canvasTooBig": "",
"canvasTooBigTip": ""
},
"errorSplash": {
"headingMain_pre": "",
"headingMain_button": "",
"clearCanvasMessage": "",
"clearCanvasMessage_button": "",
"clearCanvasCaveat": "",
"trackedToSentry_pre": "",
"trackedToSentry_post": "",
"openIssueMessage_pre": "",
"openIssueMessage_button": "",
"openIssueMessage_post": "",
"sceneContent": ""
},
"roomDialog": {
"desc_intro": "",
"desc_privacy": "",
"button_startSession": "",
"button_stopSession": "",
"desc_inProgressIntro": "",
"desc_shareLink": "",
"desc_exitSession": "",
"shareTitle": ""
},
"errorDialog": {
"title": ""
},
"exportDialog": {
"disk_title": "",
"disk_details": "",
"disk_button": "",
"link_title": "",
"link_details": "",
"link_button": "",
"excalidrawplus_description": "",
"excalidrawplus_button": "",
"excalidrawplus_exportError": ""
},
"helpDialog": {
"blog": "",
"click": "",
"curvedArrow": "",
"curvedLine": "",
"documentation": "",
"doubleClick": "",
"drag": "",
"editor": "",
"editSelectedShape": "",
"github": "",
"howto": "",
"or": "",
"preventBinding": "",
"shapes": "",
"shortcuts": "",
"textFinish": "",
"textNewLine": "",
"title": "",
"view": "",
"zoomToFit": "",
"zoomToSelection": ""
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "",
"link": ""
},
"stats": {
"angle": "",
"element": "",
"elements": "",
"height": "",
"scene": "",
"selected": "",
"storage": "",
"title": "",
"total": "",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": ""
},
"toast": {
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "",
"fileSaved": "",
"fileSavedToFilename": "",
"canvas": "",
"selection": ""
},
"colors": {
"ffffff": "",
"f8f9fa": "",
"f1f3f5": "",
"fff5f5": "",
"fff0f6": "",
"f8f0fc": "",
"f3f0ff": "",
"edf2ff": "",
"e7f5ff": "",
"e3fafc": "",
"e6fcf5": "",
"ebfbee": "",
"f4fce3": "",
"fff9db": "",
"fff4e6": "",
"transparent": "",
"ced4da": "",
"868e96": "",
"fa5252": "",
"e64980": "",
"be4bdb": "",
"7950f2": "",
"4c6ef5": "",
"228be6": "",
"15aabf": "",
"12b886": "",
"40c057": "",
"82c91e": "",
"fab005": "",
"fd7e14": "",
"000000": "",
"343a40": "",
"495057": "",
"c92a2a": "",
"a61e4d": "",
"862e9c": "",
"5f3dc4": "",
"364fc7": "",
"1864ab": "",
"0b7285": "",
"087f5b": "",
"2b8a3e": "",
"5c940d": "",
"e67700": "",
"d9480f": ""
}
}
+29 -44
View File
@@ -136,9 +136,7 @@
"darkMode": "Mode fosc",
"lightMode": "Mode clar",
"zenMode": "Mode zen",
"exitZenMode": "Surt de mode zen",
"cancel": "",
"clear": ""
"exitZenMode": "Surt de mode zen"
},
"alerts": {
"clearReset": "S'esborrarà tot el llenç. N'esteu segur?",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "No s'ha pogut afegir l'element a la biblioteca",
"errorRemovingFromLibrary": "No s'ha pogut eliminar l'element de la biblioteca",
"confirmAddLibrary": "Això afegirà {{numShapes}} forma(es) a la vostra biblioteca. Estàs segur?",
"imageDoesNotContainScene": "",
"imageDoesNotContainScene": "En aquest moment no sadmet la importació dimatges.\n\nVolies importar una escena? Sembla que aquesta imatge no conté cap dada descena. Ho has activat durant l'exportació?",
"cannotRestoreFromImage": "Lescena no sha pogut restaurar des daquest fitxer dimatge",
"invalidSceneUrl": "No s'ha pogut importar l'escena des de l'adreça URL proporcionada. Està malformada o no conté dades Excalidraw JSON vàlides.",
"resetLibrary": "Això buidarà la biblioteca. N'esteu segur?",
"invalidEncryptionKey": ""
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "Selecció",
"image": "",
"rectangle": "Rectangle",
"diamond": "Rombe",
"ellipse": "El·lipse",
@@ -196,12 +186,10 @@
"linearElementMulti": "Feu clic a l'ultim punt, o pitgeu Esc o Retorn per a finalitzar",
"lockAngle": "Per restringir els angles, mantenir premut el majúscul (SHIFT)",
"resize": "Per restringir les proporcions mentres es canvia la mida, mantenir premut el majúscul (SHIFT); per canviar la mida des del centre, mantenir premut ALT",
"resizeImage": "",
"rotate": "Per restringir els angles mentre gira, mantenir premut el majúscul (SHIFT)",
"lineEditor_info": "Fes doble clic o premi Enter per editar punts",
"lineEditor_pointSelected": "Premeu Suprimir per a eliminar el punt, CtrlOrCmd+D per a duplicar-lo, o arrossegueu-lo per a moure'l",
"lineEditor_nothingSelected": "Selecciona un punt per moure o eliminar, o manté premut Alt i fes clic per afegir punts nous",
"placeImage": ""
"lineEditor_nothingSelected": "Selecciona un punt per moure o eliminar, o manté premut Alt i fes clic per afegir punts nous"
},
"canvasError": {
"cannotShowPreview": "No es pot mostrar la previsualització",
@@ -268,9 +256,6 @@
"zoomToFit": "Zoom per veure tots els elements",
"zoomToSelection": "Zoom per veure la selecció"
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "Els vostres dibuixos estan xifrats de punta a punta de manera que els servidors dExcalidraw no els veuran mai.",
"link": "Article del blog sobre encriptació d'extrem a extrem a Excalidraw"
@@ -300,50 +285,50 @@
"selection": "la selecció"
},
"colors": {
"ffffff": "Blanc",
"f8f9fa": "Gris 0",
"f1f3f5": "Gris 1",
"fff5f5": "Vermell 0",
"fff0f6": "Rosa 0",
"ffffff": "",
"f8f9fa": "",
"f1f3f5": "",
"fff5f5": "",
"fff0f6": "",
"f8f0fc": "",
"f3f0ff": "",
"edf2ff": "",
"e7f5ff": "Blau 0",
"e7f5ff": "",
"e3fafc": "",
"e6fcf5": "",
"ebfbee": "Verd 0",
"ebfbee": "",
"f4fce3": "",
"fff9db": "Groc 0",
"fff9db": "",
"fff4e6": "",
"transparent": "Transparent",
"ced4da": "Gris 4",
"868e96": "Gris 6",
"fa5252": "Vermell 6",
"e64980": "Rosa 6",
"transparent": "",
"ced4da": "",
"868e96": "",
"fa5252": "",
"e64980": "",
"be4bdb": "",
"7950f2": "",
"4c6ef5": "",
"228be6": "Blau 6",
"228be6": "",
"15aabf": "",
"12b886": "",
"40c057": "Verd 6",
"40c057": "",
"82c91e": "",
"fab005": "Groc 6",
"fd7e14": "Taronja 6",
"000000": "Negre",
"343a40": "Gris 8",
"495057": "Gris 7",
"c92a2a": "Vermell 9",
"a61e4d": "Rosa 9",
"fab005": "",
"fd7e14": "",
"000000": "",
"343a40": "",
"495057": "",
"c92a2a": "",
"a61e4d": "",
"862e9c": "",
"5f3dc4": "",
"364fc7": "",
"1864ab": "Blau 9",
"1864ab": "",
"0b7285": "",
"087f5b": "",
"2b8a3e": "Verd 9",
"2b8a3e": "",
"5c940d": "",
"e67700": "Groc 9",
"d9480f": "Taronja 9"
"e67700": "",
"d9480f": ""
}
}
+2 -17
View File
@@ -136,9 +136,7 @@
"darkMode": "Tmavý režim",
"lightMode": "Světlý režim",
"zenMode": "Zen mód",
"exitZenMode": "Opustit zen mód",
"cancel": "",
"clear": ""
"exitZenMode": "Opustit zen mód"
},
"alerts": {
"clearReset": "",
@@ -162,16 +160,8 @@
"resetLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "Výběr",
"image": "",
"rectangle": "Obdélník",
"diamond": "Diamant",
"ellipse": "Elipsa",
@@ -196,12 +186,10 @@
"linearElementMulti": "",
"lockAngle": "",
"resize": "",
"resizeImage": "",
"rotate": "",
"lineEditor_info": "",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": ""
"lineEditor_nothingSelected": ""
},
"canvasError": {
"cannotShowPreview": "",
@@ -268,9 +256,6 @@
"zoomToFit": "",
"zoomToSelection": ""
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "",
"link": ""
+2 -17
View File
@@ -136,9 +136,7 @@
"darkMode": "Mørk tilstand",
"lightMode": "Lys baggrund",
"zenMode": "",
"exitZenMode": "",
"cancel": "",
"clear": ""
"exitZenMode": ""
},
"alerts": {
"clearReset": "",
@@ -162,16 +160,8 @@
"resetLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "",
"image": "",
"rectangle": "",
"diamond": "",
"ellipse": "",
@@ -196,12 +186,10 @@
"linearElementMulti": "",
"lockAngle": "",
"resize": "",
"resizeImage": "",
"rotate": "",
"lineEditor_info": "",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": ""
"lineEditor_nothingSelected": ""
},
"canvasError": {
"cannotShowPreview": "",
@@ -268,9 +256,6 @@
"zoomToFit": "",
"zoomToSelection": ""
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "",
"link": ""
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "Dunkler Modus",
"lightMode": "Heller Modus",
"zenMode": "Zen-Modus",
"exitZenMode": "Zen-Modus verlassen",
"cancel": "Abbrechen",
"clear": "Löschen"
"exitZenMode": "Zen-Modus verlassen"
},
"alerts": {
"clearReset": "Dies wird die ganze Zeichenfläche löschen. Bist du dir sicher?",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "Das Element konnte nicht zur Bibliothek hinzugefügt werden",
"errorRemovingFromLibrary": "Das Element konnte nicht aus der Bibliothek entfernt werden",
"confirmAddLibrary": "Dies fügt {{numShapes}} Form(en) zu deiner Bibliothek hinzu. Bist du dir sicher?",
"imageDoesNotContainScene": "Dieses Bild scheint keine Szenendaten zu enthalten. Hast Du das Einbetten der Szene während des Exports aktiviert?",
"imageDoesNotContainScene": "Das Importieren von Bildern wird derzeit nicht unterstützt.\n\nMöchtest du eine Szene importieren? Dieses Bild scheint keine Zeichnungsdaten zu enthalten. Hast du dies beim Exportieren aktiviert?",
"cannotRestoreFromImage": "Die Zeichnung konnte aus dieser Bilddatei nicht wiederhergestellt werden",
"invalidSceneUrl": "Die Szene konnte nicht von der angegebenen URL importiert werden. Sie ist entweder fehlerhaft oder enthält keine gültigen Excalidraw JSON-Daten.",
"resetLibrary": "Dieses löscht deine Bibliothek. Bist du sicher?",
"invalidEncryptionKey": "Verschlüsselungsschlüssel muss 22 Zeichen lang sein. Die Live-Zusammenarbeit ist deaktiviert."
},
"errors": {
"unsupportedFileType": "Nicht unterstützter Dateityp.",
"imageInsertError": "Das Bild konnte nicht eingefügt werden. Versuche es später erneut...",
"fileTooBig": "Die Datei ist zu groß. Die maximal zulässige Größe ist {{maxSize}}.",
"svgImageInsertError": "SVG-Bild konnte nicht eingefügt werden. Das SVG-Markup sieht ungültig aus.",
"invalidSVGString": "errors.invalidSVGString"
},
"toolBar": {
"selection": "Auswahl",
"image": "Bild einfügen",
"rectangle": "Rechteck",
"diamond": "Raute",
"ellipse": "Ellipse",
@@ -196,12 +186,10 @@
"linearElementMulti": "Zum Beenden auf den letzten Punkt klicken oder Escape oder Eingabe drücken",
"lockAngle": "Du kannst Winkel einschränken, indem du SHIFT gedrückt hältst",
"resize": "Du kannst die Proportionen einschränken, indem du SHIFT während der Größenänderung gedrückt hältst. Halte ALT gedrückt, um die Größe vom Zentrum aus zu ändern",
"resizeImage": "Du kannst die Größe frei ändern, indem du SHIFT gedrückt hältst; halte ALT, um die Größe vom Zentrum aus zu ändern",
"rotate": "Du kannst Winkel einschränken, indem du SHIFT während der Drehung gedrückt hältst",
"lineEditor_info": "Doppelklicken oder Eingabetaste drücken, um Punkte zu bearbeiten",
"lineEditor_pointSelected": "Drücke Löschen, um Punkt zu entfernen, Strg+D oder Cmd+D zum Duplizieren oder ziehe zum Verschieben",
"lineEditor_nothingSelected": "Wähle einen Punkt zum Verschieben oder Löschen oder halte die Alt-Taste gedrückt und klicke, um neue Punkte hinzuzufügen",
"placeImage": "Klicken, um das Bild zu platzieren oder klicken und ziehen um seine Größe manuell zu setzen"
"lineEditor_nothingSelected": "Wähle einen Punkt zum Verschieben oder Löschen oder halte die Alt-Taste gedrückt und klicke, um neue Punkte hinzuzufügen"
},
"canvasError": {
"cannotShowPreview": "Vorschau kann nicht angezeigt werden",
@@ -268,9 +256,6 @@
"zoomToFit": "Zoomen um alle Elemente einzupassen",
"zoomToSelection": "Auf Auswahl zoomen"
},
"clearCanvasDialog": {
"title": "Zeichenfläche löschen"
},
"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"
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "Σκοτεινή λειτουργία",
"lightMode": "Φωτεινή λειτουργία",
"zenMode": "Λειτουργία Zεν",
"exitZenMode": "Έξοδος από την λειτουργία Zen",
"cancel": "",
"clear": ""
"exitZenMode": "Έξοδος από την λειτουργία Zen"
},
"alerts": {
"clearReset": "Αυτό θα σβήσει ολόκληρο τον καμβά. Είσαι σίγουρος;",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβλιοθήκη σας. Είστε σίγουροι;",
"imageDoesNotContainScene": "",
"imageDoesNotContainScene": "Η εισαγωγή εικόνων δεν υποστηρίζεται αυτή τη στιγμή.\n\nΜήπως θέλετε να εισαγάγετε μια σκηνή; Αυτή η εικόνα δεν φαίνεται να περιέχει δεδομένα σκηνής. Έχετε ενεργοποιήσει αυτό κατά την εξαγωγή;",
"cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας",
"invalidSceneUrl": "",
"resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;",
"invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη."
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "Επιλογή",
"image": "",
"rectangle": "Ορθογώνιο",
"diamond": "Ρόμβος",
"ellipse": "Έλλειψη",
@@ -196,12 +186,10 @@
"linearElementMulti": "Κάνε κλικ στο τελευταίο σημείο ή πάτησε Escape ή Enter για να τελειώσεις",
"lockAngle": "Μπορείτε να περιορίσετε τη γωνία κρατώντας πατημένο το SHIFT",
"resize": "Μπορείς να περιορίσεις τις αναλογίες κρατώντας το SHIFT ενώ αλλάζεις μέγεθος,\nκράτησε πατημένο το ALT για αλλαγή μεγέθους από το κέντρο",
"resizeImage": "",
"rotate": "Μπορείς να περιορίσεις τις γωνίες κρατώντας πατημένο το πλήκτρο SHIFT κατά την περιστροφή",
"lineEditor_info": "Διπλό-κλικ ή πιέστε Enter για να επεξεργαστείτε τα σημεία",
"lineEditor_pointSelected": "Πιέστε Διαγραφή για να αφαιρέσετε το σημείου, CtrlOrCmd+D για να το αντιγράψετε ή σύρτε το για να το μετακινήσετε",
"lineEditor_nothingSelected": "Επιλέξτε ένα σημείο για μετακίνηση ή αφαίρεση, ή κρατήστε παρατεταμένα το Alt και κάντε κλικ για να προσθέσετε νέα σημεία",
"placeImage": ""
"lineEditor_nothingSelected": "Επιλέξτε ένα σημείο για μετακίνηση ή αφαίρεση, ή κρατήστε παρατεταμένα το Alt και κάντε κλικ για να προσθέσετε νέα σημεία"
},
"canvasError": {
"cannotShowPreview": "Αδυναμία εμφάνισης προεπισκόπησης",
@@ -268,9 +256,6 @@
"zoomToFit": "Zoom ώστε να χωρέσουν όλα τα στοιχεία",
"zoomToSelection": "Ζουμ στην επιλογή"
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "Τα σχέδιά σου είναι κρυπτογραφημένα από άκρο σε άκρο, έτσι δεν θα είναι ποτέ ορατά μέσα από τους διακομιστές του Excalidraw.",
"link": ""
+3 -20
View File
@@ -39,7 +39,6 @@
"arrowhead_arrow": "Arrow",
"arrowhead_bar": "Bar",
"arrowhead_dot": "Dot",
"arrowhead_triangle": "Triangle",
"fontSize": "Font size",
"fontFamily": "Font family",
"onlySelected": "Only selected",
@@ -50,7 +49,6 @@
"handDrawn": "Hand-drawn",
"normal": "Normal",
"code": "Code",
"wireframe": "Wireframe",
"small": "Small",
"medium": "Medium",
"large": "Large",
@@ -138,9 +136,7 @@
"darkMode": "Dark mode",
"lightMode": "Light mode",
"zenMode": "Zen mode",
"exitZenMode": "Exit zen mode",
"cancel": "Cancel",
"clear": "Clear"
"exitZenMode": "Exit zen mode"
},
"alerts": {
"clearReset": "This will clear the whole canvas. Are you sure?",
@@ -158,22 +154,14 @@
"errorAddingToLibrary": "Couldn't add item to the library",
"errorRemovingFromLibrary": "Couldn't remove item from the library",
"confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
"imageDoesNotContainScene": "This image does not seem to contain any scene data. Have you enabled scene embedding during export?",
"imageDoesNotContainScene": "Importing images isn't supported at the moment.\n\nDid you want to import a scene? This image does not seem to contain any scene data. Have you enabled this during export?",
"cannotRestoreFromImage": "Scene couldn't be restored from this image file",
"invalidSceneUrl": "Couldn't import scene from the supplied URL. It's either malformed, or doesn't contain valid Excalidraw JSON data.",
"resetLibrary": "This will clear your library. Are you sure?",
"invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled."
},
"errors": {
"unsupportedFileType": "Unsupported file type.",
"imageInsertError": "Couldn't insert image. Try again later...",
"fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.",
"svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.",
"invalidSVGString": "errors.invalidSVGString"
},
"toolBar": {
"selection": "Selection",
"image": "Insert image",
"rectangle": "Rectangle",
"diamond": "Diamond",
"ellipse": "Ellipse",
@@ -198,12 +186,10 @@
"linearElementMulti": "Click on last point or press Escape or Enter to finish",
"lockAngle": "You can constrain angle by holding SHIFT",
"resize": "You can constrain proportions by holding SHIFT while resizing,\nhold ALT to resize from the center",
"resizeImage": "You can resize freely by holding SHIFT,\nhold ALT to resize from the center",
"rotate": "You can constrain angles by holding SHIFT while rotating",
"lineEditor_info": "Double-click or press Enter to edit points",
"lineEditor_pointSelected": "Press Delete to remove point, CtrlOrCmd+D to duplicate, or drag to move",
"lineEditor_nothingSelected": "Select a point to move or remove, or hold Alt and click to add new points",
"placeImage": "Click to place the image, or click and drag to set its size manually"
"lineEditor_nothingSelected": "Select a point to move or remove, or hold Alt and click to add new points"
},
"canvasError": {
"cannotShowPreview": "Cannot show preview",
@@ -270,9 +256,6 @@
"zoomToFit": "Zoom to fit all elements",
"zoomToSelection": "Zoom to selection"
},
"clearCanvasDialog": {
"title": "Clear Canvas"
},
"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"
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "Modo oscuro",
"lightMode": "Modo claro",
"zenMode": "Modo Zen",
"exitZenMode": "Salir del modo Zen",
"cancel": "",
"clear": ""
"exitZenMode": "Salir del modo Zen"
},
"alerts": {
"clearReset": "Esto limpiará todo el lienzo. Estás seguro?",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "No se pudo agregar elemento a la biblioteca",
"errorRemovingFromLibrary": "No se pudo quitar el elemento de la biblioteca",
"confirmAddLibrary": "Esto añadirá {{numShapes}} forma(s) a tu biblioteca. ¿Estás seguro?",
"imageDoesNotContainScene": "",
"imageDoesNotContainScene": "La importación de imágenes no está homologada en este momento.\n\n¿Deseas importar una escena? Esta imagen no parece contener ningún dato de escena. ¿Lo has activado durante la exportación?",
"cannotRestoreFromImage": "No se pudo restaurar la escena desde este archivo de imagen",
"invalidSceneUrl": "No se ha podido importar la escena desde la URL proporcionada. Está mal formada, o no contiene datos de Excalidraw JSON válidos.",
"resetLibrary": "Esto eliminará tu librería. ¿Estás seguro?",
"invalidEncryptionKey": "La clave de cifrado debe tener 22 caracteres. La colaboración en vivo está deshabilitada."
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "Selección",
"image": "",
"rectangle": "Rectángulo",
"diamond": "Diamante",
"ellipse": "Elipse",
@@ -196,12 +186,10 @@
"linearElementMulti": "Haz clic en el último punto o presiona Escape o Enter para finalizar",
"lockAngle": "Puedes restringir el ángulo manteniendo presionado el botón SHIFT",
"resize": "Para mantener las proporciones mantén SHIFT presionado mientras modificas el tamaño, \nmantén presionado ALT para modificar el tamaño desde el centro",
"resizeImage": "",
"rotate": "Puedes restringir los ángulos manteniendo presionado SHIFT mientras giras",
"lineEditor_info": "Doble clic o pulse Enter para editar puntos",
"lineEditor_pointSelected": "Presione Suprimir para eliminar el punto, CtrlOrCmd+D para duplicarlo, o arrástrelo para moverlo",
"lineEditor_nothingSelected": "Selecciona un punto sea para mover o eliminar, o mantén pulsado Alt y haz clic para añadir nuevos puntos",
"placeImage": ""
"lineEditor_nothingSelected": "Selecciona un punto sea para mover o eliminar, o mantén pulsado Alt y haz clic para añadir nuevos puntos"
},
"canvasError": {
"cannotShowPreview": "No se puede mostrar la vista previa",
@@ -268,9 +256,6 @@
"zoomToFit": "Ajustar la vista para mostrar todos los elementos",
"zoomToSelection": "Hacer zoom a la selección"
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "Tus dibujos están cifrados de punto a punto, por lo que los servidores de Excalidraw nunca los verán.",
"link": "Entrada en el blog sobre cifrado de extremo a extremo"
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "حالت تیره",
"lightMode": "حالت روشن",
"zenMode": "حالت ذن",
"exitZenMode": "خروج از حالت تمرکز",
"cancel": "",
"clear": ""
"exitZenMode": "خروج از حالت تمرکز"
},
"alerts": {
"clearReset": "این کار کل صفحه را پاک میکند. آیا مطمئنید؟",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "مورد به کتابخانه اضافه نشد",
"errorRemovingFromLibrary": "مورد از کتابخانه حذف نشد",
"confirmAddLibrary": "{{numShapes}} از اشکال به کتابخانه شما اضافه خواهد شد. مطمئن هستید؟",
"imageDoesNotContainScene": "",
"imageDoesNotContainScene": "وارد کردن تصویر در این لحظه امکان پذیر نمی باشد.\nآیا مایل به وارد کردن یک صحنه هستید؟ این تصویر به نظر می رسد که فاقد هرگونه اطلاعاتی مربوط به صحنه باشد. آیا این گزینه را در زمان وارد کردن تصویر فعال کرده اید؟",
"cannotRestoreFromImage": "صحنه را نمی توان از این فایل تصویری بازیابی کرد",
"invalidSceneUrl": "",
"resetLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "گزینش",
"image": "",
"rectangle": "مستطیل",
"diamond": "لوزی",
"ellipse": "بیضی",
@@ -196,12 +186,10 @@
"linearElementMulti": "روی آخرین نقطه کلیک کنید یا کلید ESC را بزنید یا کلید Enter را بزنید برای اتمام کار",
"lockAngle": "با نگه داشتن SHIFT هنگام چرخش می توانید زاویه ها را محدود کنید",
"resize": "می توانید با نگه داشتن SHIFT در هنگام تغییر اندازه، نسبت ها را محدود کنید،ALT را برای تغییر اندازه از مرکز نگه دارید",
"resizeImage": "",
"rotate": "با نگه داشتن SHIFT هنگام چرخش می توانید زاویه ها را محدود کنید",
"lineEditor_info": "دوبار کلیک کنید یا Enter را فشار دهید تا نقاط را ویرایش کنید",
"lineEditor_pointSelected": "برای حذف نقطه Delete برای کپی زدن Ctrl یا Cmd+D را بزنید و یا برای جابجایی بکشید.",
"lineEditor_nothingSelected": "یک نقطه را برای جابجایی یا حذف انتخاب کنید یا کلید Alt بگیرید و کلیک کنید تا بتوانید یک نقطه جدید اضافه کنید",
"placeImage": ""
"lineEditor_nothingSelected": "یک نقطه را برای جابجایی یا حذف انتخاب کنید یا کلید Alt بگیرید و کلیک کنید تا بتوانید یک نقطه جدید اضافه کنید"
},
"canvasError": {
"cannotShowPreview": "پیش نمایش نشان داده نمی شود",
@@ -268,9 +256,6 @@
"zoomToFit": "بزرگنمایی برای دیدن تمام آیتم ها",
"zoomToSelection": "بزرگنمایی قسمت انتخاب شده"
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "شما در یک محیط رمزگزاری شده دو طرفه در حال طراحی هستید پس Excalidraw هرگز طرح های شما را نمیبند.",
"link": ""
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "Tumma tila",
"lightMode": "Vaalea tila",
"zenMode": "Zen-tila",
"exitZenMode": "Poistu zen-tilasta",
"cancel": "",
"clear": ""
"exitZenMode": "Poistu zen-tilasta"
},
"alerts": {
"clearReset": "Tämä tyhjentää koko piirtoalueen. Jatketaanko?",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "Kohdetta ei voitu lisätä kirjastoon",
"errorRemovingFromLibrary": "Kohdetta ei voitu poistaa kirjastosta",
"confirmAddLibrary": "Tämä lisää {{numShapes}} muotoa kirjastoosi. Jatketaanko?",
"imageDoesNotContainScene": "Tämä kuva ei näytä sisältävän piirrostietoja. Oletko ottanut käyttöön piirroksen tallennuksen viennin aikana?",
"imageDoesNotContainScene": "Kuvien lisääminen ei ole tällä hetkellä mahdollista.\n\nHaluatko tuoda piirroksen? Tämä kuva ei näytä sisältävän tarvittavia tietoja. Oletko ottanut piirrostietojen tallennuksen käyttöön viennin aikana?",
"cannotRestoreFromImage": "Teosta ei voitu palauttaa tästä kuvatiedostosta",
"invalidSceneUrl": "Teosta ei voitu tuoda annetusta URL-osoitteesta. Tallenne on vioittunut, tai osoitteessa ei ole Excalidraw JSON-dataa.",
"resetLibrary": "Tämä tyhjentää kirjastosi. Jatketaanko?",
"invalidEncryptionKey": "Salausavaimen on oltava 22 merkkiä pitkä. Live-yhteistyö ei ole käytössä."
},
"errors": {
"unsupportedFileType": "Tiedostotyyppiä ei tueta.",
"imageInsertError": "Kuvan lisääminen epäonnistui. Yritä myöhemmin uudelleen...",
"fileTooBig": "Tiedosto on liian suuri. Suurin sallittu koko on {{maxSize}}.",
"svgImageInsertError": "SVG- kuvaa ei voitu lisätä. Tiedoston SVG-sisältö näyttää virheelliseltä.",
"invalidSVGString": "errors.invalidSVGString"
},
"toolBar": {
"selection": "Valinta",
"image": "Lisää kuva",
"rectangle": "Suorakulmio",
"diamond": "Vinoneliö",
"ellipse": "Soikio",
@@ -196,12 +186,10 @@
"linearElementMulti": "Lopeta klikkaamalla viimeistä pistettä, painamalla Escape- tai Enter-näppäintä",
"lockAngle": "Voit rajoittaa kulmaa pitämällä SHIFT-näppäintä alaspainettuna",
"resize": "Voit rajoittaa mittasuhteet pitämällä SHIFT-näppäintä alaspainettuna kun muutat kokoa, pidä ALT-näppäintä alaspainettuna muuttaaksesi kokoa keskipisteen suhteen",
"resizeImage": "Voit muuttaa kokoa vapaasti pitämällä SHIFTiä pohjassa, pidä ALT pohjassa muuttaaksesi kokoa keskipisteen ympäri",
"rotate": "Voit rajoittaa kulman pitämällä SHIFT pohjassa pyörittäessäsi",
"lineEditor_info": "Kaksoisnapauta tai paina Enter muokataksesi pisteitä",
"lineEditor_pointSelected": "Paina Delete poistaaksesi pisteen, Ctrl tai Cmd+D monistaaksesi, tai raahaa liikuttaaksesi",
"lineEditor_nothingSelected": "Valitse liikutettava tai poistettava piste, tai pidä ALT-näppäintä alaspainettuna ja napsauta lisätäksesi uusia pisteitä",
"placeImage": "Klikkaa asettaaksesi kuvan, tai klikkaa ja raahaa asettaaksesi sen koon manuaalisesti"
"lineEditor_nothingSelected": "Valitse liikutettava tai poistettava piste, tai pidä ALT-näppäintä alaspainettuna ja napsauta lisätäksesi uusia pisteitä"
},
"canvasError": {
"cannotShowPreview": "Esikatselua ei voitu näyttää",
@@ -268,9 +256,6 @@
"zoomToFit": "Näytä kaikki elementit",
"zoomToSelection": "Näytä valinta"
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "Piirroksesi ovat päästä-päähän-salattuja, joten Excalidrawin palvelimet eivät koskaan näe niitä.",
"link": "Blogiartikkeli päästä päähän -salauksesta Excalidraw:ssa"
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "Mode sombre",
"lightMode": "Mode clair",
"zenMode": "Mode zen",
"exitZenMode": "Quitter le mode zen",
"cancel": "Annuler",
"clear": "Effacer"
"exitZenMode": "Quitter le mode zen"
},
"alerts": {
"clearReset": "L'intégralité du canevas va être effacée. Êtes-vous sûr ?",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "Impossible d'ajouter l'élément à la bibliothèque",
"errorRemovingFromLibrary": "Impossible de retirer l'élément de la bibliothèque",
"confirmAddLibrary": "Cela va ajouter {{numShapes}} forme(s) à votre bibliothèque. Êtes-vous sûr·e ?",
"imageDoesNotContainScene": "Cette image ne semble pas contenir de données de scène. Avez-vous activé l'intégration de scène lors de l'exportation ?",
"imageDoesNotContainScene": "L'importation d'images n'est pas prise en charge pour le moment.\n\nVouliez-vous importer une scène ? Cette image ne semble pas contenir de données de scène. Avez-vous activé cette option lors de l'exportation ?",
"cannotRestoreFromImage": "Impossible de restaurer la scène depuis ce fichier image",
"invalidSceneUrl": "Impossible d'importer la scène depuis l'URL fournie. Elle est soit incorrecte, soit ne contient pas de données JSON Excalidraw valides.",
"resetLibrary": "Cela va effacer votre bibliothèque. Êtes-vous sûr·e ?",
"invalidEncryptionKey": "La clé de chiffrement doit comporter 22 caractères. La collaboration en direct est désactivée."
},
"errors": {
"unsupportedFileType": "Type de fichier non supporté.",
"imageInsertError": "Impossible d'insérer l'image. Réessayez plus tard...",
"fileTooBig": "Le fichier est trop volumineux. La taille maximale autorisée est de {{maxSize}}.",
"svgImageInsertError": "Impossible d'insérer l'image SVG. Le balisage SVG semble invalide.",
"invalidSVGString": "errors.invalidSVGString"
},
"toolBar": {
"selection": "Sélection",
"image": "Insérer une image",
"rectangle": "Rectangle",
"diamond": "Losange",
"ellipse": "Ellipse",
@@ -196,12 +186,10 @@
"linearElementMulti": "Cliquez sur le dernier point ou appuyez sur Échap ou Entrée pour terminer",
"lockAngle": "Vous pouvez restreindre l'angle en maintenant MAJ",
"resize": "Vous pouvez conserver les proportions en maintenant la touche MAJ pendant le redimensionnement,\nmaintenez la touche ALT pour redimensionner par rapport au centre",
"resizeImage": "Vous pouvez redimensionner librement en maintenant SHIFT,\nmaintenez ALT pour redimensionner depuis le centre",
"rotate": "Vous pouvez restreindre les angles en maintenant MAJ pendant la rotation",
"lineEditor_info": "Double-cliquez ou appuyez sur Entrée pour éditer les points",
"lineEditor_pointSelected": "Appuyez sur Supprimer pour supprimer le point, Ctrl ou Cmd+D pour le dupliquer, ou faites-le glisser pour le déplacer",
"lineEditor_nothingSelected": "Sélectionnez un point à déplacer ou supprimer, ou maintenez Alt et cliquez pour ajouter de nouveaux points",
"placeImage": "Cliquez pour placer l'image, ou cliquez et faites glisser pour définir sa taille manuellement"
"lineEditor_nothingSelected": "Sélectionnez un point à déplacer ou supprimer, ou maintenez Alt et cliquez pour ajouter de nouveaux points"
},
"canvasError": {
"cannotShowPreview": "Impossible dafficher laperçu",
@@ -268,9 +256,6 @@
"zoomToFit": "Zoomer pour voir tous les éléments",
"zoomToSelection": "Zoomer sur la sélection"
},
"clearCanvasDialog": {
"title": "Effacer la zone de dessin"
},
"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"
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "מצב כהה",
"lightMode": "מצב בהיר",
"zenMode": "מצב זן",
"exitZenMode": "צא ממצב תפריט מרחף",
"cancel": "",
"clear": ""
"exitZenMode": "צא ממצב תפריט מרחף"
},
"alerts": {
"clearReset": "פעולה זו תנקה את כל הלוח. אתה בטוח?",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "לא ניתן להוסיף פריט לספרייה",
"errorRemovingFromLibrary": "לא ניתן למחוק פריט מהספריה",
"confirmAddLibrary": "הפעולה תוסיף {{numShapes}} צורה(ות) לספריה שלך. האם אתה בטוח?",
"imageDoesNotContainScene": "",
"imageDoesNotContainScene": "אין תמיכה בייבוא תמונות כעת.\n\nהאם אתה רוצה לייבא תצוגה? התמונה הזאת אינה מכילה מידע על תצוגה. האם הפעלת את האפשרות הזאת בזמן הוצאת המידע?",
"cannotRestoreFromImage": "לא הצלחנו לשחזר את התצוגה מקובץ התמונה",
"invalidSceneUrl": "ייבוא המידע מן סצינה מכתובת האינטרנט נכשלה. המידע בנוי באופן משובש או שהוא אינו קובץ JSON תקין של Excalidraw.",
"resetLibrary": "פעולה זו תנקה את כל הלוח. אתה בטוח?",
"invalidEncryptionKey": ""
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "בחירה",
"image": "",
"rectangle": "מרובע",
"diamond": "מעוין",
"ellipse": "אליפסה",
@@ -196,12 +186,10 @@
"linearElementMulti": "הקלק על הנקודה האחרונה או הקש Escape או Enter לסיום",
"lockAngle": "אתה יכול להגביל זווית ע״י לחיצה על SHIFT",
"resize": "ניתן להגביל פרופורציות על ידי לחיצה על SHIFT תוך כדי שינוי גודל,\nהחזק ALT בשביל לשנות גודל ביחס למרכז",
"resizeImage": "",
"rotate": "ניתן להגביל זוויות על ידי לחיצה על SHIFT תוך כדי סיבוב",
"lineEditor_info": "לחץ לחיצה כפולה או אנטר לעריכת הנקודות",
"lineEditor_pointSelected": "לחץ על Delete להסרת נקודה, CtrlOrCmd+D לשכפל, או גרור להזזה",
"lineEditor_nothingSelected": "בחר נקודה להזזה או הסרה, או החזק את כפתור Alt והקלק להוספת נקודות חדשות",
"placeImage": ""
"lineEditor_nothingSelected": "בחר נקודה להזזה או הסרה, או החזק את כפתור Alt והקלק להוספת נקודות חדשות"
},
"canvasError": {
"cannotShowPreview": "לא הצלחנו להציג את התצוגה המקדימה",
@@ -268,9 +256,6 @@
"zoomToFit": "גלילה להצגת כל האלמנטים במסך",
"zoomToSelection": "התמקד בבחירה"
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "הרישומים שלך מוצפנים מקצה לקצה כך שהשרתים של Excalidraw לא יראו אותם לעולם.",
"link": "פוסט בבלוג על הצפנה מקצה לקצב ב-Excalidraw"
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "डार्क मोड",
"lightMode": "लाइट मोड",
"zenMode": "ज़ेन मोड",
"exitZenMode": "जेन मोड से बाहर निकलें",
"cancel": "",
"clear": ""
"exitZenMode": "जेन मोड से बाहर निकलें"
},
"alerts": {
"clearReset": "इससे पूरा कैनवास साफ हो जाएगा। क्या आपको यकीन है?",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "लाइब्रेरी जोड़ें पुष्‍टि करें आकार संख्या",
"imageDoesNotContainScene": "",
"imageDoesNotContainScene": "दृश्य में छवि नहीं है",
"cannotRestoreFromImage": "छवि फ़ाइल बहाल दृश्य नहीं है",
"invalidSceneUrl": "",
"resetLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "चयन",
"image": "",
"rectangle": "आयात",
"diamond": "तिर्यग्वर्ग",
"ellipse": "दीर्घवृत्त",
@@ -196,12 +186,10 @@
"linearElementMulti": "अंतिम बिंदु पर क्लिक करें या समाप्त होने के लिए एस्केप या एंटर दबाएं",
"lockAngle": "आप घूर्णन करते समय SHIFT पकड़कर कोणों को मोड़ सकते हैं",
"resize": "आकार बदलते समय आप SHIFT को पकड़ कर अनुपात में कमी कर सकते हैं,\nकेंद्र से आकार बदलने के लिए ALT दबाए रखें",
"resizeImage": "",
"rotate": "आप घूर्णन करते समय SHIFT पकड़कर कोणों को विवश कर सकते हैं",
"lineEditor_info": "बिंदुओं को संपादित करने के लिए Enter पर डबल-क्लिक करें या दबाएँ",
"lineEditor_pointSelected": "बिंदु हटाने के लिए डिलीट दबाएं, प्रतिरूपित करने के लिए कण्ट्रोल या कमांड डी दबाएं या स्थानांतरित करने के लिए खींचे",
"lineEditor_nothingSelected": "स्थानांतरित करने या हटाने के लिए एक बिंदु का चयन करें, या Alt दबाए रखें और नए बिंदुओं को जोड़ने के लिए क्लिक करें",
"placeImage": ""
"lineEditor_nothingSelected": "स्थानांतरित करने या हटाने के लिए एक बिंदु का चयन करें, या Alt दबाए रखें और नए बिंदुओं को जोड़ने के लिए क्लिक करें"
},
"canvasError": {
"cannotShowPreview": "पूर्वावलोकन नहीं दिखा सकते हैं",
@@ -268,9 +256,6 @@
"zoomToFit": "सभी तत्वों को फिट करने के लिए ज़ूम करें",
"zoomToSelection": "चयन तक ज़ूम करे"
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "आपके चित्र अंत-से-अंत एन्क्रिप्टेड हैं, इसलिए एक्सक्लूसिव्रॉव के सर्वर उन्हें कभी नहीं देखेंगे।",
"link": ""
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "Sötét mód",
"lightMode": "Világos mód",
"zenMode": "Letisztult mód",
"exitZenMode": "Kilépés a letisztult módból",
"cancel": "",
"clear": ""
"exitZenMode": "Kilépés a letisztult módból"
},
"alerts": {
"clearReset": "Ez a művelet törli a vászont. Biztos benne?",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "Ez a művelet {{numShapes}} formát fog hozzáadni a könyvtáradhoz. Biztos vagy benne?",
"imageDoesNotContainScene": "",
"imageDoesNotContainScene": "Képek importálása egyelőre nem támogatott.\n\nEgy jelenetet szeretnél betölteni? Úgy tűnik ez a kép fájl nem tartalmazza a szükséges adatokat. Exportáláskor ezt egy külön opcióval lehet beállítani.",
"cannotRestoreFromImage": "A jelenet visszaállítása nem sikerült ebből a kép fájlból",
"invalidSceneUrl": "",
"resetLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "Kijelölés",
"image": "",
"rectangle": "Téglalap",
"diamond": "Rombusz",
"ellipse": "Ellipszis",
@@ -196,12 +186,10 @@
"linearElementMulti": "Kattints a következő ív pozíciójára, vagy fejezd be a nyilat az Escape vagy Enter megnyomásával",
"lockAngle": "A SHIFT billentyű lenyomva tartásával korlátozhatja forgatás szögét",
"resize": "A SHIFT billentyű lenyomva tartásával az átméretezés megtartja az arányokat,\naz ALT lenyomva tartásával pedig a középpont egy helyben marad",
"resizeImage": "",
"rotate": "A SHIFT billentyű lenyomva tartásával korlátozhatja a szögek illesztését",
"lineEditor_info": "Kattints duplán, vagy nyomj entert a pontok szerkesztéséhez",
"lineEditor_pointSelected": "Nyomd meg a delete gombot a pont eltávolításához, Ctrl vagy Cmd + D-t a duplikáláshoz, vagy húzva mozgasd",
"lineEditor_nothingSelected": "Válassz ki egy pontot a mozgatáshoz vagy törtléshez, vagy az Alt lenyomása mellett kattintva hozz létre új pontokat",
"placeImage": ""
"lineEditor_nothingSelected": "Válassz ki egy pontot a mozgatáshoz vagy törtléshez, vagy az Alt lenyomása mellett kattintva hozz létre új pontokat"
},
"canvasError": {
"cannotShowPreview": "Előnézet nem jeleníthető meg",
@@ -268,9 +256,6 @@
"zoomToFit": "",
"zoomToSelection": ""
},
"clearCanvasDialog": {
"title": ""
},
"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": ""
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "Mode gelap",
"lightMode": "Mode terang",
"zenMode": "Mode zen",
"exitZenMode": "Keluar dari mode zen",
"cancel": "Batal",
"clear": "Hapus"
"exitZenMode": "Keluar dari mode zen"
},
"alerts": {
"clearReset": "Ini akan menghapus semua yang ada dikanvas. Apakah kamu yakin ?",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "Tidak dapat menambahkan item ke pustaka",
"errorRemovingFromLibrary": "Tidak dapat membuang item dari pustaka",
"confirmAddLibrary": "Ini akan menambahkan {{numShapes}} bentuk ke pustaka Anda. Anda yakin?",
"imageDoesNotContainScene": "Gambar ini sepertinya tidak terdapat data pemandangan. Sudahkah Anda mengaktifkan penyematan pemandangan ketika ekspor?",
"imageDoesNotContainScene": "Mengimpor gambar tidak didukung saat ini.\n\nApakah Anda ingin impor pemandangan? Gambar ini tidak berisi data pemandangan. Sudah ka Anda aktifkan ini ketika ekspor?",
"cannotRestoreFromImage": "Pemandangan tidak dapat dipulihkan dari file gambar ini",
"invalidSceneUrl": "Tidak dapat impor pemandangan dari URL. Kemungkinan URL itu rusak atau tidak berisi data JSON Excalidraw yang valid.",
"resetLibrary": "Ini akan menghapus pustaka Anda. Anda yakin?",
"invalidEncryptionKey": "Sandi enkripsi harus 22 karakter. Kolaborasi langsung dinonaktifkan."
},
"errors": {
"unsupportedFileType": "Tipe file tidak didukung.",
"imageInsertError": "Tidak dapat menyisipkan gambar. Coba lagi nanti...",
"fileTooBig": "File terlalu besar. Ukuran maksimum yang dibolehkan {{maxSize}}.",
"svgImageInsertError": "Tidak dapat menyisipkan gambar SVG. Markup SVG sepertinya tidak valid.",
"invalidSVGString": "errors.invalidSVGString"
},
"toolBar": {
"selection": "Pilihan",
"image": "Sisipkan gambar",
"rectangle": "Persegi",
"diamond": "Berlian",
"ellipse": "Elips",
@@ -196,12 +186,10 @@
"linearElementMulti": "Klik pada titik akhir atau tekan Escape atau Enter untuk menyelesaikan",
"lockAngle": "Anda dapat menjaga sudut dengan menahan SHIFT",
"resize": "Anda dapat menjaga proposi dengan menekan SHIFT sambil mengubah ukuran,\ntekan AlT untuk mengubah ukuran dari tengah",
"resizeImage": "Anda dapat mengubah secara bebas dengan menekan SHIFT,\nTekan ALT untuk mengubah dari tengah",
"rotate": "Anda dapat menjaga sudut dengan menahan SHIFT sambil memutar",
"lineEditor_info": "Klik ganda atau tekan Enter untuk mengedit titik",
"lineEditor_pointSelected": "Tekan Delete untuk menghapus titik, Ctrl/Cmd + D untuk menduplikasi, atau seret untuk memindahkan",
"lineEditor_nothingSelected": "Pilih sebuah titik untuk memindah atau menghapus, atau tekan Alt dan klik untuk menambahkan titik baru",
"placeImage": "Klik untuk tempatkan gambar, atau klik dan jatuhkan untuk tetapkan ukuran secara manual"
"lineEditor_nothingSelected": "Pilih sebuah titik untuk memindah atau menghapus, atau tekan Alt dan klik untuk menambahkan titik baru"
},
"canvasError": {
"cannotShowPreview": "Tidak dapat menampilkan pratinjau",
@@ -268,9 +256,6 @@
"zoomToFit": "Perbesar agar sesuai dengan semua elemen",
"zoomToSelection": "Perbesar ke seleksi"
},
"clearCanvasDialog": {
"title": "Hapus Kanvas"
},
"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"
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "Tema scuro",
"lightMode": "Tema chiaro",
"zenMode": "Modalità Zen",
"exitZenMode": "Uscire dalla modalità zen",
"cancel": "Annulla",
"clear": "Cancella"
"exitZenMode": "Uscire dalla modalità zen"
},
"alerts": {
"clearReset": "Questa azione cancellerà l'intera tela. Sei sicuro?",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "Impossibile aggiungere l'elemento alla libreria",
"errorRemovingFromLibrary": "Impossibile rimuovere l'elemento dalla libreria",
"confirmAddLibrary": "Questo aggiungerà {{numShapes}} forma(e) alla tua libreria. Sei sicuro?",
"imageDoesNotContainScene": "Questa immagine pare non contenere alcuna scena. Avevi incluso la scena durante l'esportazione?",
"imageDoesNotContainScene": "L'importazione di immagini al momento non è supportata.\n\nVuoi importare una scena? Questa immagine non sembra contenere alcun dato di scena. Hai abilitato questa opzione durante l'esportazione?",
"cannotRestoreFromImage": "Impossibile ripristinare la scena da questo file immagine",
"invalidSceneUrl": "Impossibile importare la scena dall'URL fornito. Potrebbe essere malformato o non contenere dati JSON Excalidraw validi.",
"resetLibrary": "Questa azione cancellerà l'intera libreria. Sei sicuro?",
"invalidEncryptionKey": "La chiave di cifratura deve essere composta da 22 caratteri. La collaborazione live è disabilitata."
},
"errors": {
"unsupportedFileType": "Tipo di file non supportato.",
"imageInsertError": "Non è stato possibile inserire l'immagine. Riprova più tardi...",
"fileTooBig": "Il file è troppo grande. La dimensione massima consentita è {{maxSize}}.",
"svgImageInsertError": "Impossibile inserire l'immagine SVG. Il markup SVG non sembra corretto.",
"invalidSVGString": ""
},
"toolBar": {
"selection": "Selezione",
"image": "Inserisci immagine",
"rectangle": "Rettangolo",
"diamond": "Rombo",
"ellipse": "Ellisse",
@@ -196,12 +186,10 @@
"linearElementMulti": "Clicca sull'ultimo punto o premi Esc o Invio per finire",
"lockAngle": "Puoi limitare l'angolo tenendo premuto SHIFT",
"resize": "Per vincolare le proporzioni, tieni premuto MAIUSC durante il ridimensionamento;\nper ridimensionare dal centro, tieni premuto ALT",
"resizeImage": "Puoi ridimensionare liberamente tenendo premuto SHIFT,\ntieni premuto ALT per ridimensionare dal centro",
"rotate": "Puoi mantenere gli angoli tenendo premuto SHIFT durante la rotazione",
"lineEditor_info": "Fai doppio click o premi invio per modificare i punti",
"lineEditor_pointSelected": "Premere Elimina per rimuovere il punto, CtrlOrCmd+D per duplicare o trascinare per spostare",
"lineEditor_nothingSelected": "Seleziona un punto per spostare o rimuovere, oppure tieni premuto Alt e fai clic per aggiungere nuovi punti",
"placeImage": "Fai click per posizionare l'immagine, o click e trascina per impostarne la dimensione manualmente"
"lineEditor_nothingSelected": "Seleziona un punto per spostare o rimuovere, oppure tieni premuto Alt e fai clic per aggiungere nuovi punti"
},
"canvasError": {
"cannotShowPreview": "Impossibile visualizzare l'anteprima",
@@ -268,9 +256,6 @@
"zoomToFit": "Adatta zoom per mostrare tutti gli elementi",
"zoomToSelection": "Zoom alla selezione"
},
"clearCanvasDialog": {
"title": "Svuota la tela"
},
"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"
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "ダークモード",
"lightMode": "ライトモード",
"zenMode": "Zenモード",
"exitZenMode": "集中モードをやめる",
"cancel": "キャンセル",
"clear": "消去"
"exitZenMode": "集中モードをやめる"
},
"alerts": {
"clearReset": "この操作によってキャンバス全体が消えます。よろしいですか?",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "アイテムをライブラリに追加できませんでした",
"errorRemovingFromLibrary": "ライブラリからアイテムを削除できませんでした",
"confirmAddLibrary": "{{numShapes}} 個の図形をライブラリに追加します。よろしいですか?",
"imageDoesNotContainScene": "この画像にはシーンデータが含まれていないようです。エクスポート時にシーンの埋め込みを有効にしましたか?",
"imageDoesNotContainScene": "現在、画像のインポートはサポートされていません。\n\nシーンをインポートしようとしましたか?この画像にはシーンデータが含まれていないようです。エクスポート中に有効にしていましたか?",
"cannotRestoreFromImage": "このイメージファイルからシーンを復元できませんでした",
"invalidSceneUrl": "指定された URL からシーンをインポートできませんでした。不正な形式であるか、有効な Excalidraw JSON データが含まれていません。",
"resetLibrary": "ライブラリを消去します。本当によろしいですか?",
"invalidEncryptionKey": "暗号化キーは22文字でなければなりません。ライブコラボレーションは無効化されています。"
},
"errors": {
"unsupportedFileType": "サポートされていないファイル形式です。",
"imageInsertError": "画像を挿入できませんでした。後でもう一度お試しください...",
"fileTooBig": "ファイルが大きすぎます。許可される最大サイズは {{maxSize}} です。",
"svgImageInsertError": "SVGイメージを挿入できませんでした。SVGマークアップは無効に見えます。",
"invalidSVGString": "errors.invalidSVGString"
},
"toolBar": {
"selection": "選択",
"image": "画像を挿入",
"rectangle": "矩形",
"diamond": "ひし形",
"ellipse": "楕円",
@@ -196,12 +186,10 @@
"linearElementMulti": "最後のポイントをクリックするか、エスケープまたはEnterを押して終了します",
"lockAngle": "SHIFTを押したままにすると、角度を制限することができます",
"resize": "サイズを変更中にSHIFTを押しすと比率を制御できます。Altを押すと中央からサイズを変更できます。",
"resizeImage": "SHIFTを長押しすると自由にサイズを変更できます。\n中央からサイズを変更するにはALTを長押しします",
"rotate": "回転中にSHIFT キーを押すと角度を制限することができます",
"lineEditor_info": "ポイントを編集するには、ダブルクリックまたはEnterキーを押します",
"lineEditor_pointSelected": "削除ボタンを押して点を削除します。Ctrl+D または Cmd+D で複製します。またはドラッグして移動します",
"lineEditor_nothingSelected": "移動または削除する点を選択するか、Altキーを押しながらクリックして新しい点を追加します",
"placeImage": "クリックして画像を配置するか、クリックしてドラッグしてサイズを手動で設定します"
"lineEditor_nothingSelected": "移動または削除する点を選択するか、Altキーを押しながらクリックして新しい点を追加します"
},
"canvasError": {
"cannotShowPreview": "プレビューを表示できません",
@@ -268,9 +256,6 @@
"zoomToFit": "すべての要素が収まるようにズーム",
"zoomToSelection": "選択要素にズーム"
},
"clearCanvasDialog": {
"title": "キャンバスを消去"
},
"encrypted": {
"tooltip": "描画内容はエンドツーエンド暗号化が施されており、Excalidrawサーバーが内容を見ることはできません。",
"link": "Excalidrawのエンドツーエンド暗号化に関するブログ記事"
+4 -19
View File
@@ -136,9 +136,7 @@
"darkMode": "Askar imsulles",
"lightMode": "Askar afaw",
"zenMode": "Askar Zen",
"exitZenMode": "Ffeɣ seg uskar Zen",
"cancel": "Sefsex",
"clear": "Sfeḍ"
"exitZenMode": "Ffeɣ seg uskar Zen"
},
"alerts": {
"clearReset": "Ayagi ad isfeḍ akk taɣzut n usuneɣ. Tetḥeqqeḍ?",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "Ulamek ara yettwarnu uferdis ɣer temkarḍit",
"errorRemovingFromLibrary": "Ulamek ara yettwakkes uferdis si temkarḍit",
"confirmAddLibrary": "Ayagi adirnu talɣa (win) {{numShapes}} ɣer temkarḍit-inek (m). Tetḥeqqeḍ?",
"imageDoesNotContainScene": "Tugna-agi tettban-d ur tesɛi ara isefka n usayes. Tesremdeḍ aseddu n usayes deg usifeḍ?",
"imageDoesNotContainScene": "Taktert n tugniwin ur tettwadhel ara akka tura.\nTebɣiḍ ad tketreḍ asayes? Tugna-agi tettban-d ur tegbir ara isefka n usnas. Tesremdeḍ ayagi deg usifeḍ?",
"cannotRestoreFromImage": "Asayes ulamek ara d-yettwarr seg ufaylu-agi n tugna",
"invalidSceneUrl": "Ulamek taktert n usayes seg URL i d-ittunefken. Ahat mačči d tameɣtut neɣ ur tegbir ara isefka JSON n Excalidraw.",
"resetLibrary": "Ayagi ad isfeḍ tamkarḍit-inek•m. Tetḥeqqeḍ?",
"invalidEncryptionKey": "Tasarut n uwgelhen isefk ad tesɛu 22 n yiekkilen. Amɛiwen srid yensa."
},
"errors": {
"unsupportedFileType": "Anaw n ufaylu ur yettwasefrak ara.",
"imageInsertError": "D awezɣi tugra n tugna. Eɛreḍ tikkelt-nniḍen ardeqqal...",
"fileTooBig": "Afaylu meqqer aṭas. Tiddi tafellayt yurgen d {{maxSize}}.",
"svgImageInsertError": "D awezɣi tugra n tugna SVG. Acraḍ SVG yettban-d d armeɣtu.",
"invalidSVGString": ""
"invalidEncryptionKey": ""
},
"toolBar": {
"selection": "Tafrayt",
"image": "Ger tugna",
"rectangle": "Asrem",
"diamond": "Ameɣṛun",
"ellipse": "Taglayt",
@@ -196,12 +186,10 @@
"linearElementMulti": "Ssit ɣef tenqiḍt taneggarut neɣ ssed taqeffalt Escape neɣ taqeffalt Kcem akken ad tfakkeḍ",
"lockAngle": "Tzemreḍ ad tḥettmeḍ tiɣmert s tuṭṭfa n tqeffalt SHIFT",
"resize": "Tzemreḍ ad tḥettemeḍ assaɣ s tuṭṭfa n tqeffalt SHIFT mi ara tettbeddileḍ tiddi,\nma teṭṭfeḍ ALT abeddel n tiddi ad yili si tlemmast",
"resizeImage": "Tzemreḍ ad talseḍ tiddi s tilelli s tuṭṭfa n SHIFT,\nṭṭef ALT akken ad talseḍ tiddi si tlemmast",
"rotate": "Tzemreḍ ad tḥettemeḍ tiɣemmar s tuṭṭfa n SHIFT di tuzzya",
"lineEditor_info": "Ssit snat n tikkal neɣ ssed taqeffalt Kcem akken ad tẓergeḍ tinqiḍin",
"lineEditor_pointSelected": "Ssed taqeffalt kkes akken ad tekkseḍ tanqiḍt, CtrlOrCmd+D akken ad tsiselgeḍ, neɣ zuɣer akken ad tesmuttiḍ",
"lineEditor_nothingSelected": "Fren tanqiḍt ara tesmuttiḍ neɣ ara tekkseḍ, neɣ ṭṭef taqeffalt Alt akken ad ternuḍ tinqiḍin timaynutin",
"placeImage": "Ssit akken ad tserseḍ tugna, neɣ ssit u zuɣer akken ad tesbaduḍ tiddi-ines s ufus"
"lineEditor_nothingSelected": "Fren tanqiḍt ara tesmuttiḍ neɣ ara tekkseḍ, neɣ ṭṭef taqeffalt Alt akken ad ternuḍ tinqiḍin timaynutin"
},
"canvasError": {
"cannotShowPreview": "Ulamek abeqqeḍ n teskant",
@@ -268,9 +256,6 @@
"zoomToFit": "Simɣur akken ad twliḍ akk iferdisen",
"zoomToSelection": "Simɣur ɣer tefrayt"
},
"clearCanvasDialog": {
"title": "Sfeḍ taɣzut n usuneɣ"
},
"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"
+2 -17
View File
@@ -136,9 +136,7 @@
"darkMode": "",
"lightMode": "",
"zenMode": "",
"exitZenMode": "",
"cancel": "",
"clear": ""
"exitZenMode": ""
},
"alerts": {
"clearReset": "",
@@ -162,16 +160,8 @@
"resetLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "Суретті жүктеу мүмкін болмады. Кейінірек қайталап көріңіз...",
"fileTooBig": "Файл өте үлкен. Максималды рұқсат етілген көлем {{maxSize}}.",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "",
"image": "Суретті қою",
"rectangle": "",
"diamond": "",
"ellipse": "",
@@ -196,12 +186,10 @@
"linearElementMulti": "",
"lockAngle": "",
"resize": "",
"resizeImage": "",
"rotate": "",
"lineEditor_info": "",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": ""
"lineEditor_nothingSelected": ""
},
"canvasError": {
"cannotShowPreview": "",
@@ -268,9 +256,6 @@
"zoomToFit": "Барлық элементтердің көлеміне сәйкес үлкейту",
"zoomToSelection": "Таңдалғанды үлкейту"
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "Сіздің сызбаларыңыз өтпелі шифрлеу арқылы шифрланған, сондықтан Excalidraw серверлері оларды ешқашан көрмейді.",
"link": "Excalidraw қолданатын өтпелі шифрлеу туралы блог жазбасы"
+3 -18
View File
@@ -136,9 +136,7 @@
"darkMode": "다크 모드",
"lightMode": "밝은 모드",
"zenMode": "젠 모드",
"exitZenMode": "젠 모드 종료하기",
"cancel": "",
"clear": ""
"exitZenMode": "젠 모드 종료하기"
},
"alerts": {
"clearReset": "모든 작업 내용이 초기화됩니다. 계속하시겠습니까?",
@@ -156,22 +154,14 @@
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "{{numShapes}}개의 모양이 라이브러리에 추가됩니다. 계속하시겠어요?",
"imageDoesNotContainScene": "",
"imageDoesNotContainScene": "이미지에서 불러오기는 현재 지원되지 않습니다.\n\n화면을 불러오려고 하셨나요? 이미지에 화면 정보가 없는 것 같습니다. 내보낼 때 화면을 포함했나요?",
"cannotRestoreFromImage": "이미지 파일에서 화면을 복구할 수 없었습니다",
"invalidSceneUrl": "",
"resetLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "선택",
"image": "",
"rectangle": "사각형",
"diamond": "다이아몬드",
"ellipse": "타원",
@@ -196,12 +186,10 @@
"linearElementMulti": "마지막 지점을 클릭하거나 Esc 또는 Enter 키를 눌러 완료하세요.",
"lockAngle": "SHIFT 키를 누르면서 회전하면 각도를 제한할 수 있습니다.",
"resize": "SHIFT 키를 누르면서 조정하면 크기의 비율이 제한됩니다.\nALT를 누르면서 조정하면 중앙을 기준으로 크기를 조정합니다.",
"resizeImage": "",
"rotate": "SHIFT 키를 누르면서 회전하면 각도를 제한할 수 있습니다.",
"lineEditor_info": "지점을 수정하려면 두 번 클릭하거나 Enter 키를 누르세요.",
"lineEditor_pointSelected": "제거하려면 Delete 키, 복제하려면 CtrlOrCmd+D, 이동하려면 드래그하세요.",
"lineEditor_nothingSelected": "옮기거나 지울 지점을 선택하거나, Alt를 누른 상태로 클릭해 새 지점을 만드세요",
"placeImage": ""
"lineEditor_nothingSelected": "옮기거나 지울 지점을 선택하거나, Alt를 누른 상태로 클릭해 새 지점을 만드세요"
},
"canvasError": {
"cannotShowPreview": "미리보기를 볼 수 없습니다",
@@ -268,9 +256,6 @@
"zoomToFit": "모든 요소가 보이도록 확대/축소",
"zoomToSelection": "선택 영역으로 확대/축소"
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "그림은 종단 간 암호화되므로 Excalidraw의 서버는 절대로 내용을 알 수 없습니다.",
"link": ""
+2 -17
View File
@@ -136,9 +136,7 @@
"darkMode": "",
"lightMode": "",
"zenMode": "",
"exitZenMode": "",
"cancel": "",
"clear": ""
"exitZenMode": ""
},
"alerts": {
"clearReset": "",
@@ -162,16 +160,8 @@
"resetLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "",
"image": "",
"rectangle": "",
"diamond": "",
"ellipse": "",
@@ -196,12 +186,10 @@
"linearElementMulti": "",
"lockAngle": "",
"resize": "",
"resizeImage": "",
"rotate": "",
"lineEditor_info": "",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": ""
"lineEditor_nothingSelected": ""
},
"canvasError": {
"cannotShowPreview": "",
@@ -268,9 +256,6 @@
"zoomToFit": "",
"zoomToSelection": ""
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "",
"link": ""

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