Compare commits

..

14 Commits

Author SHA1 Message Date
Preet Shihn
b20fe944e7 add max shape count 2021-02-05 10:27:06 -08:00
Preet Shihn
1156ef6b96 pixelated images initial experiment 2021-02-04 23:27:35 -08:00
Excalidraw Bot
dc25fe06d0 chore: Update translations from Crowdin (#2906) 2021-02-04 20:18:57 +02:00
David Luzar
1e17c1967b fix: toolbar unnecessarily eats too much width (#2924) 2021-02-04 16:52:16 +01:00
David Luzar
23a8891e0e fix: mistakenly hardcoding scale (#2925) 2021-02-04 16:52:00 +01:00
David Luzar
6c81a32d62 fix: text editor not visible in dark mode (#2920) 2021-02-04 16:21:48 +01:00
David Luzar
f0f5430313 feat: support supplying custom scale when exporting canvas (#2904) 2021-02-04 14:54:08 +01:00
David Luzar
e18e945cd3 fix: incorrect z-index of text editor (#2914) 2021-02-04 14:54:00 +01:00
Lipis
bd0c6e63ff feat: Show version in the stats dialog (#2908)
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-02-04 13:47:46 +01:00
Christophe pasquier
dbae33e4f8 fix: make scrollbars draggable when offsets are set (#2916)
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-02-04 13:07:29 +01:00
Lipis
0f4a053759 chore: Run actions on pull requests as well (#2917)
* chore: Run actions on pull requests as well

* Update cancel.yml

* Update lint.yml

* Update test.yml

* Update cancel.yml
2021-02-04 13:01:20 +01:00
Thomas Steiner
1837147c55 feat: Add idle detection to collaboration feature (#2877)
* Start idle detection implementation

* First working version

* Add screen state

* Add type safety

* Better rendering, enum types, localization

* Add origin trial token

* Fix

* Refactor idle detection to no longer use IdleDetector API

* Cleanup some leftovers

* Fix

* Apply suggestions from code review

* Three state: active 🟢, idle 💤, away ️

* Address feedback from code review
Thanks, @lipis

* Deal with unmount

Co-authored-by: Panayiotis Lipiridis <lipiridis@gmail.com>
2021-02-04 11:55:43 +01:00
David Luzar
15f698dc21 fix: pointer-events being disabled on free-draw (#2912) 2021-02-03 20:43:16 +01:00
David Luzar
ce507b0a0b feat: don't store to LS during collab (#2909) 2021-02-03 19:14:26 +01:00
66 changed files with 934 additions and 547 deletions

View File

@@ -1,6 +1,10 @@
name: Cancel previous runs
on: push
on:
push:
branches:
- master
pull_request:
jobs:
cancel:

View File

@@ -1,6 +1,6 @@
name: Lint
on: push
on: pull_request
jobs:
lint:

View File

@@ -1,6 +1,6 @@
name: Tests
on: push
on: pull_request
jobs:
test:

View File

@@ -57,6 +57,7 @@
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<link
rel="preload"
href="FG_Virgil.woff2"

View File

@@ -3,7 +3,7 @@ import { getDefaultAppState } from "../appState";
import { ColorPicker } from "../components/ColorPicker";
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { GRID_SIZE, ZOOM_STEP } from "../constants";
import { ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
@@ -52,7 +52,7 @@ export const actionClearCanvas = register({
elementLocked: appState.elementLocked,
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize || GRID_SIZE,
gridSize: appState.gridSize,
shouldAddWatermark: appState.shouldAddWatermark,
showStats: appState.showStats,
pasteDialog: appState.pasteDialog,

View File

@@ -1,7 +1,8 @@
import { trackEvent } from "../analytics";
import { CODES, KEYS } from "../keys";
import { AppState } from "../types";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import { AppState } from "../types";
import { trackEvent } from "../analytics";
export const actionToggleGridMode = register({
name: "gridMode",
@@ -10,12 +11,12 @@ export const actionToggleGridMode = register({
return {
appState: {
...appState,
showGrid: !appState.showGrid,
gridSize: this.checked!(appState) ? null : GRID_SIZE,
},
commitToHistory: false,
};
},
checked: (appState: AppState) => appState.showGrid,
checked: (appState: AppState) => appState.gridSize !== null,
contextItemLabel: "labels.gridMode",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});

View File

@@ -3,7 +3,6 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
GRID_SIZE,
} from "./constants";
import { t } from "./i18n";
import { AppState, NormalizedZoomValue } from "./types";
@@ -42,7 +41,7 @@ export const getDefaultAppState = (): Omit<
exportBackground: true,
exportEmbedScene: false,
fileHandle: null,
gridSize: GRID_SIZE,
gridSize: null,
height: window.innerHeight,
isBindingEnabled: true,
isLibraryOpen: false,
@@ -64,7 +63,6 @@ export const getDefaultAppState = (): Omit<
selectionElement: null,
shouldAddWatermark: false,
shouldCacheIgnoreZoom: false,
showGrid: false,
showHelpDialog: false,
showStats: false,
startBoundElement: null,
@@ -122,7 +120,6 @@ const APP_STATE_STORAGE_CONF = (<
exportEmbedScene: { browser: true, export: false },
fileHandle: { browser: false, export: false },
gridSize: { browser: true, export: true },
showGrid: { browser: true, export: false },
height: { browser: false, export: false },
isBindingEnabled: { browser: false, export: false },
isLibraryOpen: { browser: false, export: false },

View File

@@ -454,10 +454,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
renderCustomFooter={renderFooter}
viewModeEnabled={viewModeEnabled}
/>
<div className="excalidraw-textEditorContainer" />
{this.state.showStats && (
<Stats
setAppState={this.setAppState}
appState={this.state}
setAppState={this.setAppState}
elements={this.scene.getElements()}
onClose={this.toggleStats}
/>
@@ -883,6 +884,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
const pointerUsernames: { [id: string]: string } = {};
const pointerUserStates: { [id: string]: string } = {};
this.state.collaborators.forEach((user, socketId) => {
if (user.selectedElementIds) {
for (const id of Object.keys(user.selectedElementIds)) {
@@ -898,6 +900,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (user.username) {
pointerUsernames[socketId] = user.username;
}
if (user.userState) {
pointerUserStates[socketId] = user.userState;
}
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
{
sceneX: user.pointer.x,
@@ -932,6 +937,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
remotePointerButton: cursorButton,
remoteSelectedElementIds,
remotePointerUsernames: pointerUsernames,
remotePointerUserStates: pointerUserStates,
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
},
{
@@ -1088,12 +1094,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const dy = y - elementsCenterY;
const groupIdMap = new Map();
const [gridX, gridY] = getGridPoint(
dx,
dy,
this.state.showGrid,
this.state.gridSize,
);
const [gridX, gridY] = getGridPoint(dx, dy, this.state.gridSize);
const oldIdToDuplicatedId = new Map();
const newElements = clipboardElements.map((element) => {
@@ -1201,10 +1202,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.actionManager.executeAction(actionToggleZenMode);
};
toggleGridMode = () => {
this.actionManager.executeAction(actionToggleGridMode);
};
toggleStats = () => {
if (!this.state.showStats) {
trackEvent("dialog", "stats");
@@ -1317,7 +1314,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (isArrowKey(event.key)) {
const step =
(this.state.showGrid &&
(this.state.gridSize &&
(event.shiftKey ? ELEMENT_TRANSLATE_AMOUNT : this.state.gridSize)) ||
(event.shiftKey
? ELEMENT_SHIFT_TRANSLATE_AMOUNT
@@ -1833,10 +1830,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (isHoldingSpace || isPanning || isDraggingScrollBar) {
return;
}
const isPointerOverScrollBars = isOverScrollBars(
currentScrollBars,
event.clientX,
event.clientY,
event.clientX - this.state.offsetLeft,
event.clientY - this.state.offsetTop,
);
const isOverScrollBar = isPointerOverScrollBars.isOverEither;
if (!this.state.draggingElement && !this.state.multiElement) {
@@ -1859,7 +1857,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
scenePointerX,
scenePointerY,
this.state.editingLinearElement,
this.state.showGrid,
this.state.gridSize,
);
if (editingLinearElement !== this.state.editingLinearElement) {
@@ -2289,17 +2286,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
return {
origin,
originInGrid: tupleToCoors(
getGridPoint(
origin.x,
origin.y,
this.state.showGrid,
this.state.gridSize,
),
getGridPoint(origin.x, origin.y, this.state.gridSize),
),
scrollbars: isOverScrollBars(
currentScrollBars,
event.clientX,
event.clientY,
event.clientX - this.state.offsetLeft,
event.clientY - this.state.offsetTop,
),
// we need to duplicate because we'll be updating this state
lastCoords: { ...origin },
@@ -2650,8 +2642,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x,
pointerDownState.origin.y,
elementType === "draw" ? false : this.state.showGrid,
this.state.gridSize,
elementType === "draw" ? null : this.state.gridSize,
);
/* If arrow is pre-arrowheads, it will have undefined for both start and end arrowheads.
@@ -2713,7 +2704,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x,
pointerDownState.origin.y,
this.state.showGrid,
this.state.gridSize,
);
const element = newElement({
@@ -2803,7 +2793,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
this.state.showGrid,
this.state.gridSize,
);
@@ -2876,7 +2865,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const [dragX, dragY] = getGridPoint(
pointerCoords.x - pointerDownState.drag.offset.x,
pointerCoords.y - pointerDownState.drag.offset.y,
this.state.showGrid,
this.state.gridSize,
);
@@ -2929,7 +2917,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const [originDragX, originDragY] = getGridPoint(
pointerDownState.origin.x - pointerDownState.drag.offset.x,
pointerDownState.origin.y - pointerDownState.drag.offset.y,
this.state.showGrid,
this.state.gridSize,
);
mutateElement(duplicatedElement, {
@@ -3466,11 +3453,40 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}
};
private handleCanvasImageDrop = async (
event: React.DragEvent<HTMLCanvasElement>,
file: File,
) => {
try {
const shapes = await (
await import(
/* webpackChunkName: "pixelated-image" */ "../data/pixelated-image"
)
).pixelateImage(file, 20, 1200, event.clientX, event.clientY);
const nextElements = [
...this.scene.getElementsIncludingDeleted(),
...shapes,
];
this.scene.replaceAllElements(nextElements);
} catch (error) {
return this.setState({
isLoading: false,
errorMessage: error.message,
});
}
};
private handleCanvasOnDrop = async (
event: React.DragEvent<HTMLCanvasElement>,
) => {
let imageFile: File | null = null;
try {
const file = event.dataTransfer.files[0];
if (file?.type.indexOf("image/") === 0) {
imageFile = file;
}
if (file?.type === "image/png" || file?.type === "image/svg+xml") {
const { elements, appState } = await loadFromBlob(file, this.state);
this.syncActionResult({
@@ -3482,8 +3498,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
commitToHistory: true,
});
return;
} else if (imageFile) {
return await this.handleCanvasImageDrop(event, imageFile);
}
} catch (error) {
if (imageFile) {
return await this.handleCanvasImageDrop(event, imageFile);
}
return this.setState({
isLoading: false,
errorMessage: error.message,
@@ -3586,7 +3607,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
this.state.showGrid,
this.state.gridSize,
);
dragNewElement(
@@ -3625,7 +3645,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const [resizeX, resizeY] = getGridPoint(
pointerCoords.x - pointerDownState.resize.offset.x,
pointerCoords.y - pointerDownState.resize.offset.y,
this.state.showGrid,
this.state.gridSize,
);
if (

View File

@@ -38,6 +38,8 @@
}
.layer-ui__wrapper {
z-index: var(--zIndex-layerUI);
.encrypted-icon {
position: relative;
margin-inline-start: 15px;

View File

@@ -11,7 +11,7 @@ import { CLASSES } from "../constants";
import { exportCanvas } from "../data";
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import { Library } from "../data/library";
import { showSelectedShapeActions } from "../element";
import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n";
import useIsMobile from "../is-mobile";
@@ -647,7 +647,10 @@ const LayerUI = ({
) : (
<div
className={clsx("layer-ui__wrapper", {
"disable-pointerEvents": appState.cursorButton === "down",
"disable-pointerEvents":
appState.draggingElement ||
appState.resizingElement ||
(appState.editingElement && !isTextElement(appState.editingElement)),
})}
>
{dialogs}

View File

@@ -46,9 +46,9 @@ export const MobileMenu = ({
renderCustomFooter,
viewModeEnabled,
}: MobileMenuProps) => {
const renderFixedSideContainer = () => {
const renderToolbar = () => {
return (
<FixedSideContainer side="top">
<FixedSideContainer side="top" className="App-top-bar">
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="center">
@@ -136,7 +136,7 @@ export const MobileMenu = ({
};
return (
<>
{!viewModeEnabled && renderFixedSideContainer()}
{!viewModeEnabled && renderToolbar()}
<div
className="App-bottom-bar"
style={{

View File

@@ -1,4 +1,6 @@
import React, { useEffect, useState } from "react";
import { copyTextToSystemClipboard } from "../clipboard";
import { DEFAULT_VERSION } from "../constants";
import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types";
import {
@@ -9,7 +11,7 @@ import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { getTargetElements } from "../scene";
import { AppState } from "../types";
import { debounce, nFormatter } from "../utils";
import { debounce, getVersion, nFormatter } from "../utils";
import { close } from "./icons";
import { Island } from "./Island";
import "./Stats.scss";
@@ -24,8 +26,8 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
}, 500);
export const Stats = (props: {
setAppState: React.Component<any, AppState>["setState"];
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onClose: () => void;
}) => {
@@ -47,16 +49,21 @@ export const Stats = (props: {
const selectedElements = getTargetElements(props.elements, props.appState);
const selectedBoundingBox = getCommonBounds(selectedElements);
const onGridSizeChange = () => {
props.setAppState({
gridSize: ((props.appState.gridSize - 5) % 50) + 10,
});
};
if (isMobile && props.appState.openMenu) {
return null;
}
const version = getVersion();
let hash;
let timestamp;
if (version !== DEFAULT_VERSION) {
timestamp = version.slice(0, 16).replace("T", " ");
hash = version.slice(21);
} else {
timestamp = t("stats.versionNotAvailable");
}
return (
<div className="Stats">
<Island padding={2}>
@@ -163,17 +170,28 @@ export const Stats = (props: {
</td>
</tr>
)}
{props.appState.showGrid && (
<>
<tr>
<th colSpan={2}>{"Misc"}</th>
</tr>
<tr onClick={onGridSizeChange} style={{ cursor: "pointer" }}>
<td>{"Grid size"}</td>
<td>{props.appState.gridSize}</td>
</tr>
</>
)}
<tr>
<th colSpan={2}>{t("stats.version")}</th>
</tr>
<tr>
<td
colSpan={2}
style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setAppState({
toastMessage: t("toast.copyToClipboard"),
});
} catch {}
}}
title={t("stats.versionCopy")}
>
{timestamp}
<br />
{hash}
</td>
</tr>
</tbody>
</table>
</Island>

View File

@@ -46,6 +46,7 @@ export enum EVENT {
TOUCH_START = "touchstart",
TOUCH_END = "touchend",
HASHCHANGE = "hashchange",
VISIBILITY_CHANGE = "visibilitychange",
}
export const ENV = {
@@ -93,3 +94,8 @@ export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 30000;
export const ZOOM_STEP = 0.1;
// Report a user inactive after IDLE_THRESHOLD milliseconds
export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000;

View File

@@ -1,6 +1,12 @@
@import "./variables.module";
@import "./theme";
:root {
--zIndex-canvas: 1;
--zIndex-wysiwyg: 2;
--zIndex-layerUI: 3;
}
.excalidraw {
color: var(--text-color-primary);
display: flex;
@@ -30,6 +36,8 @@
image-rendering: pixelated; // chromium
// NOTE: must be declared *after* the above
image-rendering: -moz-crisp-edges; // FF
z-index: var(--zIndex-canvas);
}
&.Appearance_dark {
@@ -216,6 +224,12 @@
}
}
.App-top-bar {
z-index: var(--zIndex-layerUI);
display: flex;
justify-content: center;
}
.App-bottom-bar {
position: absolute;
top: 0;

106
src/data/pixelated-image.ts Normal file
View File

@@ -0,0 +1,106 @@
import { ExcalidrawGenericElement, NonDeleted } from "../element/types";
import { newElement } from "../element";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
import { randomId } from "../random";
const loadImage = async (url: string): Promise<HTMLImageElement> => {
const image = new Image();
return new Promise<HTMLImageElement>((resolve, reject) => {
image.onload = () => resolve(image);
image.onerror = (err) =>
reject(
new Error(
`Failed to load image: ${err ? err.toString : "unknown error"}`,
),
);
image.onabort = () =>
reject(new Error(`Failed to load image: image load aborted`));
image.src = url;
});
};
const commonProps = {
fillStyle: "solid",
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: DEFAULT_FONT_SIZE,
opacity: 100,
roughness: 1,
strokeColor: "transparent",
strokeSharpness: "sharp",
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: "middle",
} as const;
export const pixelateImage = async (
blob: Blob,
cellSize: number,
suggestedMaxShapeCount: number,
x: number,
y: number,
) => {
const url = URL.createObjectURL(blob);
try {
const image = await loadImage(url);
// initialize canvas for pixelation
const { width, height } = image;
let canvasWidth = Math.floor(width / cellSize);
let canvasHeight = Math.floor(height / cellSize);
const shapeCount = canvasHeight * canvasWidth;
if (shapeCount > suggestedMaxShapeCount) {
canvasWidth = Math.floor(
canvasWidth * (suggestedMaxShapeCount / shapeCount),
);
canvasHeight = Math.floor(
canvasHeight * (suggestedMaxShapeCount / shapeCount),
);
}
const xOffset = x - (canvasWidth * cellSize) / 2;
const yOffset = y - (canvasHeight * cellSize) / 2;
const canvas =
"OffscreenCanvas" in window
? new OffscreenCanvas(canvasWidth, canvasHeight)
: document.createElement("canvas");
canvas.width = canvasWidth;
canvas.height = canvasHeight;
// Draw image on canvas
const ctx = canvas.getContext("2d")!;
ctx.drawImage(image, 0, 0, width, height, 0, 0, canvasWidth, canvasHeight);
const imageData = ctx.getImageData(0, 0, canvasWidth, canvasHeight);
const buffer = imageData.data;
const groupId = randomId();
const shapes: NonDeleted<ExcalidrawGenericElement>[] = [];
for (let row = 0; row < canvasHeight; row++) {
for (let col = 0; col < canvasWidth; col++) {
const offset = row * canvasWidth * 4 + col * 4;
const r = buffer[offset];
const g = buffer[offset + 1];
const b = buffer[offset + 2];
const alpha = buffer[offset + 3];
if (alpha) {
const color = `rgba(${r}, ${g}, ${b}, ${alpha})`;
const rectangle = newElement({
backgroundColor: color,
groupIds: [groupId],
...commonProps,
type: "rectangle",
x: xOffset + col * cellSize,
y: yOffset + row * cellSize,
width: cellSize,
height: cellSize,
});
shapes.push(rectangle);
}
}
}
return shapes;
} finally {
URL.revokeObjectURL(url);
}
};

View File

@@ -102,7 +102,6 @@ export class LinearElementEditor {
element,
scenePointerX - editingLinearElement.pointerOffset.x,
scenePointerY - editingLinearElement.pointerOffset.y,
appState.showGrid,
appState.gridSize,
);
LinearElementEditor.movePoint(element, activePointIndex, newPoint);
@@ -199,7 +198,6 @@ export class LinearElementEditor {
element,
scenePointer.x,
scenePointer.y,
appState.showGrid,
appState.gridSize,
),
],
@@ -284,8 +282,7 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
editingLinearElement: LinearElementEditor,
isGridOn: boolean,
gridSize: number,
gridSize: number | null,
): LinearElementEditor {
const { elementId, lastUncommittedPoint } = editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
@@ -307,7 +304,6 @@ export class LinearElementEditor {
element,
scenePointerX - editingLinearElement.pointerOffset.x,
scenePointerY - editingLinearElement.pointerOffset.y,
isGridOn,
gridSize,
);
@@ -402,15 +398,9 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>,
scenePointerX: number,
scenePointerY: number,
isGridOn: boolean,
gridSize: number,
gridSize: number | null,
): Point {
const pointerOnGrid = getGridPoint(
scenePointerX,
scenePointerY,
isGridOn,
gridSize,
);
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;

View File

@@ -89,9 +89,6 @@ export const textWysiwyg = ({
editable.dataset.type = "wysiwyg";
// prevent line wrapping on Safari
editable.wrap = "off";
editable.className = `excalidraw ${
appState.appearance === "dark" ? "Appearance_dark" : ""
}`;
Object.assign(editable.style, {
position: "fixed",
@@ -107,6 +104,8 @@ export const textWysiwyg = ({
overflow: "hidden",
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
whiteSpace: "pre",
// must be specified because in dark mode canvas creates a stacking context
zIndex: "var(--zIndex-wysiwyg)",
});
updateWysiwygStyle();
@@ -160,7 +159,7 @@ export const textWysiwyg = ({
unbindUpdate();
document.body.removeChild(editable);
editable.remove();
};
const rebindBlur = () => {
@@ -206,7 +205,9 @@ export const textWysiwyg = ({
passive: false,
capture: true,
});
document.body.appendChild(editable);
document
.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable);
editable.focus();
editable.select();
};

View File

@@ -19,12 +19,16 @@ import {
} from "../app_constants";
import {
decryptAESGEM,
generateCollaborationLink,
getCollaborationLinkData,
generateCollaborationLinkData,
getCollaborationLink,
SocketUpdateDataSource,
SOCKET_SERVER,
} from "../data";
import { isSavedToFirebase, saveToFirebase } from "../data/firebase";
import {
isSavedToFirebase,
loadFromFirebase,
saveToFirebase,
} from "../data/firebase";
import {
importUsernameFromLocalStorage,
saveUsernameToLocalStorage,
@@ -33,20 +37,25 @@ import {
import Portal from "./Portal";
import RoomDialog from "./RoomDialog";
import { createInverseContext } from "../../createInverseContext";
import { t } from "../../i18n";
import { UserIdleState } from "./types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
interface CollabState {
isCollaborating: boolean;
modalIsShown: boolean;
errorMessage: string;
username: string;
userState: UserIdleState;
activeRoomLink: string;
}
type CollabInstance = InstanceType<typeof CollabWrapper>;
export interface CollabAPI {
isCollaborating: CollabState["isCollaborating"];
/** function so that we can access the latest value from stale callbacks */
isCollaborating: () => boolean;
username: CollabState["username"];
userState: CollabState["userState"];
onPointerUpdate: CollabInstance["onPointerUpdate"];
initializeSocketClient: CollabInstance["initializeSocketClient"];
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
@@ -72,6 +81,10 @@ export { CollabContext, CollabContextConsumer };
class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal;
excalidrawAPI: Props["excalidrawAPI"];
isCollaborating: boolean = false;
activeIntervalId: number | null;
idleTimeoutId: number | null;
private socketInitializationTimer?: NodeJS.Timeout;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<string, Collaborator>();
@@ -79,14 +92,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
constructor(props: Props) {
super(props);
this.state = {
isCollaborating: false,
modalIsShown: false,
errorMessage: "",
username: importUsernameFromLocalStorage() || "",
userState: UserIdleState.ACTIVE,
activeRoomLink: "",
};
this.portal = new Portal(this);
this.excalidrawAPI = props.excalidrawAPI;
this.activeIntervalId = null;
this.idleTimeoutId = null;
}
componentDidMount() {
@@ -110,18 +125,32 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
componentWillUnmount() {
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
window.removeEventListener(EVENT.UNLOAD, this.onUnload);
window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
window.removeEventListener(
EVENT.VISIBILITY_CHANGE,
this.onVisibilityChange,
);
if (this.activeIntervalId) {
window.clearInterval(this.activeIntervalId);
this.activeIntervalId = null;
}
if (this.idleTimeoutId) {
window.clearTimeout(this.idleTimeoutId);
this.idleTimeoutId = null;
}
}
private onUnload = () => {
this.destroySocketClient();
this.destroySocketClient({ isUnload: true });
};
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
const syncableElements = getSyncableElements(
this.getSceneElementsIncludingDeleted(),
);
if (
this.state.isCollaborating &&
this.isCollaborating &&
!isSavedToFirebase(this.portal, syncableElements)
) {
// this won't run in time if user decides to leave the site, but
@@ -133,7 +162,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
event.returnValue = "";
}
if (this.state.isCollaborating || this.portal.roomId) {
if (this.isCollaborating || this.portal.roomId) {
try {
localStorage?.setItem(
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
@@ -159,143 +188,188 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
};
openPortal = async () => {
window.history.pushState({}, APP_NAME, await generateCollaborationLink());
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
// to database even if deleted before creating the room.
this.excalidrawAPI.history.clear();
this.excalidrawAPI.updateScene({
elements,
commitToHistory: true,
});
return this.initializeSocketClient();
return this.initializeSocketClient(null);
};
closePortal = () => {
this.saveCollabRoomToFirebase();
window.history.pushState({}, APP_NAME, window.location.origin);
this.destroySocketClient();
if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
window.history.pushState({}, APP_NAME, window.location.origin);
this.destroySocketClient();
}
};
private destroySocketClient = () => {
this.collaborators = new Map();
this.excalidrawAPI.updateScene({
collaborators: this.collaborators,
});
this.setState({
isCollaborating: false,
activeRoomLink: "",
});
private destroySocketClient = (opts?: { isUnload: boolean }) => {
if (!opts?.isUnload) {
this.collaborators = new Map();
this.excalidrawAPI.updateScene({
collaborators: this.collaborators,
});
this.setState({
activeRoomLink: "",
});
this.isCollaborating = false;
}
this.portal.close();
};
private initializeSocketClient = async (): Promise<ImportedDataState | null> => {
private initializeSocketClient = async (
existingRoomLinkData: null | { roomId: string; roomKey: string },
): Promise<ImportedDataState | null> => {
if (this.portal.socket) {
return null;
}
const scenePromise = resolvablePromise<ImportedDataState | null>();
let roomId;
let roomKey;
const roomMatch = getCollaborationLinkData(window.location.href);
if (roomMatch) {
const roomId = roomMatch[1];
const roomKey = roomMatch[2];
// fallback in case you're not alone in the room but still don't receive
// initial SCENE_UPDATE message
this.socketInitializationTimer = setTimeout(() => {
this.initializeSocket();
scenePromise.resolve(null);
}, INITIAL_SCENE_UPDATE_TIMEOUT);
const { default: socketIOClient }: any = await import(
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
if (existingRoomLinkData) {
({ roomId, roomKey } = existingRoomLinkData);
} else {
({ roomId, roomKey } = await generateCollaborationLinkData());
window.history.pushState(
{},
APP_NAME,
getCollaborationLink({ roomId, roomKey }),
);
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
// All socket listeners are moving to Portal
this.portal.socket!.on(
"client-broadcast",
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
if (!this.portal.roomKey) {
return;
}
const decryptedData = await decryptAESGEM(
encryptedData,
this.portal.roomKey,
iv,
);
switch (decryptedData.type) {
case "INVALID_RESPONSE":
return;
case SCENE.INIT: {
if (!this.portal.socketInitialized) {
const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(
remoteElements,
);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
this.initializeSocket();
scenePromise.resolve({ elements: reconciledElements });
}
break;
}
case SCENE.UPDATE:
this.handleRemoteSceneUpdate(
this.reconcileElements(decryptedData.payload.elements),
);
break;
case "MOUSE_LOCATION": {
const {
pointer,
button,
username,
selectedElementIds,
} = decryptedData.payload;
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
decryptedData.payload.socketId ||
// @ts-ignore legacy, see #2094 (#2097)
decryptedData.payload.socketID;
const collaborators = new Map(this.collaborators);
const user = collaborators.get(socketId) || {}!;
user.pointer = pointer;
user.button = button;
user.selectedElementIds = selectedElementIds;
user.username = username;
collaborators.set(socketId, user);
this.excalidrawAPI.updateScene({
collaborators,
});
break;
}
}
},
);
this.portal.socket!.on("first-in-room", () => {
if (this.portal.socket) {
this.portal.socket.off("first-in-room");
}
this.initializeSocket();
scenePromise.resolve(null);
});
this.setState({
isCollaborating: true,
activeRoomLink: window.location.href,
});
return scenePromise;
}
return null;
const scenePromise = resolvablePromise<ImportedDataState | null>();
this.isCollaborating = true;
const { default: socketIOClient }: any = await import(
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
);
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
if (existingRoomLinkData) {
this.excalidrawAPI.resetScene();
try {
const elements = await loadFromFirebase(
roomId,
roomKey,
this.portal.socket,
);
if (elements) {
scenePromise.resolve({
elements,
});
}
} catch (error) {
// log the error and move on. other peers will sync us the scene.
console.error(error);
}
} else {
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
// to database even if deleted before creating the room.
this.excalidrawAPI.history.clear();
this.excalidrawAPI.updateScene({
elements,
commitToHistory: true,
});
}
// fallback in case you're not alone in the room but still don't receive
// initial SCENE_UPDATE message
this.socketInitializationTimer = setTimeout(() => {
this.initializeSocket();
scenePromise.resolve(null);
}, INITIAL_SCENE_UPDATE_TIMEOUT);
// All socket listeners are moving to Portal
this.portal.socket!.on(
"client-broadcast",
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
if (!this.portal.roomKey) {
return;
}
const decryptedData = await decryptAESGEM(
encryptedData,
this.portal.roomKey,
iv,
);
switch (decryptedData.type) {
case "INVALID_RESPONSE":
return;
case SCENE.INIT: {
if (!this.portal.socketInitialized) {
this.initializeSocket();
const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
// noop if already resolved via init from firebase
scenePromise.resolve({ elements: reconciledElements });
}
break;
}
case SCENE.UPDATE:
this.handleRemoteSceneUpdate(
this.reconcileElements(decryptedData.payload.elements),
);
break;
case "MOUSE_LOCATION": {
const {
pointer,
button,
username,
selectedElementIds,
} = decryptedData.payload;
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
decryptedData.payload.socketId ||
// @ts-ignore legacy, see #2094 (#2097)
decryptedData.payload.socketID;
const collaborators = new Map(this.collaborators);
const user = collaborators.get(socketId) || {}!;
user.pointer = pointer;
user.button = button;
user.selectedElementIds = selectedElementIds;
user.username = username;
collaborators.set(socketId, user);
this.excalidrawAPI.updateScene({
collaborators,
});
break;
}
case "IDLE_STATUS": {
const { userState, socketId, username } = decryptedData.payload;
const collaborators = new Map(this.collaborators);
const user = collaborators.get(socketId) || {}!;
user.userState = userState;
user.username = username;
this.excalidrawAPI.updateScene({
collaborators,
});
break;
}
}
},
);
this.portal.socket!.on("first-in-room", () => {
if (this.portal.socket) {
this.portal.socket.off("first-in-room");
}
this.initializeSocket();
scenePromise.resolve(null);
});
this.initializeIdleDetector();
this.setState({
activeRoomLink: window.location.href,
});
return scenePromise;
};
private initializeSocket = () => {
@@ -359,7 +433,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
// 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
// syncronously calls render.
// synchronously calls render.
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
return newElements as ReconciledElements;
@@ -388,6 +462,58 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.excalidrawAPI.history.clear();
};
private onPointerMove = () => {
if (this.idleTimeoutId) {
window.clearTimeout(this.idleTimeoutId);
this.idleTimeoutId = null;
}
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
if (!this.activeIntervalId) {
this.activeIntervalId = window.setInterval(
this.reportActive,
ACTIVE_THRESHOLD,
);
}
};
private onVisibilityChange = () => {
if (document.hidden) {
if (this.idleTimeoutId) {
window.clearTimeout(this.idleTimeoutId);
this.idleTimeoutId = null;
}
if (this.activeIntervalId) {
window.clearInterval(this.activeIntervalId);
this.activeIntervalId = null;
}
this.onIdleStateChange(UserIdleState.AWAY);
} else {
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
this.activeIntervalId = window.setInterval(
this.reportActive,
ACTIVE_THRESHOLD,
);
this.onIdleStateChange(UserIdleState.ACTIVE);
}
};
private reportIdle = () => {
this.onIdleStateChange(UserIdleState.IDLE);
if (this.activeIntervalId) {
window.clearInterval(this.activeIntervalId);
this.activeIntervalId = null;
}
};
private reportActive = () => {
this.onIdleStateChange(UserIdleState.ACTIVE);
};
private initializeIdleDetector = () => {
document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
};
setCollaborators(sockets: string[]) {
this.setState((state) => {
const collaborators: InstanceType<
@@ -427,6 +553,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.portal.broadcastMouseLocation(payload);
};
onIdleStateChange = (userState: UserIdleState) => {
this.setState({ userState });
this.portal.broadcastIdleChange(userState);
};
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
if (
getSceneVersion(elements) >
@@ -480,9 +611,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
/** Getter of context value. Returned object is stable. */
getContextValue = (): CollabAPI => {
this.contextValue = this.contextValue || ({} as CollabAPI);
if (!this.contextValue) {
this.contextValue = {} as CollabAPI;
}
this.contextValue.isCollaborating = this.state.isCollaborating;
this.contextValue.isCollaborating = () => this.isCollaborating;
this.contextValue.username = this.state.username;
this.contextValue.onPointerUpdate = this.onPointerUpdate;
this.contextValue.initializeSocketClient = this.initializeSocketClient;

View File

@@ -9,6 +9,7 @@ import CollabWrapper from "./CollabWrapper";
import { getSyncableElements } from "../../packages/excalidraw/index";
import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, SCENE } from "../app_constants";
import { UserIdleState } from "./types";
class Portal {
collab: CollabWrapper;
@@ -122,7 +123,7 @@ class Portal {
data as SocketUpdateData,
);
if (syncAll && this.collab.state.isCollaborating) {
if (syncAll && this.collab.isCollaborating) {
await Promise.all([
broadcastPromise,
this.collab.saveCollabRoomToFirebase(syncableElements),
@@ -132,6 +133,23 @@ class Portal {
}
};
broadcastIdleChange = (userState: UserIdleState) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
type: "IDLE_STATUS",
payload: {
socketId: this.socket.id,
userState,
username: this.collab.state.username,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
);
}
};
broadcastMouseLocation = (payload: {
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];

View File

@@ -0,0 +1,5 @@
export enum UserIdleState {
ACTIVE = "active",
AWAY = "away",
IDLE = "idle",
}

View File

@@ -148,6 +148,7 @@ export const saveToFirebase = async (
export const loadFromFirebase = async (
roomId: string,
roomKey: string,
socket: SocketIOClient.Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => {
const firebase = await getFirebase();
const db = firebase.firestore();
@@ -160,5 +161,12 @@ export const loadFromFirebase = async (
const storedScene = doc.data() as FirebaseStoredScene;
const ciphertext = storedScene.ciphertext.toUint8Array();
const iv = storedScene.iv.toUint8Array();
return restoreElements(await decryptElements(roomKey, iv, ciphertext));
const elements = await decryptElements(roomKey, iv, ciphertext);
if (socket) {
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
}
return restoreElements(elements);
};

View File

@@ -4,6 +4,7 @@ import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types";
import { t } from "../../i18n";
import { AppState } from "../../types";
import { UserIdleState } from "../collab/types";
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
@@ -59,6 +60,14 @@ export type SocketUpdateDataSource = {
username: string;
};
};
IDLE_STATUS: {
type: "IDLE_STATUS";
payload: {
socketId: string;
userState: UserIdleState;
username: string;
};
};
};
export type SocketUpdateDataIncoming =
@@ -125,17 +134,27 @@ export const decryptAESGEM = async (
};
export const getCollaborationLinkData = (link: string) => {
if (link.length === 0) {
return;
}
const hash = new URL(link).hash;
return hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
return match ? { roomId: match[1], roomKey: match[2] } : null;
};
export const generateCollaborationLink = async () => {
const id = await generateRandomID();
const key = await generateEncryptionKey();
return `${window.location.origin}${window.location.pathname}#room=${id},${key}`;
export const generateCollaborationLinkData = async () => {
const roomId = await generateRandomID();
const roomKey = await generateEncryptionKey();
if (!roomKey) {
throw new Error("Couldn't generate room key");
}
return { roomId, roomKey };
};
export const getCollaborationLink = (data: {
roomId: string;
roomKey: string;
}) => {
return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
};
export const getImportedKey = (key: string, usage: KeyUsage) =>

View File

@@ -39,11 +39,9 @@ import CollabWrapper, {
} from "./collab/CollabWrapper";
import { LanguageList } from "./components/LanguageList";
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
import { loadFromFirebase } from "./data/firebase";
import {
importFromLocalStorage,
saveToLocalStorage,
STORAGE_KEYS,
} from "./data/localStorage";
const languageDetector = new LanguageDetector();
@@ -66,50 +64,9 @@ const onBlur = () => {
saveDebounced.flush();
};
const shouldForceLoadScene = (
scene: ResolutionType<typeof loadScene>,
): boolean => {
if (!scene.elements.length) {
return true;
}
const roomMatch = getCollaborationLinkData(window.location.href);
if (!roomMatch) {
return false;
}
const roomId = roomMatch[1];
let collabForceLoadFlag;
try {
collabForceLoadFlag = localStorage?.getItem(
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
);
} catch {}
if (collabForceLoadFlag) {
try {
const {
room: previousRoom,
timestamp,
}: { room: string; timestamp: number } = JSON.parse(collabForceLoadFlag);
// if loading same room as the one previously unloaded within 15sec
// force reload without prompting
if (previousRoom === roomId && Date.now() - timestamp < 15000) {
return true;
}
} catch {}
}
return false;
};
type Scene = ImportedDataState & { commitToHistory: boolean };
const initializeScene = async (opts: {
resetScene: ExcalidrawImperativeAPI["resetScene"];
initializeSocketClient: CollabAPI["initializeSocketClient"];
}): Promise<Scene | null> => {
collabAPI: CollabAPI;
}): Promise<ImportedDataState | null> => {
const searchParams = new URLSearchParams(window.location.search);
const id = searchParams.get("id");
const jsonMatch = window.location.hash.match(
@@ -120,20 +77,17 @@ const initializeScene = async (opts: {
let scene = await loadScene(null, null, initialData);
let isCollabScene = !!getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonMatch || isCollabScene);
let roomLinkData = getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonMatch || roomLinkData);
if (isExternalScene) {
if (
shouldForceLoadScene(scene) ||
window.confirm(t("alerts.loadSceneOverridePrompt"))
) {
if (roomLinkData || window.confirm(t("alerts.loadSceneOverridePrompt"))) {
// Backwards compatibility with legacy url format
if (id) {
scene = await loadScene(id, null, initialData);
} else if (jsonMatch) {
scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
}
if (!isCollabScene) {
if (!roomLinkData) {
window.history.replaceState({}, APP_NAME, window.location.origin);
}
} else {
@@ -150,38 +104,12 @@ const initializeScene = async (opts: {
});
}
isCollabScene = false;
roomLinkData = null;
window.history.replaceState({}, APP_NAME, window.location.origin);
}
}
if (isCollabScene) {
// when joining a room we don't want user's local scene data to be merged
// into the remote scene
opts.resetScene();
const scenePromise = opts.initializeSocketClient();
try {
const [, roomId, roomKey] = getCollaborationLinkData(
window.location.href,
)!;
const elements = await loadFromFirebase(roomId, roomKey);
if (elements) {
return {
elements,
commitToHistory: true,
};
}
return {
...(await scenePromise),
commitToHistory: true,
};
} catch (error) {
// log the error and move on. other peers will sync us the scene.
console.error(error);
}
return null;
if (roomLinkData) {
return opts.collabAPI.initializeSocketClient(roomLinkData);
} else if (scene) {
return scene;
}
@@ -242,24 +170,16 @@ function ExcalidrawWrapper() {
return;
}
initializeScene({
resetScene: excalidrawAPI.resetScene,
initializeSocketClient: collabAPI.initializeSocketClient,
}).then((scene) => {
initializeScene({ collabAPI }).then((scene) => {
initialStatePromiseRef.current.promise.resolve(scene);
});
const onHashChange = (_: HashChangeEvent) => {
if (window.location.hash.length > 1) {
initializeScene({
resetScene: excalidrawAPI.resetScene,
initializeSocketClient: collabAPI.initializeSocketClient,
}).then((scene) => {
if (scene) {
excalidrawAPI.updateScene(scene);
}
});
}
initializeScene({ collabAPI }).then((scene) => {
if (scene) {
excalidrawAPI.updateScene(scene);
}
});
};
const titleTimeout = setTimeout(
@@ -285,9 +205,13 @@ function ExcalidrawWrapper() {
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
saveDebounced(elements, appState);
if (collabAPI?.isCollaborating) {
if (collabAPI?.isCollaborating()) {
collabAPI.broadcastElements(elements);
} else {
// 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);
}
};
@@ -352,7 +276,7 @@ function ExcalidrawWrapper() {
initialData={initialStatePromiseRef.current.promise}
user={{ name: collabAPI?.username }}
onCollabButtonClick={collabAPI?.onCollabButtonClick}
isCollaborating={collabAPI?.isCollaborating}
isCollaborating={collabAPI?.isCollaborating()}
onPointerUpdate={collabAPI?.onPointerUpdate}
onExportToBackend={onExportToBackend}
renderFooter={renderFooter}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "تعذر فك تشفير البيانات.",
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
@@ -234,10 +235,14 @@
"storage": "التخزين",
"title": "إحصائيات للمهووسين",
"total": "المجموع",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "العرض"
},
"toast": {
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": ""
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "Данните не можаха да се дешифрират.",
"uploadedSecurly": "Качването е защитено с криптиране от край до край, което означава, че сървърът Excalidraw и трети страни не могат да четат съдържанието.",
"loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Възникна грешка при зареждането на външна библиотека.",
"confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?",
"imageDoesNotContainScene": "Импортирането на картинки не се поддържва в момента.\n\nИскате да импортнете сцена? Тази картинка не съдържа данни от сцена. Разрешили ли сте последното при експортирането?",
@@ -234,10 +235,14 @@
"storage": "Съхранение на данни",
"title": "Статистика за хакери",
"total": "Общо",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Широчина"
},
"toast": {
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": ""
}
}

View File

@@ -80,7 +80,7 @@
"gridMode": "Mode quadrícula",
"addToLibrary": "Afegir a la biblioteca",
"removeFromLibrary": "Eliminar de la biblioteca",
"libraryLoadingMessage": "Carregant la biblioteca...",
"libraryLoadingMessage": "Carregant la biblioteca",
"libraries": "Explorar biblioteques",
"loadingScene": "Carregant escena…",
"align": "Alinear",
@@ -136,6 +136,7 @@
"decryptFailed": "No s'ha pogut desencriptar.",
"uploadedSecurly": "La càrrega s'ha assegurat amb xifratge punta a punta, cosa que significa que el servidor Excalidraw i tercers no poden llegir el contingut.",
"loadSceneOverridePrompt": "Si carregas aquest dibuix extern, substituirá el que tens. Vols continuar?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "S'ha produït un error en carregar la biblioteca de tercers.",
"confirmAddLibrary": "Això afegirà {{numShapes}} forma(es) a la vostra biblioteca. Estàs segur?",
"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ó?",
@@ -234,10 +235,14 @@
"storage": "Emmagatzematge",
"title": "Estadístiques per nerds",
"total": "Total",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Amplada"
},
"toast": {
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": ""
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "Daten konnten nicht entschlüsselt werden.",
"uploadedSecurly": "Der Upload wurde mit Ende-zu-Ende-Verschlüsselung gespeichert. Weder Excalidraw noch Dritte können den Inhalt einsehen.",
"loadSceneOverridePrompt": "Das Laden der externen Zeichnung ersetzt den vorhandenen Inhalt. Möchtest Du fortfahren?",
"collabStopOverridePrompt": "Das Stoppen der Sitzung wird Deine vorherige, lokal gespeicherte Zeichnung überschreiben. Bist Du sicher?\n\n(Wenn Du Deine lokale Zeichnung behalten möchtest, schließe einfach stattdessen den Browser-Tab.)",
"errorLoadingLibrary": "Beim Laden der Drittanbieter-Bibliothek ist ein Fehler aufgetreten.",
"confirmAddLibrary": "Dieses fügt {{numShapes}} Form(en) zu deiner Bibliothek hinzu. Bist du sicher?",
"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?",
@@ -234,10 +235,14 @@
"storage": "Speicher",
"title": "Statistiken für Nerds",
"total": "Gesamt",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Breite"
},
"toast": {
"copyStyles": "Formatierung kopiert.",
"copyToClipboard": "",
"copyToClipboardAsPng": "In die Zwischenablage als PNG kopiert."
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "Δεν ήταν δυνατή η αποκρυπτογράφηση δεδομένων.",
"uploadedSecurly": "Η μεταφόρτωση έχει εξασφαλιστεί με κρυπτογράφηση από άκρο σε άκρο, πράγμα που σημαίνει ότι ο διακομιστής Excalidraw και τρίτα μέρη δεν μπορούν να διαβάσουν το περιεχόμενο.",
"loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Υπήρξε ένα σφάλμα κατά τη φόρτωση της βιβλιοθήκης τρίτου μέρους.",
"confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβιλιοθήκη σας. Είστε σίγουροι;",
"imageDoesNotContainScene": "Η εισαγωγή εικόνων δεν υποστηρίζεται αυτή τη στιγμή.\n\nΜήπως θέλετε να εισαγάγετε μια σκηνή; Αυτή η εικόνα δεν φαίνεται να περιέχει δεδομένα σκηνής. Έχετε ενεργοποιήσει αυτό κατά την εξαγωγή;",
@@ -234,10 +235,14 @@
"storage": "Χώρος",
"title": "Στατιστικά για σπασίκλες",
"total": "Σύνολο ",
"version": "Έκδοση",
"versionCopy": "Κάνε κλικ για αντιγραφή",
"versionNotAvailable": "Έκδοση μη διαθέσιμη",
"width": "Πλάτος"
},
"toast": {
"copyStyles": "Αντιγράφηκαν στυλ.",
"copyToClipboard": "Αντιγράφηκε στο πρόχειρο.",
"copyToClipboardAsPng": "Αντιγράφτηκε στο πρόχειρο ως PNG."
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "Couldn't decrypt data.",
"uploadedSecurly": "The upload has been secured with end-to-end encryption, which means that Excalidraw server and third parties can't read the content.",
"loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?",
"collabStopOverridePrompt": "Stopping the session will overwrite your previous, locally stored drawing. Are you sure?\n\n(If you want to keep your local drawing, simply close the browser tab instead.)",
"errorLoadingLibrary": "There was an error loading the third party library.",
"confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
"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?",
@@ -234,10 +235,14 @@
"storage": "Storage",
"title": "Stats for nerds",
"total": "Total",
"version": "Version",
"versionCopy": "Click to copy",
"versionNotAvailable": "Version not available",
"width": "Width"
},
"toast": {
"copyStyles": "Copied styles.",
"copyToClipboard": "Copied to clipboard.",
"copyToClipboardAsPng": "Copied to clipboard as PNG."
}
}

View File

@@ -80,9 +80,9 @@
"gridMode": "Modo cuadrícula",
"addToLibrary": "Añadir a la biblioteca",
"removeFromLibrary": "Eliminar de la biblioteca",
"libraryLoadingMessage": "Cargando biblioteca...",
"libraryLoadingMessage": "Cargando biblioteca",
"libraries": "Explorar bibliotecas",
"loadingScene": "Cargando escena...",
"loadingScene": "Cargando escena",
"align": "Alinear",
"alignTop": "Alineación superior",
"alignBottom": "Alineación inferior",
@@ -136,6 +136,7 @@
"decryptFailed": "No se pudieron descifrar los datos.",
"uploadedSecurly": "La carga ha sido asegurada con cifrado de principio a fin, lo que significa que el servidor de Excalidraw y terceros no pueden leer el contenido.",
"loadSceneOverridePrompt": "Si carga este dibujo externo, reemplazará el que tiene. ¿Desea continuar?",
"collabStopOverridePrompt": "Detener la sesión sobrescribirá su dibujo anterior almacenado en local. ¿Estás seguro?\n\n(Si quieres mantener tu dibujo en local, simplemente cierre la pestaña del navegador en su lugar.)",
"errorLoadingLibrary": "Se ha producido un error al cargar la biblioteca de terceros.",
"confirmAddLibrary": "Esto añadirá {{numShapes}} forma(s) a tu biblioteca. ¿Estás seguro?",
"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?",
@@ -234,10 +235,14 @@
"storage": "Almacenamiento",
"title": "Estadísticas para nerds",
"total": "Total",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Ancho"
},
"toast": {
"copyStyles": "Estilos copiados.",
"copyToClipboard": "",
"copyToClipboardAsPng": "Copiado al portapapeles como PNG."
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "رمزگشایی داده ها امکان پذیر نیست.",
"uploadedSecurly": "آپلود با رمزگذاری دو طرفه انجام میشود، به این معنی که سرور Excalidraw و اشخاص ثالث نمی توانند مطالب شما را بخوانند.",
"loadSceneOverridePrompt": "بارگزاری یک طرح خارجی محتوای فعلی رو از بین میبرد. آیا میخواهید ادامه دهید؟",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "خطایی در بارگذاری کتابخانه ثالث وجود داشت.",
"confirmAddLibrary": "{{numShapes}} از اشکال به کتابخانه شما اضافه خواهد شد. مطمئن هستید؟",
"imageDoesNotContainScene": "وارد کردن تصویر در این لحظه امکان پذیر نمی باشد.\nآیا مایل به وارد کردن یک صحنه هستید؟ این تصویر به نظر می رسد که فاقد هرگونه اطلاعاتی مربوط به صحنه باشد. آیا این گزینه را در زمان وارد کردن تصویر فعال کرده اید؟",
@@ -234,10 +235,14 @@
"storage": "حافظه",
"title": "آمار برای نردها",
"total": "مجموع",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "عرض"
},
"toast": {
"copyStyles": "کپی سبک.",
"copyToClipboard": "",
"copyToClipboardAsPng": "کپی در حافطه موقت به صورت PNG."
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "Salauksen purkaminen epäonnistui.",
"uploadedSecurly": "Lähetys on turvattu päästä päähän salauksella. Excalidrawin palvelin ja kolmannet osapuolet eivät voi lukea sisältöä.",
"loadSceneOverridePrompt": "Ulkopuolisen piirroksen lataaminen korvaa nykyisen sisältösi. Haluatko jatkaa?",
"collabStopOverridePrompt": "Istunnon lopettaminen korvaa aiemman, paikallisesti tallennetun piirustuksen. Oletko varma?\n\n(Jos haluat pitää paikallisen piirustuksen, sulje selaimen välilehti sen sijaan.)",
"errorLoadingLibrary": "Kolmannen osapuolen kirjastoa ladattaessa tapahtui virhe.",
"confirmAddLibrary": "Tämä lisää {{numShapes}} muotoa kirjastoosi. Oletko varma?",
"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?",
@@ -234,10 +235,14 @@
"storage": "Tallennustila",
"title": "Nörttien tilastot",
"total": "Yhteensä",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Leveys"
},
"toast": {
"copyStyles": "Tyylit kopioitu.",
"copyToClipboard": "",
"copyToClipboardAsPng": "Kopioitu leikepöydälle PNG-tiedostona."
}
}

View File

@@ -80,9 +80,9 @@
"gridMode": "Mode grille",
"addToLibrary": "Ajouter à la bibliothèque",
"removeFromLibrary": "Supprimer de la bibliothèque",
"libraryLoadingMessage": "Chargement de la bibliothèque...",
"libraryLoadingMessage": "Chargement de la bibliothèque",
"libraries": "Parcourir les bibliothèques",
"loadingScene": "Chargement de la scène...",
"loadingScene": "Chargement de la scène",
"align": "Aligner",
"alignTop": "Aligner en haut",
"alignBottom": "Aligner en bas",
@@ -136,6 +136,7 @@
"decryptFailed": "Les données n'ont pas pu être déchiffrées.",
"uploadedSecurly": "Le téléchargement a été sécurisé avec un chiffrement de bout en bout, ce qui signifie que ni Excalidraw ni personne d'autre ne peut en lire le contenu.",
"loadSceneOverridePrompt": "Le chargement d'un dessin externe remplacera votre contenu actuel. Souhaitez-vous continuer ?",
"collabStopOverridePrompt": "Arrêter la session écrasera votre précédent dessin stocké localement. Êtes-vous sûr·e ?\n\n(Si vous voulez garder votre dessin local, fermez simplement l'onglet du navigateur à la place.)",
"errorLoadingLibrary": "Une erreur s'est produite lors du chargement de la bibliothèque tierce.",
"confirmAddLibrary": "Cela va ajouter {{numShapes}} forme(s) à votre bibliothèque. Êtes-vous sûr·e ?",
"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 ?",
@@ -234,10 +235,14 @@
"storage": "Stockage",
"title": "Stats pour les nerds",
"total": "Total",
"version": "Version",
"versionCopy": "Cliquer pour copier",
"versionNotAvailable": "Version non disponible",
"width": "Largeur"
},
"toast": {
"copyStyles": "Styles copiés.",
"copyToClipboard": "Copié vers le presse-papiers.",
"copyToClipboardAsPng": "Copié vers le presse-papier en PNG."
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "לא ניתן לפענח מידע.",
"uploadedSecurly": "ההעלאה הוצפנה מקצה לקצה, ולכן שרת Excalidraw וצד שלישי לא יכולים לקרוא את התוכן.",
"loadSceneOverridePrompt": "טעינה של ציור חיצוני תחליף את התוכן הקיים שלך. האם תרצה להמשיך?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "קרתה שגיאה בטעינת הספריה החיצונית.",
"confirmAddLibrary": "הפעולה תוסיף {{numShapes}} צורה(ות) לספריה שלך. האם אתה בטוח?",
"imageDoesNotContainScene": "אין תמיכה בייבוא תמונות כעת.\n\nהאם אתה רוצה לייבא תצוגה? התמונה הזאת אינה מכילה מידע על תצוגה. האם הפעלת את האפשרות הזאת בזמן הוצאת המידע?",
@@ -234,10 +235,14 @@
"storage": "אחסון",
"title": "סטטיסטיקות לחנונים",
"total": "סה״כ",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "רוחב"
},
"toast": {
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": ""
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "डेटा को डिक्रिप्ट नहीं किया जा सका।",
"uploadedSecurly": "अपलोड को एंड-टू-एंड एन्क्रिप्शन के साथ सुरक्षित किया गया है, जिसका मतलब है कि एक्सक्लूसिव सर्वर और थर्ड पार्टी कंटेंट नहीं पढ़ सकते हैं।",
"loadSceneOverridePrompt": "लोड हो रहा है बाहरी ड्राइंग आपके मौजूदा सामग्री को बदल देगा। क्या आप जारी रखना चाहते हैं?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "लाइब्रेरी लोड करने में त्रुटि",
"confirmAddLibrary": "लाइब्रेरी जोड़ें पुष्‍टि करें आकार संख्या",
"imageDoesNotContainScene": "दृश्य में छवि नहीं है",
@@ -201,25 +202,25 @@
"title": "गलती"
},
"helpDialog": {
"blog": "",
"click": "",
"curvedArrow": "",
"curvedLine": "",
"blog": "हमारा ब्लॉग पढे",
"click": "क्लिक करें",
"curvedArrow": "वक्र तीर",
"curvedLine": "वक्र रेखा",
"documentation": "",
"drag": "",
"editor": "",
"github": "",
"howto": "",
"or": "",
"preventBinding": "",
"shapes": "",
"shortcuts": "",
"textFinish": "",
"textNewLine": "",
"title": "",
"view": "",
"zoomToFit": "",
"zoomToSelection": ""
"drag": "खींचें",
"editor": "संपादक",
"github": "मुद्दा मिला? प्रस्तुत करें",
"howto": "हमारे गाइड का पालन करें",
"or": "या",
"preventBinding": "तीर बंधन रोकें",
"shapes": "आकृतियाँ",
"shortcuts": "कीबोर्ड के शॉर्टकट्स",
"textFinish": "संपादन समाप्त करें (पाठ)",
"textNewLine": "नई पंक्ति जोड़ें (पाठ)",
"title": "मदद",
"view": "दृश्य",
"zoomToFit": "सभी तत्वों को फिट करने के लिए ज़ूम करें",
"zoomToSelection": "चयन तक ज़ूम करे"
},
"encrypted": {
"tooltip": "आपके चित्र अंत-से-अंत एन्क्रिप्टेड हैं, इसलिए एक्सक्लूसिव्रॉव के सर्वर उन्हें कभी नहीं देखेंगे।"
@@ -234,10 +235,14 @@
"storage": "संग्रह",
"title": "बेवकूफ के लिए आँकड़े",
"total": "कुल",
"version": "संस्करण",
"versionCopy": "काॅपी करने के लिए क्लिक करें",
"versionNotAvailable": "संस्करण उपलब्ध नहीं है",
"width": "चौड़ाई"
},
"toast": {
"copyStyles": "",
"copyToClipboardAsPng": ""
"copyStyles": "काॅपी कीए स्टाइल",
"copyToClipboard": "क्लिपबोर्ड में कॉपी कीए",
"copyToClipboardAsPng": "क्लिपबोर्ड में PNG के रूप में कॉपी किए"
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "Nem sikerült visszafejteni a titkosított adatot.",
"uploadedSecurly": "A feltöltést végpontok közötti titkosítással biztosítottuk, ami azt jelenti, hogy egy harmadik fél nem tudja megnézni a tartalmát, beleértve az Excalidraw szervereit is.",
"loadSceneOverridePrompt": "A betöltött külső rajz felül fogja írnia meglévőt. Szeretnéd folytatni?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Hibába ütközött a harmarmadik féltől származó könyvtár betöltése.",
"confirmAddLibrary": "Ez a művelet {{numShapes}} formát fog hozzáadni a könyvtáradhoz. Biztos vagy benne?",
"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.",
@@ -234,10 +235,14 @@
"storage": "Tárhely",
"title": "Statisztikák",
"total": "Összesen",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Szélesség"
},
"toast": {
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": ""
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "Tidak dapat mengdekripsi data.",
"uploadedSecurly": "Pengunggahan ini telah diamankan menggunakan enkripsi end-to-end, artinya server Excalidraw dan pihak ketiga tidak data membaca nya",
"loadSceneOverridePrompt": "Memuat gambar external akan mengganti konten Anda yang ada. Apakah Anda ingin melanjutkan?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Terdapat kesalahan dalam memuat pustaka pihak ketiga.",
"confirmAddLibrary": "Ini akan menambahkan {{numShapes}} bentuk ke pustaka Anda. Anda yakin?",
"imageDoesNotContainScene": "Mengimpor gambar tidak didukung saat ini.\n\nApakah Anda ingin impor pemandangan? Gambar ini tidak berisi data pemandangan. Sudah ka Anda aktifkan ini ketika ekspor?",
@@ -234,10 +235,14 @@
"storage": "Penyimpanan",
"title": "Statistik untuk nerd",
"total": "Total",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Lebar"
},
"toast": {
"copyStyles": "Gaya tersalin.",
"copyToClipboard": "",
"copyToClipboardAsPng": "Tersalin ke clipboard sebagai PNG."
}
}

View File

@@ -80,7 +80,7 @@
"gridMode": "Modalità griglia",
"addToLibrary": "Aggiungi alla libreria",
"removeFromLibrary": "Rimuovi dalla libreria",
"libraryLoadingMessage": "Caricamento della biblioteca…",
"libraryLoadingMessage": "Caricamento libreria…",
"libraries": "Sfoglia librerie",
"loadingScene": "Caricamento della scena…",
"align": "Allinea",
@@ -136,6 +136,7 @@
"decryptFailed": "Impossibile decriptare i dati.",
"uploadedSecurly": "L'upload è stato protetto con la crittografia end-to-end, il che significa che il server Excalidraw e terze parti non possono leggere il contenuto.",
"loadSceneOverridePrompt": "Se carichi questo disegno esterno, sostituirà quello che hai. Vuoi continuare?",
"collabStopOverridePrompt": "Interrompere la sessione sovrascriverà il precedente disegno memorizzato localmente. Sei sicuro?\n\n(Se vuoi mantenere il tuo disegno locale, chiudi semplicemente la scheda del browser.)",
"errorLoadingLibrary": "Si è verificato un errore nel caricamento della libreria di terze parti.",
"confirmAddLibrary": "Questo aggiungerà {{numShapes}} forma(e) alla tua libreria. Sei sicuro?",
"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?",
@@ -234,10 +235,14 @@
"storage": "Memoria",
"title": "Statistiche per nerd",
"total": "Totale",
"version": "Versione",
"versionCopy": "Clicca per copiare",
"versionNotAvailable": "Versione non disponibile",
"width": "Larghezza"
},
"toast": {
"copyStyles": "Stili copiati.",
"copyToClipboard": "Copiato negli appunti.",
"copyToClipboardAsPng": "Copiato negli appunti come PNG."
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "データを復号できませんでした。",
"uploadedSecurly": "データのアップロードはエンドツーエンド暗号化によって保護されています。Excalidrawサーバーと第三者はデータの内容を見ることができません。",
"loadSceneOverridePrompt": "外部図面を読み込むと、既存のコンテンツが置き換わります。続行しますか?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "サードパーティライブラリの読み込み中にエラーが発生しました。",
"confirmAddLibrary": "{{numShapes}} 個の図形をライブラリに追加します。よろしいですか?",
"imageDoesNotContainScene": "",
@@ -234,10 +235,14 @@
"storage": "",
"title": "",
"total": "合計",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "幅"
},
"toast": {
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": ""
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "D awezɣi tukksa n uwgelhen i yisefka.",
"uploadedSecurly": "Asili yettwasɣelles s uwgelhen ixef s ixef, ayagi yebɣa ad d-yini belli aqeddac n Excalidraw akked medden ur zmiren ara ad ɣren agbur.",
"loadSceneOverridePrompt": "Asali n wunuɣ uffiɣ ad isemselsi agbur-inek (m) yellan. Tebɣiḍ ad tkemmeleḍ?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Teḍra-d tuccḍa deg usali n temkarḍit n wis kraḍ.",
"confirmAddLibrary": "Ayagi adirnu talɣa (win) {{numShapes}} ɣer temkarḍit-inek (m). Tetḥeqqeḍ?",
"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ḍ?",
@@ -234,10 +235,14 @@
"storage": "Aḥraz",
"title": "",
"total": "Aɣrud",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Tehri"
},
"toast": {
"copyStyles": "Iɣunab yettwaneɣlen.",
"copyToClipboard": "",
"copyToClipboardAsPng": "Yettwanɣel ɣer tecfawit am PNG."
}
}

View File

@@ -80,9 +80,9 @@
"gridMode": "격자 방식",
"addToLibrary": "라이브러리에 추가",
"removeFromLibrary": "라이브러리에서 제거",
"libraryLoadingMessage": "라이브러리 불러오는 중...",
"libraryLoadingMessage": "라이브러리 불러오는 중",
"libraries": "라이브러리 찾기",
"loadingScene": "화면 불러오는 중...",
"loadingScene": "화면 불러오는 중",
"align": "정렬",
"alignTop": "상단 정렬",
"alignBottom": "하단 정렬",
@@ -136,6 +136,7 @@
"decryptFailed": "데이터를 복호화하지 못했습니다.",
"uploadedSecurly": "업로드는 종단 간 암호화로 보호되므로 Excalidraw 서버 및 타사가 콘텐츠를 읽을 수 없습니다.",
"loadSceneOverridePrompt": "외부 파일을 불러 오면 기존 콘텐츠가 대체됩니다. 계속 진행할까요?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "외부 라이브러리를 불러오는 중에 문제가 발생했습니다.",
"confirmAddLibrary": "{{numShapes}}개의 모양이 라이브러리에 추가됩니다. 계속하시겠어요?",
"imageDoesNotContainScene": "이미지에서 불러오기는 현재 지원되지 않습니다.\n\n화면을 불러오려고 하셨나요? 이미지에 화면 정보가 없는 것 같습니다. 내보낼 때 화면을 포함했나요?",
@@ -234,10 +235,14 @@
"storage": "저장공간",
"title": "덕후들을 위한 통계",
"total": "합계",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "너비"
},
"toast": {
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": ""
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "အချက်အလက်ဖော်ယူ၍မရပါ။",
"uploadedSecurly": "တင်သွင်းအချက်အလက်များအား နှစ်ဘက်စွန်းတိုင်လျှို့ဝှက်စနစ်အသုံးပြု၍လုံခြုံစွာထိန်းသိမ်းထားပါသဖြင့် Excalidraw ဆာဗာနှင့်ဆက်စပ်အဖွဲ့အစည်းများပင်လျှင်မဖတ်ရှုနိုင်ပါ။",
"loadSceneOverridePrompt": "လက်ရှိရေးဆွဲထားသမျှအား ပြင်ပမှတင်သွင်းသောပုံနှင့်အစားထိုးပါမည်။ ဆက်လက်ဆောင်ရွက်လိုပါသလား။",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "ပြင်ပမှမှတ်တမ်းအားတင်သွင်းရာတွင်အမှားအယွင်းရှိနေသည်။",
"confirmAddLibrary": "{{numShapes}} ခုသောပုံသဏ္ဌာန်အားမှတ်တမ်းတင်ပါမည်။ အတည်ပြုပါ။",
"imageDoesNotContainScene": "",
@@ -234,10 +235,14 @@
"storage": "သိုလှောင်ခန်း",
"title": "အက္ခရာများအတွက်အချက်အလက်များ",
"total": "စုစုပေါင်း",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "အကျယ်"
},
"toast": {
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": ""
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "Kunne ikke dekryptere data.",
"uploadedSecurly": "Opplastingen er kryptert og kan ikke leses av Excalidraw-serveren eller tredjeparter.",
"loadSceneOverridePrompt": "Å laste inn ekstern tegning vil erstatte det eksisterende innholdet. Ønsker du å fortsette?",
"collabStopOverridePrompt": "Hvis du slutter økten, overskrives din forrige, lokalt lagrede tegning. Er du sikker?\n\n(Hvis du ønsker å beholde din lokale tegning, bare lukk nettleserfanen i stedet.)",
"errorLoadingLibrary": "Det oppstod en feil under lasting av tredjepartsbiblioteket.",
"confirmAddLibrary": "Dette vil legge til {{numShapes}} figur(er) i biblioteket ditt. Er du sikker?",
"imageDoesNotContainScene": "Importering av bilder støttes ikke for øyeblikket.\n\nVil du importere en scene? Dette bildet ser ikke ut til å inneholde noen scene-data. Har du aktivert dette under eksporten?",
@@ -234,10 +235,14 @@
"storage": "Lagring",
"title": "Statistikk for nerder",
"total": "Totalt",
"version": "Versjon",
"versionCopy": "Klikk for å kopiere",
"versionNotAvailable": "Versjon ikke tilgjengelig",
"width": "Bredde"
},
"toast": {
"copyStyles": "Kopierte stiler.",
"copyToClipboard": "Kopiert til utklippstavlen.",
"copyToClipboardAsPng": "Kopiert til utklippstavlen som PNG."
}
}

View File

@@ -80,9 +80,9 @@
"gridMode": "Rasterweergave",
"addToLibrary": "Voeg toe aan bibliotheek",
"removeFromLibrary": "Verwijder uit bibliotheek",
"libraryLoadingMessage": "Bibliotheek laden...",
"libraryLoadingMessage": "Bibliotheek laden",
"libraries": "Blader door bibliotheken",
"loadingScene": "Scène laden...",
"loadingScene": "Scène laden",
"align": "Uitlijnen",
"alignTop": "Boven uitlijnen",
"alignBottom": "Onder uitlijnen",
@@ -136,6 +136,7 @@
"decryptFailed": "Kan gegevens niet decoderen.",
"uploadedSecurly": "De upload is beveiligd met end-to-end encryptie, wat betekent dat de Excalidraw server en derden de inhoud niet kunnen lezen.",
"loadSceneOverridePrompt": "Het laden van externe tekening zal uw bestaande inhoud vervangen. Wil je doorgaan?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Bij het laden van de externe bibliotheek is een fout opgetreden.",
"confirmAddLibrary": "Hiermee worden {{numShapes}} vorm(n) aan uw bibliotheek toegevoegd. Ben je het zeker?",
"imageDoesNotContainScene": "Afbeeldingen importeren wordt op dit moment niet ondersteund.\n\nWil je een scène importeren? Deze afbeelding lijkt geen scène gegevens te bevatten. Heb je dit geactiveerd tijdens het exporteren?",
@@ -234,10 +235,14 @@
"storage": "Opslag",
"title": "Statistieken voor nerds",
"total": "Totaal",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Breedte"
},
"toast": {
"copyStyles": "Stijlen gekopieerd.",
"copyToClipboard": "",
"copyToClipboardAsPng": "Gekopieerd naar klembord als PNG."
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "Kunne ikkje dekryptere data.",
"uploadedSecurly": "Opplastinga er kryptert og er ikkje mogleg å lese av Excalidraw-serveren eller tredjepartar.",
"loadSceneOverridePrompt": "Innlasting av ekstern teikning erstattar ditt eksisterande innhald. Ynskjer du å fortsette?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Det oppstod ein feil under lastinga av tredjepartsbibliotek.",
"confirmAddLibrary": "Dette vil legge til {{numShapes}} form(er) i biblioteket ditt. Er du sikker?",
"imageDoesNotContainScene": "Importering av bilder støttes ikkje for p. t.\n\nVil du importere ein scene? Dette bildet ser ikkje ut til å inneholde noen scene-data. Har du aktivert dette under eksporten?",
@@ -234,10 +235,14 @@
"storage": "Lagring",
"title": "Statistikk for nerdar",
"total": "Totalt",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Breidde"
},
"toast": {
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": ""
}
}

View File

@@ -80,9 +80,9 @@
"gridMode": "ਜਾਲੀਦਾਰ ਮੋਡ",
"addToLibrary": "ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਜੋੜੋ",
"removeFromLibrary": "ਲਾਇਬ੍ਰੇਰੀ 'ਚੋਂ ਹਟਾਓ",
"libraryLoadingMessage": "ਲਾਇਬ੍ਰੇਰੀ ਲੋਡ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ...",
"libraryLoadingMessage": "ਲਾਇਬ੍ਰੇਰੀ ਲੋਡ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ",
"libraries": "ਲਾਇਬ੍ਰੇਰੀਆਂ ਬਰਾਉਜ਼ ਕਰੋ",
"loadingScene": "ਦ੍ਰਿਸ਼ ਲੋਡ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ...",
"loadingScene": "ਦ੍ਰਿਸ਼ ਲੋਡ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ",
"align": "ਇਕਸਾਰ",
"alignTop": "ਉੱਪਰ ਇਕਸਾਰ ਕਰੋ",
"alignBottom": "ਹੇਠਾਂ ਇਕਸਾਰ ਕਰੋ",
@@ -92,7 +92,7 @@
"centerHorizontally": "ਖੜ੍ਹਵੇਂ ਵਿਚਕਾਰ ਕਰੋ",
"distributeHorizontally": "ਖੜ੍ਹਵੇਂ ਇਕਸਾਰ ਵੰਡੋ",
"distributeVertically": "ਲੇਟਵੇਂ ਇਕਸਾਰ ਵੰਡੋ",
"viewMode": ""
"viewMode": "ਦੇਖੋ ਮੋਡ"
},
"buttons": {
"clearReset": "ਕੈਨਵਸ ਰੀਸੈੱਟ ਕਰੋ",
@@ -136,6 +136,7 @@
"decryptFailed": "ਡਾਟਾ ਡੀਕਰਿਪਟ ਨਹੀਂ ਕਰ ਸਕੇ।",
"uploadedSecurly": "ਅੱਪਲੋਡ ਸਿਰੇ-ਤੋਂ-ਸਿਰੇ ਤੱਕ ਇਨਕਰਿਪਸ਼ਨ ਨਾਲ ਸੁਰੱਖਿਅਤ ਕੀਤੀ ਹੋਈ ਹੈ, ਜਿਸਦਾ ਮਤਲਬ ਇਹ ਹੈ ਕਿ Excalidraw ਸਰਵਰ ਅਤੇ ਤੀਜੀ ਧਿਰ ਦੇ ਬੰਦੇ ਸਮੱਗਰੀ ਨੂੰ ਪੜ੍ਹ ਨਹੀਂ ਸਕਦੇ।",
"loadSceneOverridePrompt": "ਬਾਹਰੀ ਡਰਾਇੰਗ ਨੂੰ ਲੋਡ ਕਰਨਾ ਤੁਹਾਡੀ ਮੌਜੂਦਾ ਸਮੱਗਰੀ ਦੀ ਥਾਂ ਲੈ ਲਵੇਗਾ। ਕੀ ਤੁਸੀਂ ਜਾਰੀ ਰੱਖਣਾ ਚਾਹੁੰਦੇ ਹੋ?",
"collabStopOverridePrompt": "ਇਜਲਾਸ ਨੂੰ ਰੋਕਣਾ ਪਿਛਲੀ ਲੋਕਲ ਸਾਂਭੀ ਡਰਾਇੰਗ ਦੀ ਥਾਂ ਲੈ ਲਵੇਗਾ। ਪੱਕਾ ਇੰਝ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?\n\n(ਜੇ ਤੁਸੀਂ ਆਪਣੀ ਲੋਕਲ ਡਰਾਇੰਗ ਨੂੰ ਬਰਕਰਾਰ ਰੱਖਣਾ ਚਾਹੁੰਦੇ ਹੋ ਤਾਂ ਇਹ ਕਰਨ ਦੀ ਬਜਾਏ ਬੱਸ ਆਪਣਾ ਟੈਬ ਬੰਦ ਕਰ ਦਿਉ।)",
"errorLoadingLibrary": "ਤੀਜੀ ਧਿਰ ਦੀ ਲਾਇਬ੍ਰੇਰੀ ਨੂੰ ਲੋਡ ਕਰਨ ਵਿੱਚ ਗਲਤੀ ਹੋਈ ਸੀ।",
"confirmAddLibrary": "ਇਹ ਤੁਹਾਡੀ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ {{numShapes}} ਆਕ੍ਰਿਤੀ(ਆਂ) ਨੂੰ ਜੋੜ ਦੇਵੇਗਾ। ਕੀ ਤੁਸੀਂ ਪੱਕਾ ਇੰਝ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?",
"imageDoesNotContainScene": "ਫਿਲਹਾਲ ਤਸਵੀਰਾਂ ਨੂੰ ਆਯਾਤ ਕਰਨ ਦਾ ਸਮਰਥਨ ਨਹੀਂ ਕਰਦਾ।\n\nਕੀ ਤੁਸੀਂ ਦ੍ਰਿਸ਼ ਨੂੰ ਆਯਾਤ ਕਰਨਾ ਚਾਹੁੰਦੇ ਸੀ? ਇਸ ਤਸਵੀਰ ਵਿੱਚ ਦ੍ਰਿਸ਼ ਦਾ ਕੋਈ ਵੀ ਡਾਟਾ ਨਜ਼ਰ ਨਹੀਂ ਆ ਰਿਹਾ। ਕੀ ਨਿਰਯਾਤ ਦੌਰਾਨ ਤੁਸੀਂ ਇਹ ਸਮਰੱਥ ਕੀਤਾ ਸੀ?",
@@ -234,10 +235,14 @@
"storage": "ਸਟੋਰੇਜ",
"title": "ਪੜਾਕੂਆਂ ਲਈ ਅੰਕੜੇ",
"total": "ਕੁੱਲ",
"version": "ਸੰਸਕਰਨ",
"versionCopy": "ਕਾਪੀ ਕਰਨ ਲਈ ਕਲਿੱਕ ਕਰੋ",
"versionNotAvailable": "ਸੰਸਕਰਨ ਉਪਲਬਧ ਨਹੀਂ ਹੈ",
"width": "ਚੌੜਾਈ"
},
"toast": {
"copyStyles": "ਕਾਪੀ ਕੀਤੇ ਸਟਾਇਲ।",
"copyToClipboard": "ਕਲਿੱਪਬੋਰਡ 'ਤੇ ਕਾਪੀ ਕੀਤਾ।",
"copyToClipboardAsPng": "ਕਲਿੱਪਬੋਰਡ 'ਤੇ PNG ਵਜੋਂ ਕਾਪੀ ਕੀਤਾ।"
}
}

View File

@@ -1,36 +1,36 @@
{
"ar-SA": 89,
"bg-BG": 93,
"ca-ES": 89,
"de-DE": 100,
"el-GR": 100,
"ar-SA": 87,
"bg-BG": 91,
"ca-ES": 87,
"de-DE": 98,
"el-GR": 99,
"en": 100,
"es-ES": 100,
"fa-IR": 98,
"fi-FI": 100,
"es-ES": 98,
"fa-IR": 95,
"fi-FI": 98,
"fr-FR": 100,
"he-IL": 89,
"hi-IN": 89,
"hu-HU": 89,
"id-ID": 100,
"he-IL": 87,
"hi-IN": 98,
"hu-HU": 87,
"id-ID": 97,
"it-IT": 100,
"ja-JP": 81,
"kab-KAB": 97,
"ko-KR": 89,
"my-MM": 83,
"ja-JP": 79,
"kab-KAB": 94,
"ko-KR": 87,
"my-MM": 81,
"nb-NO": 100,
"nl-NL": 100,
"nn-NO": 92,
"pa-IN": 99,
"pl-PL": 90,
"nl-NL": 97,
"nn-NO": 90,
"pa-IN": 100,
"pl-PL": 88,
"pt-BR": 100,
"pt-PT": 99,
"pt-PT": 97,
"ro-RO": 100,
"ru-RU": 99,
"ru-RU": 97,
"sk-SK": 100,
"sv-SE": 100,
"tr-TR": 89,
"uk-UA": 99,
"zh-CN": 99,
"sv-SE": 98,
"tr-TR": 87,
"uk-UA": 97,
"zh-CN": 98,
"zh-TW": 100
}

View File

@@ -80,9 +80,9 @@
"gridMode": "Tryb siatki",
"addToLibrary": "Dodaj do biblioteki",
"removeFromLibrary": "Usuń z biblioteki",
"libraryLoadingMessage": "Wczytywanie biblioteki...",
"libraryLoadingMessage": "Ładowanie biblioteki",
"libraries": "Przeglądaj biblioteki",
"loadingScene": "Ładowanie sceny...",
"loadingScene": "Ładowanie sceny",
"align": "Wyrównaj",
"alignTop": "Wyrównaj do góry",
"alignBottom": "Wyrównaj do dołu",
@@ -136,6 +136,7 @@
"decryptFailed": "Nie udało się odszyfrować danych.",
"uploadedSecurly": "By zapewnić Ci prywatność, udostępnianie projektu jest zabezpieczone szyfrowaniem end-to-end, co oznacza, że poza tobą i osobą z którą podzielisz się linkiem, nikt nie ma dostępu do tego co udostępniasz.",
"loadSceneOverridePrompt": "Wczytanie zewnętrznego rysunku zastąpi istniejącą zawartość. Czy chcesz kontynuować?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Wystąpił błąd podczas ładowania zewnętrznej biblioteki.",
"confirmAddLibrary": "To doda {{numShapes}} kształtów do twojej biblioteki. Jesteś pewien?",
"imageDoesNotContainScene": "Importowanie zdjęć nie jest obecnie obsługiwane.\n\nCzy chciałeś zaimportować scenę? Ten obraz nie zawiera żadnych danych sceny. Czy włączyłeś to podczas eksportowania?",
@@ -234,10 +235,14 @@
"storage": "Pamięć",
"title": "Statystyki dla nerdów",
"total": "Łącznie",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Szerokość"
},
"toast": {
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": ""
}
}

View File

@@ -80,9 +80,9 @@
"gridMode": "Modo grade",
"addToLibrary": "Adicionar à biblioteca",
"removeFromLibrary": "Remover da biblioteca",
"libraryLoadingMessage": "Carregando biblioteca...",
"libraryLoadingMessage": "Carregando biblioteca",
"libraries": "Procurar bibliotecas",
"loadingScene": "Carregando cena...",
"loadingScene": "Carregando cena",
"align": "Alinhamento",
"alignTop": "Alinhar ao topo",
"alignBottom": "Alinhar embaixo",
@@ -136,6 +136,7 @@
"decryptFailed": "Não foi possível descriptografar os dados.",
"uploadedSecurly": "O upload foi protegido com criptografia de ponta a ponta, o que significa que o servidor do Excalidraw e terceiros não podem ler o conteúdo.",
"loadSceneOverridePrompt": "Carregar um desenho externo substituirá o seu conteúdo existente. Deseja continuar?",
"collabStopOverridePrompt": "Ao interromper a sessão, você substituirá seu desenho anterior, armazenado localmente. Você tem certeza?\n\n(Se você deseja manter seu desenho local, simplesmente feche a aba do navegador.)",
"errorLoadingLibrary": "Houve um erro ao carregar a biblioteca de terceiros.",
"confirmAddLibrary": "Isso adicionará {{numShapes}} forma(s) à sua biblioteca. Tem certeza?",
"imageDoesNotContainScene": "A importação de imagens não é suportada no momento.\n\nVocê deseja importar uma cena? Esta imagem parece não conter dados de cena. Você ativou isto durante a exportação?",
@@ -234,10 +235,14 @@
"storage": "Armazenamento",
"title": "Estatísticas para nerds",
"total": "Total",
"version": "Versão",
"versionCopy": "Clique para copiar",
"versionNotAvailable": "Versão não disponível",
"width": "Largura"
},
"toast": {
"copyStyles": "Estilos copiados.",
"copyToClipboard": "Copiado para área de transferência.",
"copyToClipboardAsPng": "Copiado para a área de transferência como PNG."
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "Não foi possível descriptografar os dados.",
"uploadedSecurly": "O upload foi protegido com criptografia de ponta a ponta, o que significa que o servidor do Excalidraw e terceiros não podem ler o conteúdo.",
"loadSceneOverridePrompt": "Carregar um desenho externo substituirá o seu conteúdo existente. Deseja continuar?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Houve um erro ao carregar a biblioteca de terceiros.",
"confirmAddLibrary": "Isso adicionará {{numShapes}} forma(s) à sua biblioteca. Tem certeza?",
"imageDoesNotContainScene": "A importação de imagens não é suportada no momento.\n\nVocê deseja importar uma cena? Esta imagem parece não conter dados de cena. Você ativou isto durante a exportação?",
@@ -234,10 +235,14 @@
"storage": "Armazenamento",
"title": "Estatísticas para nerds",
"total": "Total",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Largura"
},
"toast": {
"copyStyles": "Estilos copiados.",
"copyToClipboard": "",
"copyToClipboardAsPng": "Copiado para o clipboard como PNG."
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "Datele nu au putut fi decriptate.",
"uploadedSecurly": "Încărcarea a fost securizată prin criptare integrală, însemnând că serverul Excalidraw și terții nu pot citi conținutul.",
"loadSceneOverridePrompt": "Încărcarea desenului extern va înlocui conținutul existent. Dorești să continui?",
"collabStopOverridePrompt": "Oprirea sesiunii va suprascrie desenul anterior stocat local. Confirmi alegerea?\n\n(Dacă vrei să păstrezi desenul local, pur și simplu închide fila navigatorului în schimb.)",
"errorLoadingLibrary": "A apărut o eroare la încărcarea bibliotecii terțe.",
"confirmAddLibrary": "Această acțiune va adăuga {{numShapes}} formă(e) la biblioteca ta. Confirmi?",
"imageDoesNotContainScene": "Importarea imaginilor nu este acceptată în acest moment.\n\nVoiai să imporți o scenă? Această imagine nu pare să conțină date de scenă. Ai activat această opțiune pe durata exportării?",
@@ -234,10 +235,14 @@
"storage": "Stocare",
"title": "Statistici pentru pasionați",
"total": "Total",
"version": "Versiune",
"versionCopy": "Clic pentru copiere",
"versionNotAvailable": "Versiune indisponibilă",
"width": "Lățime"
},
"toast": {
"copyStyles": "Stiluri copiate.",
"copyToClipboard": "Copiat în memoria temporară.",
"copyToClipboardAsPng": "Copiat în memoria temporară ca PNG."
}
}

View File

@@ -92,7 +92,7 @@
"centerHorizontally": "Центрировать по горизонтали",
"distributeHorizontally": "Распределить по горизонтали",
"distributeVertically": "Распределить по вертикали",
"viewMode": ""
"viewMode": "Вид"
},
"buttons": {
"clearReset": "Очистить холст и сбросить цвет фона",
@@ -136,6 +136,7 @@
"decryptFailed": "Не удалось расшифровать данные.",
"uploadedSecurly": "Загружаемые данные защищена сквозным шифрованием, что означает, что сервер Excalidraw и третьи стороны не могут прочитать содержимое.",
"loadSceneOverridePrompt": "Загрузка рисунка приведёт к замене имеющегося содержимого. Вы хотите продолжить?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Произошла ошибка при загрузке сторонней библиотеки.",
"confirmAddLibrary": "Будет добавлено {{numShapes}} фигур в вашу библиотеку. Продолжить?",
"imageDoesNotContainScene": "Импорт изображений не поддерживается в данный момент.\n\nХотите импортировать сцену? Данное изображение не содержит данных о сцене. Было ли включено это во время экспорта?",
@@ -234,10 +235,14 @@
"storage": "Хранилище",
"title": "Статистика для ботаников",
"total": "Всего",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Ширина"
},
"toast": {
"copyStyles": "Скопированы стили.",
"copyToClipboard": "",
"copyToClipboardAsPng": "Скопировано в буфер обмена в формате PNG."
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "Nepodarilo sa rozšifrovať údaje.",
"uploadedSecurly": "Nahratie je zabezpečené end-to-end šifrovaním, takže Excalidraw server a tretie strany nedokážu prečítať jeho obsah.",
"loadSceneOverridePrompt": "Nahratie externej kresby nahradí existujúci obsah. Prajete si pokračovať?",
"collabStopOverridePrompt": "Ukončenie schôdze nahradí vašu predchádzajúcu lokálne uloženú scénu. Ste si istý?\n\n(Ak si chcete ponechať lokálnu scénu, jednoducho iba zavrite kartu prehliadača.)",
"errorLoadingLibrary": "Nepodarilo sa načítať externú knižnicu.",
"confirmAddLibrary": "Týmto sa pridá {{numShapes}} tvar(ov) do vašej knižnice. Ste si istí?",
"imageDoesNotContainScene": "Importovanie obrázku v tomto momente nie je možné.\n\nChceli ste importovať scénu? Tento obrázok neobsahuje žiadne údaje scény. Povolili ste túto možnosť počas exportovania?",
@@ -234,10 +235,14 @@
"storage": "Úložisko",
"title": "Štatistiky",
"total": "Celkom",
"version": "Verzia",
"versionCopy": "Kliknutím skopírujete",
"versionNotAvailable": "Verzia nie je k dispozícii",
"width": "Šírka"
},
"toast": {
"copyStyles": "Štýly skopírované.",
"copyToClipboard": "Skopírované do schránky.",
"copyToClipboardAsPng": "Skopírované do schránky ako PNG."
}
}

View File

@@ -82,7 +82,7 @@
"removeFromLibrary": "Ta bort från bibliotek",
"libraryLoadingMessage": "Laddar bibliotek…",
"libraries": "Bläddra i bibliotek",
"loadingScene": "Laddar scen…",
"loadingScene": "Laddar skiss…",
"align": "Justera",
"alignTop": "Justera överkant",
"alignBottom": "Justera underkant",
@@ -136,6 +136,7 @@
"decryptFailed": "Kunde inte avkryptera data.",
"uploadedSecurly": "Uppladdning har säkrats med kryptering från ände till ände. vilket innebär att Excalidraw server och tredje part inte kan läsa innehållet.",
"loadSceneOverridePrompt": "Laddning av extern skiss kommer att ersätta ditt befintliga innehåll. Vill du fortsätta?",
"collabStopOverridePrompt": "Att stoppa sessionen kommer att skriva över din föregående, lokalt lagrade skiss. Är du säker?\n\n(Om du vill behålla din lokala skiss, stäng bara webbläsarfliken istället.)",
"errorLoadingLibrary": "Fel vid inläsning av tredjeparts bibliotek.",
"confirmAddLibrary": "Detta kommer att lägga till {{numShapes}} form(er) till ditt bibliotek. Är du säker?",
"imageDoesNotContainScene": "Importering av bilder stöds inte just nu.\n\nVill du importera en skiss? Den här bilden verkar inte innehålla någon skissdata. Har du aktiverat detta under export?",
@@ -234,10 +235,14 @@
"storage": "Lagring",
"title": "Statistik för nördar",
"total": "Totalt",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Bredd"
},
"toast": {
"copyStyles": "Kopierade stilar.",
"copyToClipboard": "",
"copyToClipboardAsPng": "Kopierat till urklipp som PNG."
}
}

View File

@@ -80,7 +80,7 @@
"gridMode": "Izgara modu",
"addToLibrary": "Kütüphaneye ekle",
"removeFromLibrary": "Kütüphaneden kaldır",
"libraryLoadingMessage": "Kütüphane yükleniyor...",
"libraryLoadingMessage": "Kütüphane yükleniyor",
"libraries": "Kütüphanelere gözat",
"loadingScene": "Çalışma alanı yükleniyor…",
"align": "Hizala",
@@ -136,6 +136,7 @@
"decryptFailed": "Şifrelenmiş veri çözümlenemedi.",
"uploadedSecurly": "Yükleme uçtan uca şifreleme ile korunmaktadır. Excalidraw sunucusu ve üçüncül şahıslar içeriği okuyamayacaktır.",
"loadSceneOverridePrompt": "Harici çizimler yüklemek mevcut olan içeriği değiştirecektir. Devam etmek istiyor musunuz?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Üçüncü taraf kitaplığı yüklerken bir hata oluştu.",
"confirmAddLibrary": "Bu, kitaplığınıza {{numShapes}} tane şekil ekleyecek. Emin misiniz?",
"imageDoesNotContainScene": "Resim ekleme şuan için desteklenmiyor.\nBir sahneyi içeri aktarmak mı istediniz? Bu dosya herhangi bir sahne içeriyor gibi durmuyor. Çıktı alırken sahneyi dahil ettiniz mi?",
@@ -234,10 +235,14 @@
"storage": "Depolama",
"title": "İnekler için istatistikler",
"total": "Toplam",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Genişlik"
},
"toast": {
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": ""
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "Не вдалося розшифрувати дані.",
"uploadedSecurly": "Це завантаження було захищене наскрізним шифруванням, а це означає що сервер Excalidraw та інші не зможуть прочитати вміст.",
"loadSceneOverridePrompt": "Завантаження зовнішнього креслення замінить ваш наявний контент. Продовжити?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Помилка при завантаженні сторонньої бібліотеки.",
"confirmAddLibrary": "Це призведе до додавання {{numShapes}} фігур до вашої бібліотеки. Ви впевнені?",
"imageDoesNotContainScene": "Імпортування зображень на даний момент не підтримується.\n\nЧи хочете ви імпортувати сцену? Це зображення не містить ніяких даних сцен. Ви увімкнули це під час експорту?",
@@ -234,10 +235,14 @@
"storage": "Сховище",
"title": "Статистика",
"total": "Всього",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Ширина"
},
"toast": {
"copyStyles": "Скопійовані стилі.",
"copyToClipboard": "",
"copyToClipboardAsPng": "Скопійовано в буфер обміну як PNG."
}
}

View File

@@ -92,7 +92,7 @@
"centerHorizontally": "水平居中",
"distributeHorizontally": "水平等距分布",
"distributeVertically": "垂直等距分布",
"viewMode": ""
"viewMode": "查看模式"
},
"buttons": {
"clearReset": "重置画布",
@@ -136,6 +136,7 @@
"decryptFailed": "无法解密数据。",
"uploadedSecurly": "上传已被端到端加密保护,这意味着 Excalidraw 的服务器和第三方都无法读取内容。",
"loadSceneOverridePrompt": "加载外部绘图将取代您现有的内容。您想要继续吗?",
"collabStopOverridePrompt": "停止会话将覆盖您先前本地存储的绘图。 您确定吗?\n\n(如果您想保持本地绘图,只需关闭浏览器选项卡。)",
"errorLoadingLibrary": "加载第三方库时出错。",
"confirmAddLibrary": "这将添加 {{numShapes}} 个形状到您的库。您确定吗?",
"imageDoesNotContainScene": "当前不支持导入图片。\n\n您想要导入画布数据吗此图像似乎不包含任何画布数据。您是否在导出过程中启用了嵌入画布的选项",
@@ -234,10 +235,14 @@
"storage": "存储",
"title": "详细统计信息",
"total": "总计",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "宽度"
},
"toast": {
"copyStyles": "复制样式",
"copyToClipboard": "",
"copyToClipboardAsPng": "复制为 PNG 到剪贴板"
}
}

View File

@@ -136,6 +136,7 @@
"decryptFailed": "無法解密資料。",
"uploadedSecurly": "上傳已通過 end-to-end 加密Excalidraw 伺服器和第三方無法皆讀取其內容。",
"loadSceneOverridePrompt": "讀取外部圖樣將取代目前的內容。是否要繼續?",
"collabStopOverridePrompt": "停止連線將覆蓋您先前於本機儲存的繪圖進度,是否確認?\n\n如要保留原有的本機繪圖進度直接關閉瀏覽器分頁即可。",
"errorLoadingLibrary": "載入第三方套件時出現錯誤。",
"confirmAddLibrary": "這將會將 {{numShapes}} 個圖形加入你的資料庫,你確定嗎?",
"imageDoesNotContainScene": "目前尚不支援載入圖片。\n您是否要載入場景此圖片中並無任何場景資料輸出時是否有選擇包含",
@@ -234,10 +235,14 @@
"storage": "儲存",
"title": "詳細統計",
"total": "合計",
"version": "版本",
"versionCopy": "點擊複製",
"versionNotAvailable": "無法取得版本",
"width": "寬度"
},
"toast": {
"copyStyles": "已複製樣式",
"copyToClipboard": "複製至剪貼簿。",
"copyToClipboardAsPng": "已複製 PNG 至剪貼簿"
}
}

View File

@@ -249,10 +249,9 @@ const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
export const getGridPoint = (
x: number,
y: number,
isGridOn: boolean,
gridSize: number,
gridSize: number | null,
): [number, number] => {
if (isGridOn) {
if (gridSize) {
return [
Math.round(x / gridSize) * gridSize,
Math.round(y / gridSize) * gridSize,

View File

@@ -36,7 +36,7 @@ export const exportToCanvas = ({
canvas.width = ret.width;
canvas.height = ret.height;
return canvas;
return { canvas, scale: ret.scale };
},
);
};

View File

@@ -74,7 +74,7 @@ const excalidrawDiagram = {
],
appState: {
viewBackgroundColor: "#ffffff",
gridSize: 20,
gridSize: null,
},
};

View File

@@ -48,6 +48,7 @@ import {
TransformHandleType,
} from "../element/transformHandles";
import { viewportCoordsToSceneCoords } from "../utils";
import { UserIdleState } from "../excalidraw-app/collab/types";
const strokeRectWithRotation = (
context: CanvasRenderingContext2D,
@@ -233,7 +234,7 @@ export const renderScene = (
context.scale(sceneState.zoom.value, sceneState.zoom.value);
// Grid
if (renderGrid && appState.showGrid) {
if (renderGrid && appState.gridSize) {
strokeGrid(
context,
appState.gridSize,
@@ -445,7 +446,9 @@ export const renderScene = (
const globalAlpha = context.globalAlpha;
context.strokeStyle = stroke;
context.fillStyle = background;
if (isOutOfBounds) {
const userState = sceneState.remotePointerUserStates[clientId];
if (isOutOfBounds || userState === UserIdleState.AWAY) {
context.globalAlpha = 0.2;
}
@@ -478,19 +481,25 @@ export const renderScene = (
context.stroke();
const username = sceneState.remotePointerUsernames[clientId];
const usernameAndIdleState = `${username ? `${username} ` : ""}${
userState === UserIdleState.AWAY
? "⚫️"
: userState === UserIdleState.IDLE
? "💤"
: "🟢"
}`;
if (!isOutOfBounds && username) {
if (!isOutOfBounds && usernameAndIdleState) {
const offsetX = x + width;
const offsetY = y + height;
const paddingHorizontal = 4;
const paddingVertical = 4;
const measure = context.measureText(username);
const measure = context.measureText(usernameAndIdleState);
const measureHeight =
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
// Border
context.fillStyle = stroke;
context.globalAlpha = globalAlpha;
context.fillRect(
offsetX - 1,
offsetY - 1,
@@ -506,8 +515,9 @@ export const renderScene = (
measureHeight + 2 * paddingVertical,
);
context.fillStyle = oc.white;
context.fillText(
username,
usernameAndIdleState,
offsetX + paddingHorizontal,
offsetY + paddingVertical + measure.actualBoundingBoxAscent,
);

View File

@@ -29,14 +29,14 @@ export const exportToCanvas = (
viewBackgroundColor: string;
shouldAddWatermark: boolean;
},
createCanvas: (width: number, height: number) => HTMLCanvasElement = (
width,
height,
) => {
createCanvas: (
width: number,
height: number,
) => { canvas: HTMLCanvasElement; scale: number } = (width, height) => {
const tempCanvas = document.createElement("canvas");
tempCanvas.width = width * scale;
tempCanvas.height = height * scale;
return tempCanvas;
return { canvas: tempCanvas, scale };
},
) => {
const sceneElements = getElementsAndWatermark(elements, shouldAddWatermark);
@@ -47,13 +47,16 @@ export const exportToCanvas = (
shouldAddWatermark,
);
const tempCanvas = createCanvas(width, height);
const { canvas: tempCanvas, scale: newScale = scale } = createCanvas(
width,
height,
);
renderScene(
sceneElements,
appState,
null,
scale,
newScale,
rough.canvas(tempCanvas),
tempCanvas,
{
@@ -65,6 +68,7 @@ export const exportToCanvas = (
remoteSelectedElementIds: {},
shouldCacheIgnoreZoom: false,
remotePointerUsernames: {},
remotePointerUserStates: {},
},
{
renderScrollbars: false,

View File

@@ -12,6 +12,7 @@ export type SceneState = {
remotePointerButton?: { [id: string]: string | undefined };
remoteSelectedElementIds: { [elementId: string]: string[] };
remotePointerUsernames: { [id: string]: string };
remotePointerUserStates: { [id: string]: string };
};
export type SceneScroll = {

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@ import { ExcalidrawImperativeAPI } from "./components/App";
import type { ResolvablePromise } from "./utils";
import { Spreadsheet } from "./charts";
import { Language } from "./i18n";
import { UserIdleState } from "./excalidraw-app/collab/types";
export type Point = Readonly<RoughPoint>;
@@ -31,6 +32,7 @@ export type Collaborator = {
button?: "up" | "down";
selectedElementIds?: AppState["selectedElementIds"];
username?: string | null;
userState?: UserIdleState;
};
export type AppState = {
@@ -84,8 +86,7 @@ export type AppState = {
toastMessage: string | null;
zenModeEnabled: boolean;
appearance: "light" | "dark";
gridSize: number;
showGrid: boolean;
gridSize: number | null;
viewModeEnabled: boolean;
/** top-most selected groups (i.e. does not include nested groups) */