Compare commits
54 Commits
change-grid
...
net-stats
| Author | SHA1 | Date | |
|---|---|---|---|
| 98c16659c9 | |||
| 5644063fc7 | |||
| 85b8050cc5 | |||
| 60d3bf1718 | |||
| b8e1b1f3ad | |||
| 57432b9779 | |||
| 156073a407 | |||
| f676a20332 | |||
| 63af29d345 | |||
| 69a1b74e05 | |||
| 163dbd47d4 | |||
| 08889adfa5 | |||
| aaf4943fa3 | |||
| 4fd18d1b3e | |||
| f8cf19cae9 | |||
| 6540c5460e | |||
| dc95cf3447 | |||
| 1ca985aa4a | |||
| bc9c8f7ee2 | |||
| 3eacd6f07b | |||
| afa929b2a2 | |||
| 370c9a643f | |||
| 22c57c8f4a | |||
| eef9662195 | |||
| 69a7b1f2b5 | |||
| 3b11b1d9d3 | |||
| b778035854 | |||
| 92c2a42edf | |||
| 7cadc3de52 | |||
| 336b7222de | |||
| 4d0ebf5ac5 | |||
| 86222662f2 | |||
| 066560311b | |||
| 949e9ae03a | |||
| b5151bda5a | |||
| 8157c84d11 | |||
| d6ca981f7a | |||
| f7f98d9dda | |||
| 1a67642fd1 | |||
| 6aa22bada8 | |||
| 00209ef9c3 | |||
| b79ef0d428 | |||
| dc25fe06d0 | |||
| 1e17c1967b | |||
| 23a8891e0e | |||
| 6c81a32d62 | |||
| f0f5430313 | |||
| e18e945cd3 | |||
| bd0c6e63ff | |||
| dbae33e4f8 | |||
| 0f4a053759 | |||
| 1837147c55 | |||
| 15f698dc21 | |||
| ce507b0a0b |
@@ -1,6 +1,10 @@
|
||||
name: Cancel previous runs
|
||||
|
||||
on: push
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
cancel:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Lint
|
||||
|
||||
on: push
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Tests
|
||||
|
||||
on: push
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
||||
</a>
|
||||
</p>
|
||||
<p>Ask questions or hang out on our <a target="_blank" href="https://discord.gg/UexuTaE">discord.gg/UexuTaE</a>.</p>
|
||||
</div>
|
||||
|
||||
## Try it now
|
||||
@@ -19,6 +20,14 @@ Go to [excalidraw.com](https://excalidraw.com) to start sketching.
|
||||
|
||||
Read the latest news and updates on our [blog](https://blog.excalidraw.com). A good start is to see all the updates of [One Year of Excalidraw](https://blog.excalidraw.com/one-year-of-excalidraw/).
|
||||
|
||||
## We accept donations
|
||||
|
||||
If you like the project, you can become a sponsor at [Open Collective](https://opencollective.com/excalidraw).
|
||||
|
||||
<a href="https://opencollective.com/excalidraw#category-CONTRIBUTE" target="_blank"><img src="https://opencollective.com/excalidraw/tiers/sponsors.svg?avatarHeight=64"/></a>
|
||||
|
||||
<a href="https://opencollective.com/excalidraw#category-CONTRIBUTE" target="_blank"><img src="https://opencollective.com/excalidraw/tiers/backers.svg?avatarHeight=32"/></a>
|
||||
|
||||
## Documentation
|
||||
|
||||
### Shortcuts
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
|
||||
<!-- Excalidraw version -->
|
||||
<meta name="version" content="{version}" />
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
href="FG_Virgil.woff2"
|
||||
|
||||
@@ -17,6 +17,6 @@ export const actionToggleGridMode = register({
|
||||
};
|
||||
},
|
||||
checked: (appState: AppState) => appState.gridSize !== null,
|
||||
contextItemLabel: "labels.gridMode",
|
||||
contextItemLabel: "labels.showGrid",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
||||
});
|
||||
|
||||
@@ -73,6 +73,8 @@ export const getDefaultAppState = (): Omit<
|
||||
zenModeEnabled: false,
|
||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||
viewModeEnabled: false,
|
||||
networkSpeed: 0,
|
||||
networkPing: 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -153,6 +155,8 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
zenModeEnabled: { browser: true, export: false },
|
||||
zoom: { browser: true, export: false },
|
||||
viewModeEnabled: { browser: false, export: false },
|
||||
networkSpeed: { browser: false, export: false },
|
||||
networkPing: { browser: false, export: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
||||
|
||||
+119
-6
@@ -48,8 +48,10 @@ import {
|
||||
ELEMENT_TRANSLATE_AMOUNT,
|
||||
ENV,
|
||||
EVENT,
|
||||
GRID_SIZE,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
MIME_TYPES,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
POINTER_BUTTON,
|
||||
TAP_TWICE_TIMEOUT,
|
||||
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
||||
@@ -182,6 +184,7 @@ import LayerUI from "./LayerUI";
|
||||
import { Stats } from "./Stats";
|
||||
import { Toast } from "./Toast";
|
||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||
import { getNetworkSpeed, getNetworkPing } from "../networkStats";
|
||||
|
||||
const { history } = createHistory();
|
||||
|
||||
@@ -204,6 +207,9 @@ const gesture: Gesture = {
|
||||
initialScale: null,
|
||||
};
|
||||
|
||||
const shouldEnableNetworkStats = !!(
|
||||
typeof process !== "undefined" && process.env?.REACT_APP_SOCKET_SERVER_URL
|
||||
);
|
||||
export type PointerDownState = Readonly<{
|
||||
// The first position at which pointerDown happened
|
||||
origin: Readonly<{ x: number; y: number }>;
|
||||
@@ -288,6 +294,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
height: window.innerHeight,
|
||||
};
|
||||
private scene: Scene;
|
||||
private networkSpeedIntervalId?: any;
|
||||
private networkPingIntervalId?: any;
|
||||
constructor(props: ExcalidrawProps) {
|
||||
super(props);
|
||||
const defaultAppState = getDefaultAppState();
|
||||
@@ -299,6 +307,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
offsetTop,
|
||||
excalidrawRef,
|
||||
viewModeEnabled = false,
|
||||
zenModeEnabled = false,
|
||||
gridModeEnabled = false,
|
||||
} = props;
|
||||
this.state = {
|
||||
...defaultAppState,
|
||||
@@ -307,6 +317,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
height,
|
||||
...this.getCanvasOffsets({ offsetLeft, offsetTop }),
|
||||
viewModeEnabled,
|
||||
zenModeEnabled,
|
||||
gridSize: gridModeEnabled ? GRID_SIZE : null,
|
||||
};
|
||||
if (excalidrawRef) {
|
||||
const readyPromise =
|
||||
@@ -453,12 +465,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
onExportToBackend={onExportToBackend}
|
||||
renderCustomFooter={renderFooter}
|
||||
viewModeEnabled={viewModeEnabled}
|
||||
showExitZenModeBtn={
|
||||
typeof this.props?.zenModeEnabled === "undefined" && zenModeEnabled
|
||||
}
|
||||
/>
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
{this.state.showStats && (
|
||||
<Stats
|
||||
appState={this.state}
|
||||
setAppState={this.setAppState}
|
||||
elements={this.scene.getElements()}
|
||||
onClose={this.toggleStats}
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
shouldEnableNetworkStats={shouldEnableNetworkStats}
|
||||
/>
|
||||
)}
|
||||
{this.state.toastMessage !== null && (
|
||||
@@ -509,11 +528,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}
|
||||
|
||||
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
|
||||
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
|
||||
let gridSize = actionResult?.appState?.gridSize || null;
|
||||
|
||||
if (typeof this.props.viewModeEnabled !== "undefined") {
|
||||
viewModeEnabled = this.props.viewModeEnabled;
|
||||
}
|
||||
|
||||
if (typeof this.props.zenModeEnabled !== "undefined") {
|
||||
zenModeEnabled = this.props.zenModeEnabled;
|
||||
}
|
||||
|
||||
if (typeof this.props.gridModeEnabled !== "undefined") {
|
||||
gridSize = this.props.gridModeEnabled ? GRID_SIZE : null;
|
||||
}
|
||||
|
||||
this.setState(
|
||||
(state) => ({
|
||||
...actionResult.appState,
|
||||
@@ -524,6 +553,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
offsetTop: state.offsetTop,
|
||||
offsetLeft: state.offsetLeft,
|
||||
viewModeEnabled,
|
||||
zenModeEnabled,
|
||||
gridSize,
|
||||
}),
|
||||
() => {
|
||||
if (actionResult.syncHistory) {
|
||||
@@ -726,6 +757,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.removeEventListeners();
|
||||
this.scene.destroy();
|
||||
clearTimeout(touchTimeout);
|
||||
clearTimeout(this.networkSpeedIntervalId);
|
||||
touchTimeout = 0;
|
||||
}
|
||||
|
||||
@@ -843,6 +875,43 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.addEventListeners();
|
||||
}
|
||||
|
||||
if (prevProps.zenModeEnabled !== this.props.zenModeEnabled) {
|
||||
this.setState({ zenModeEnabled: !!this.props.zenModeEnabled });
|
||||
}
|
||||
|
||||
if (prevProps.gridModeEnabled !== this.props.gridModeEnabled) {
|
||||
this.setState({
|
||||
gridSize: this.props.gridModeEnabled ? GRID_SIZE : null,
|
||||
});
|
||||
}
|
||||
if (
|
||||
shouldEnableNetworkStats &&
|
||||
(prevState.showStats !== this.state.showStats ||
|
||||
prevProps.isCollaborating !== this.props.isCollaborating)
|
||||
) {
|
||||
const navigator: Navigator & {
|
||||
connection?: {
|
||||
addEventListener: Function;
|
||||
removeEventListener: Function;
|
||||
};
|
||||
} = window.navigator;
|
||||
|
||||
if (this.state.showStats && this.props.isCollaborating) {
|
||||
this.calculateNetStats();
|
||||
|
||||
navigator?.connection?.addEventListener(
|
||||
"change",
|
||||
this.calculateNetStats,
|
||||
);
|
||||
} else {
|
||||
navigator?.connection?.removeEventListener(
|
||||
"change",
|
||||
this.calculateNetStats,
|
||||
);
|
||||
clearTimeout(this.networkSpeedIntervalId);
|
||||
}
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector(".excalidraw")
|
||||
?.classList.toggle("Appearance_dark", this.state.appearance === "dark");
|
||||
@@ -882,6 +951,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)) {
|
||||
@@ -897,6 +967,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,
|
||||
@@ -931,6 +1004,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
remotePointerButton: cursorButton,
|
||||
remoteSelectedElementIds,
|
||||
remotePointerUsernames: pointerUsernames,
|
||||
remotePointerUserStates: pointerUserStates,
|
||||
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
|
||||
},
|
||||
{
|
||||
@@ -963,6 +1037,42 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
private calculateNetStats = () => {
|
||||
this.checkNetworkPing();
|
||||
this.checkNetworkSpeed();
|
||||
};
|
||||
|
||||
private checkNetworkPing = async () => {
|
||||
if (!this.state.showStats || !this.props.isCollaborating) {
|
||||
clearTimeout(this.networkPingIntervalId);
|
||||
return;
|
||||
}
|
||||
const networkPing = await getNetworkPing();
|
||||
this.setState({ networkPing });
|
||||
if (this.networkPingIntervalId) {
|
||||
clearTimeout(this.networkPingIntervalId);
|
||||
}
|
||||
this.networkPingIntervalId = setTimeout(
|
||||
this.checkNetworkPing,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
);
|
||||
};
|
||||
|
||||
private checkNetworkSpeed = async () => {
|
||||
if (!this.state.showStats || !this.props.isCollaborating) {
|
||||
clearTimeout(this.networkSpeedIntervalId);
|
||||
return;
|
||||
}
|
||||
const networkSpeed = await getNetworkSpeed();
|
||||
this.setState({ networkSpeed });
|
||||
if (this.networkSpeedIntervalId) {
|
||||
clearTimeout(this.networkSpeedIntervalId);
|
||||
}
|
||||
this.networkSpeedIntervalId = setTimeout(
|
||||
this.checkNetworkSpeed,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
);
|
||||
};
|
||||
// Copy/paste
|
||||
|
||||
private onCut = withBatchedUpdates((event: ClipboardEvent) => {
|
||||
@@ -1823,10 +1933,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) {
|
||||
@@ -2282,8 +2393,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
),
|
||||
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 },
|
||||
@@ -3709,8 +3820,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
separator,
|
||||
actionSelectAll,
|
||||
separator,
|
||||
actionToggleGridMode,
|
||||
actionToggleZenMode,
|
||||
typeof this.props.gridModeEnabled === "undefined" &&
|
||||
actionToggleGridMode,
|
||||
typeof this.props.zenModeEnabled === "undefined" &&
|
||||
actionToggleZenMode,
|
||||
typeof this.props.viewModeEnabled === "undefined" &&
|
||||
actionToggleViewMode,
|
||||
actionToggleStats,
|
||||
|
||||
@@ -224,7 +224,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
shortcuts={[getShortcutKey("Alt+Z")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.gridMode")}
|
||||
label={t("labels.showGrid")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
||||
/>
|
||||
<Shortcut
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
}
|
||||
|
||||
.layer-ui__wrapper {
|
||||
z-index: var(--zIndex-layerUI);
|
||||
|
||||
.encrypted-icon {
|
||||
position: relative;
|
||||
margin-inline-start: 15px;
|
||||
|
||||
+20
-14
@@ -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";
|
||||
@@ -52,6 +52,7 @@ interface LayerUIProps {
|
||||
onLockToggle: () => void;
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
zenModeEnabled: boolean;
|
||||
showExitZenModeBtn: boolean;
|
||||
toggleZenMode: () => void;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
@@ -296,6 +297,7 @@ const LayerUI = ({
|
||||
onLockToggle,
|
||||
onInsertElements,
|
||||
zenModeEnabled,
|
||||
showExitZenModeBtn,
|
||||
toggleZenMode,
|
||||
isCollaborating,
|
||||
onExportToBackend,
|
||||
@@ -513,17 +515,18 @@ const LayerUI = ({
|
||||
"transition-right": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{Array.from(appState.collaborators)
|
||||
// Collaborator is either not initialized or is actually the current user.
|
||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||
.map(([clientId, client]) => (
|
||||
<Tooltip
|
||||
label={client.username || "Unknown user"}
|
||||
key={clientId}
|
||||
>
|
||||
{actionManager.renderAction("goToCollaborator", clientId)}
|
||||
</Tooltip>
|
||||
))}
|
||||
{appState.collaborators.size > 0 &&
|
||||
Array.from(appState.collaborators)
|
||||
// Collaborator is either not initialized or is actually the current user.
|
||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||
.map(([clientId, client]) => (
|
||||
<Tooltip
|
||||
label={client.username || "Unknown user"}
|
||||
key={clientId}
|
||||
>
|
||||
{actionManager.renderAction("goToCollaborator", clientId)}
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
</div>
|
||||
</FixedSideContainer>
|
||||
@@ -578,7 +581,7 @@ const LayerUI = ({
|
||||
</div>
|
||||
<button
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": zenModeEnabled,
|
||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||
})}
|
||||
onClick={toggleZenMode}
|
||||
>
|
||||
@@ -647,7 +650,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}
|
||||
|
||||
@@ -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={{
|
||||
@@ -152,24 +152,26 @@ export const MobileMenu = ({
|
||||
<Stack.Col gap={4}>
|
||||
{renderCanvasActions()}
|
||||
{renderCustomFooter?.(true)}
|
||||
<fieldset>
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
<UserList mobile>
|
||||
{Array.from(appState.collaborators)
|
||||
// Collaborator is either not initialized or is actually the current user.
|
||||
.filter(
|
||||
([_, client]) => Object.keys(client).length !== 0,
|
||||
)
|
||||
.map(([clientId, client]) => (
|
||||
<React.Fragment key={clientId}>
|
||||
{actionManager.renderAction(
|
||||
"goToCollaborator",
|
||||
clientId,
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</UserList>
|
||||
</fieldset>
|
||||
{appState.collaborators.size > 0 && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
<UserList mobile>
|
||||
{Array.from(appState.collaborators)
|
||||
// Collaborator is either not initialized or is actually the current user.
|
||||
.filter(
|
||||
([_, client]) => Object.keys(client).length !== 0,
|
||||
)
|
||||
.map(([clientId, client]) => (
|
||||
<React.Fragment key={clientId}>
|
||||
{actionManager.renderAction(
|
||||
"goToCollaborator",
|
||||
clientId,
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</UserList>
|
||||
</fieldset>
|
||||
)}
|
||||
</Stack.Col>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -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,13 @@ import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { getTargetElements } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { debounce, nFormatter } from "../utils";
|
||||
import {
|
||||
debounce,
|
||||
formatSpeedBits,
|
||||
formatTime,
|
||||
getVersion,
|
||||
nFormatter,
|
||||
} from "../utils";
|
||||
import { close } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import "./Stats.scss";
|
||||
@@ -25,8 +33,11 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
||||
|
||||
export const Stats = (props: {
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onClose: () => void;
|
||||
isCollaborating?: boolean;
|
||||
shouldEnableNetworkStats: boolean;
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
|
||||
@@ -50,6 +61,17 @@ export const Stats = (props: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const version = getVersion();
|
||||
let hash;
|
||||
let timestamp;
|
||||
|
||||
if (version !== DEFAULT_VERSION) {
|
||||
timestamp = version.slice(0, 16).replace("T", " ");
|
||||
hash = version.slice(21);
|
||||
} else {
|
||||
timestamp = t("stats.versionNotAvailable");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Stats">
|
||||
<Island padding={2}>
|
||||
@@ -156,6 +178,59 @@ export const Stats = (props: {
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{props.shouldEnableNetworkStats && props.isCollaborating ? (
|
||||
<>
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.collaboration")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.collaborators")}</td>
|
||||
<td>{props.appState.collaborators.size}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.ping")}</td>
|
||||
<td>
|
||||
{props.appState.networkPing === 0
|
||||
? "…"
|
||||
: props.appState.networkPing > 0
|
||||
? formatTime(props.appState.networkPing)
|
||||
: t("stats.error")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.speed")}</td>
|
||||
<td>
|
||||
{props.appState.networkSpeed === 0
|
||||
? "…"
|
||||
: props.appState.networkSpeed > 0
|
||||
? formatSpeedBits(props.appState.networkSpeed)
|
||||
: t("stats.error")}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
) : null}
|
||||
<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>
|
||||
|
||||
@@ -8,6 +8,8 @@ export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||
export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
|
||||
export const SHIFT_LOCKING_ANGLE = Math.PI / 12;
|
||||
export const NETWORK_TIMEOUT_MS = 4000;
|
||||
|
||||
export const CURSOR_TYPE = {
|
||||
TEXT: "text",
|
||||
CROSSHAIR: "crosshair",
|
||||
@@ -46,6 +48,7 @@ export enum EVENT {
|
||||
TOUCH_START = "touchstart",
|
||||
TOUCH_END = "touchend",
|
||||
HASHCHANGE = "hashchange",
|
||||
VISIBILITY_CHANGE = "visibilitychange",
|
||||
}
|
||||
|
||||
export const ENV = {
|
||||
@@ -93,3 +96,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;
|
||||
|
||||
@@ -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,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.App-top-bar {
|
||||
z-index: var(--zIndex-layerUI);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.App-bottom-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum UserIdleState {
|
||||
ACTIVE = "active",
|
||||
AWAY = "away",
|
||||
IDLE = "idle",
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,11 +77,15 @@ 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) ||
|
||||
// don't prompt if scene is empty
|
||||
!scene.elements.length ||
|
||||
// don't prompt for collab scenes because we don't override local storage
|
||||
roomLinkData ||
|
||||
// otherwise, prompt whether user wants to override current scene
|
||||
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
||||
) {
|
||||
// Backwards compatibility with legacy url format
|
||||
@@ -133,7 +94,7 @@ const initializeScene = async (opts: {
|
||||
} 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 +111,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 +177,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 +212,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 +283,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}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 s’admet la importació d’imatges.\n\nVolies importar una escena? Sembla que aquesta imatge no conté cap dada d’escena. 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": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Version",
|
||||
"versionCopy": "Zum Kopieren klicken",
|
||||
"versionNotAvailable": "Version nicht verfügbar",
|
||||
"width": "Breite"
|
||||
},
|
||||
"toast": {
|
||||
"copyStyles": "Formatierung kopiert.",
|
||||
"copyToClipboard": "In die Zwischenablage kopiert.",
|
||||
"copyToClipboardAsPng": "In die Zwischenablage als PNG kopiert."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -77,7 +77,7 @@
|
||||
"group": "Group selection",
|
||||
"ungroup": "Ungroup selection",
|
||||
"collaborators": "Collaborators",
|
||||
"gridMode": "Grid mode",
|
||||
"showGrid": "Show grid",
|
||||
"addToLibrary": "Add to library",
|
||||
"removeFromLibrary": "Remove from library",
|
||||
"libraryLoadingMessage": "Loading library…",
|
||||
@@ -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?",
|
||||
@@ -226,18 +227,27 @@
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Angle",
|
||||
"collaboration": "Collaboration",
|
||||
"collaborators": "Collaborators",
|
||||
"element": "Element",
|
||||
"elements": "Elements",
|
||||
"error": "Error",
|
||||
"height": "Height",
|
||||
"ping": "Ping",
|
||||
"scene": "Scene",
|
||||
"selected": "Selected",
|
||||
"speed": "Speed",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +80,9 @@
|
||||
"gridMode": "Modo cuadrícula",
|
||||
"addToLibrary": "Añadir a la biblioteca",
|
||||
"removeFromLibrary": "Eliminar de la biblioteca",
|
||||
"libraryLoadingMessage": "Cargando biblioteca...",
|
||||
"libraryLoadingMessage": "Cargando librería…",
|
||||
"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": "Versión",
|
||||
"versionCopy": "Clic para copiar",
|
||||
"versionNotAvailable": "Versión no disponible",
|
||||
"width": "Ancho"
|
||||
},
|
||||
"toast": {
|
||||
"copyStyles": "Estilos copiados.",
|
||||
"copyToClipboard": "Copiado en el portapapeles.",
|
||||
"copyToClipboardAsPng": "Copiado al portapapeles como PNG."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Versio",
|
||||
"versionCopy": "Klikkaa kopioidaksesi",
|
||||
"versionNotAvailable": "Versio ei saatavilla",
|
||||
"width": "Leveys"
|
||||
},
|
||||
"toast": {
|
||||
"copyStyles": "Tyylit kopioitu.",
|
||||
"copyToClipboard": "Kopioitu leikepöydälle.",
|
||||
"copyToClipboardAsPng": "Kopioitu leikepöydälle PNG-tiedostona."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
+25
-20
@@ -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 के रूप में कॉपी किए"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Alqem",
|
||||
"versionCopy": "Sit ad tneɣleḍ",
|
||||
"versionNotAvailable": "Ur inuḥ ulqem",
|
||||
"width": "Tehri"
|
||||
},
|
||||
"toast": {
|
||||
"copyStyles": "Iɣunab yettwaneɣlen.",
|
||||
"copyToClipboard": "",
|
||||
"copyToClipboardAsPng": "Yettwanɣel ɣer tecfawit am PNG."
|
||||
}
|
||||
}
|
||||
|
||||
+29
-24
@@ -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화면을 불러오려고 하셨나요? 이미지에 화면 정보가 없는 것 같습니다. 내보낼 때 화면을 포함했나요?",
|
||||
@@ -201,25 +202,25 @@
|
||||
"title": "오류"
|
||||
},
|
||||
"helpDialog": {
|
||||
"blog": "",
|
||||
"click": "",
|
||||
"curvedArrow": "",
|
||||
"curvedLine": "",
|
||||
"documentation": "",
|
||||
"drag": "",
|
||||
"editor": "",
|
||||
"github": "",
|
||||
"howto": "",
|
||||
"or": "",
|
||||
"preventBinding": "",
|
||||
"shapes": "",
|
||||
"shortcuts": "",
|
||||
"textFinish": "",
|
||||
"textNewLine": "",
|
||||
"title": "",
|
||||
"view": "",
|
||||
"zoomToFit": "",
|
||||
"zoomToSelection": ""
|
||||
"blog": "블로그 읽어보기",
|
||||
"click": "클릭",
|
||||
"curvedArrow": "곡선 화살표",
|
||||
"curvedLine": "곡선",
|
||||
"documentation": "설명서",
|
||||
"drag": "드래그",
|
||||
"editor": "에디터",
|
||||
"github": "문제 제보하기",
|
||||
"howto": "가이드 참고하기",
|
||||
"or": "또는",
|
||||
"preventBinding": "화살표가 붙지 않게 하기",
|
||||
"shapes": "도형",
|
||||
"shortcuts": "키보드 단축키",
|
||||
"textFinish": "편집 완료 (텍스트)",
|
||||
"textNewLine": "줄바꿈 (텍스트)",
|
||||
"title": "도움말",
|
||||
"view": "보기",
|
||||
"zoomToFit": "모든 요소가 보이도록 확대/축소",
|
||||
"zoomToSelection": "선택 영역으로 확대/축소"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "그림은 종단 간 암호화되므로 Excalidraw의 서버는 절대로 내용을 알 수 없습니다."
|
||||
@@ -234,10 +235,14 @@
|
||||
"storage": "저장공간",
|
||||
"title": "덕후들을 위한 통계",
|
||||
"total": "합계",
|
||||
"version": "버전",
|
||||
"versionCopy": "복사하려면 클릭",
|
||||
"versionNotAvailable": "해당 버전 사용 불가능",
|
||||
"width": "너비"
|
||||
},
|
||||
"toast": {
|
||||
"copyStyles": "",
|
||||
"copyToClipboardAsPng": ""
|
||||
"copyStyles": "스타일 복사.",
|
||||
"copyToClipboard": "클립보드로 복사.",
|
||||
"copyToClipboardAsPng": "클립보드로 PNG 이미지 복사."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Versie",
|
||||
"versionCopy": "Klik om te kopiëren",
|
||||
"versionNotAvailable": "Versie niet beschikbaar",
|
||||
"width": "Breedte"
|
||||
},
|
||||
"toast": {
|
||||
"copyStyles": "Stijlen gekopieerd.",
|
||||
"copyToClipboard": "Gekopieerd naar het klembord.",
|
||||
"copyToClipboardAsPng": "Gekopieerd naar klembord als PNG."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ਵਜੋਂ ਕਾਪੀ ਕੀਤਾ।"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
{
|
||||
"ar-SA": 89,
|
||||
"bg-BG": 93,
|
||||
"ca-ES": 89,
|
||||
"ar-SA": 87,
|
||||
"bg-BG": 91,
|
||||
"ca-ES": 87,
|
||||
"de-DE": 100,
|
||||
"el-GR": 100,
|
||||
"el-GR": 99,
|
||||
"en": 100,
|
||||
"es-ES": 100,
|
||||
"fa-IR": 98,
|
||||
"fa-IR": 95,
|
||||
"fi-FI": 100,
|
||||
"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": 96,
|
||||
"ko-KR": 100,
|
||||
"my-MM": 81,
|
||||
"nb-NO": 100,
|
||||
"nl-NL": 100,
|
||||
"nn-NO": 92,
|
||||
"pa-IN": 99,
|
||||
"pl-PL": 90,
|
||||
"nl-NL": 99,
|
||||
"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": 98,
|
||||
"sk-SK": 100,
|
||||
"sv-SE": 100,
|
||||
"tr-TR": 89,
|
||||
"uk-UA": 99,
|
||||
"zh-CN": 99,
|
||||
"tr-TR": 87,
|
||||
"uk-UA": 97,
|
||||
"zh-CN": 98,
|
||||
"zh-TW": 100
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Version",
|
||||
"versionCopy": "Klicka för att kopiera",
|
||||
"versionNotAvailable": "Versionen är inte tillgänglig",
|
||||
"width": "Bredd"
|
||||
},
|
||||
"toast": {
|
||||
"copyStyles": "Kopierade stilar.",
|
||||
"copyToClipboard": "Kopierad till urklipp.",
|
||||
"copyToClipboardAsPng": "Kopierat till urklipp som PNG."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 到剪贴板"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 至剪貼簿"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { getAverage } from "./utils";
|
||||
|
||||
const IMAGE_URL = `${process.env.REACT_APP_SOCKET_SERVER_URL}/test256.png`;
|
||||
const IMAGE_SIZE_BITS = 141978 * 8;
|
||||
const AVERAGE_MAX = 4;
|
||||
|
||||
const speedHistory: number[] = [];
|
||||
|
||||
const pushSpeed = (speed: number): void => {
|
||||
speedHistory.push(speed);
|
||||
if (speedHistory.length > AVERAGE_MAX) {
|
||||
speedHistory.shift();
|
||||
}
|
||||
};
|
||||
|
||||
const getSpeedBits = (
|
||||
imageSize: number,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): number => {
|
||||
const duration = (endTime - startTime) / 1000;
|
||||
if (duration > 0) {
|
||||
return imageSize / duration;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const processImage = (): Promise<number> => {
|
||||
return new Promise((resolve) => {
|
||||
const image = new Image();
|
||||
let endTime: number;
|
||||
image.onload = () => {
|
||||
endTime = new Date().getTime();
|
||||
const speed = getSpeedBits(IMAGE_SIZE_BITS, startTime, endTime);
|
||||
pushSpeed(speed);
|
||||
resolve(getAverage(speedHistory));
|
||||
};
|
||||
|
||||
image.onerror = () => {
|
||||
resolve(-1);
|
||||
};
|
||||
|
||||
const startTime = new Date().getTime();
|
||||
image.src = `${IMAGE_URL}?t=${startTime}`;
|
||||
});
|
||||
};
|
||||
|
||||
export const getNetworkSpeed = async (): Promise<number> => {
|
||||
return await processImage();
|
||||
};
|
||||
|
||||
export const getNetworkPing = async () => {
|
||||
const startTime = new Date().getTime();
|
||||
try {
|
||||
await fetch(process.env.REACT_APP_SOCKET_SERVER_URL, {
|
||||
mode: "no-cors",
|
||||
method: "HEAD",
|
||||
});
|
||||
const endTime = new Date().getTime();
|
||||
return endTime - startTime;
|
||||
} catch (error) {
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
@@ -18,6 +18,7 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
### Features
|
||||
|
||||
- Add `zenModeEnabled` and `gridModeEnabled` prop which enables zen mode and grid mode respectively [#2901](https://github.com/excalidraw/excalidraw/pull/2901). When this prop is used, the zen mode / grid mode will be fully controlled by the host app.
|
||||
- Add `viewModeEnabled` prop which enabled the view mode [#2840](https://github.com/excalidraw/excalidraw/pull/2840). When this prop is used, the view mode will not show up in context menu is so it is fully controlled by host.
|
||||
- Expose `getAppState` on `excalidrawRef` [#2834](https://github.com/excalidraw/excalidraw/pull/2834).
|
||||
|
||||
|
||||
@@ -138,7 +138,9 @@ export default function App() {
|
||||
| [`onExportToBackend`](#onExportToBackend) | Function | | Callback triggered when link button is clicked on export dialog |
|
||||
| [`langCode`](#langCode) | string | `en` | Language code string |
|
||||
| [`renderFooter `](#renderFooter) | Function | | Function that renders custom UI footer |
|
||||
| [`viewModeEnabled`](#viewModeEnabled) | boolean | false | This implies if the app is in view mode. |
|
||||
| [`viewModeEnabled`](#viewModeEnabled) | boolean | | This implies if the app is in view mode. |
|
||||
| [`zenModeEnabled`](#zenModeEnabled) | boolean | | This implies if the zen mode is enabled |
|
||||
| [`gridModeEnabled`](#gridModeEnabled) | boolean | | This implies if the grid mode is enabled |
|
||||
|
||||
### `Extra API's`
|
||||
|
||||
@@ -334,4 +336,12 @@ A function that renders (returns JSX) custom UI footer. For example, you can use
|
||||
|
||||
#### `viewModeEnabled`
|
||||
|
||||
This prop indicates if the app is in `view mode`. When this prop is used, the `view mode` will not show up in context menu is so it is fully controlled by host. Also the value of this prop if passed will be used over the value of `intialData.appState.viewModeEnabled`
|
||||
This prop indicates whether the app is in `view mode`. When supplied, the value takes precedence over `intialData.appState.viewModeEnabled`, the `view mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app.
|
||||
|
||||
#### `zenModeEnabled`
|
||||
|
||||
This prop indicates whether the app is in `zen mode`. When supplied, the value takes precedence over `intialData.appState.zenModeEnabled`, the `zen mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app.
|
||||
|
||||
#### `gridModeEnabled`
|
||||
|
||||
This prop indicates whether the shows the grid. When supplied, the value takes precedence over `intialData.appState.gridModeEnabled`, the grid will be fully controlled by the host app, and users won't be able to toggle it from within the app.
|
||||
|
||||
@@ -27,6 +27,8 @@ const Excalidraw = (props: ExcalidrawProps) => {
|
||||
renderFooter,
|
||||
langCode = defaultLang.code,
|
||||
viewModeEnabled,
|
||||
zenModeEnabled,
|
||||
gridModeEnabled,
|
||||
} = props;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,6 +68,8 @@ const Excalidraw = (props: ExcalidrawProps) => {
|
||||
renderFooter={renderFooter}
|
||||
langCode={langCode}
|
||||
viewModeEnabled={viewModeEnabled}
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
gridModeEnabled={gridModeEnabled}
|
||||
/>
|
||||
</IsMobileProvider>
|
||||
</InitializeApp>
|
||||
|
||||
@@ -36,7 +36,7 @@ export const exportToCanvas = ({
|
||||
canvas.width = ret.width;
|
||||
canvas.height = ret.height;
|
||||
|
||||
return canvas;
|
||||
return { canvas, scale: ret.scale };
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,7 +47,10 @@ import {
|
||||
TransformHandles,
|
||||
TransformHandleType,
|
||||
} from "../element/transformHandles";
|
||||
import { viewportCoordsToSceneCoords } from "../utils";
|
||||
import { viewportCoordsToSceneCoords, supportsEmoji } from "../utils";
|
||||
import { UserIdleState } from "../excalidraw-app/collab/types";
|
||||
|
||||
const hasEmojiSupport = supportsEmoji();
|
||||
|
||||
const strokeRectWithRotation = (
|
||||
context: CanvasRenderingContext2D,
|
||||
@@ -445,8 +448,10 @@ export const renderScene = (
|
||||
const globalAlpha = context.globalAlpha;
|
||||
context.strokeStyle = stroke;
|
||||
context.fillStyle = background;
|
||||
if (isOutOfBounds) {
|
||||
context.globalAlpha = 0.2;
|
||||
|
||||
const userState = sceneState.remotePointerUserStates[clientId];
|
||||
if (isOutOfBounds || userState === UserIdleState.AWAY) {
|
||||
context.globalAlpha = 0.48;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -478,19 +483,36 @@ export const renderScene = (
|
||||
context.stroke();
|
||||
|
||||
const username = sceneState.remotePointerUsernames[clientId];
|
||||
let usernameAndIdleState;
|
||||
if (hasEmojiSupport) {
|
||||
usernameAndIdleState = `${username ? `${username} ` : ""}${
|
||||
userState === UserIdleState.AWAY
|
||||
? "⚫️"
|
||||
: userState === UserIdleState.IDLE
|
||||
? "💤"
|
||||
: "🟢"
|
||||
}`;
|
||||
} else {
|
||||
usernameAndIdleState = `${username ? `${username}` : ""}${
|
||||
userState === UserIdleState.AWAY
|
||||
? ` (${UserIdleState.AWAY})`
|
||||
: userState === UserIdleState.IDLE
|
||||
? ` (${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 +528,9 @@ export const renderScene = (
|
||||
measureHeight + 2 * paddingVertical,
|
||||
);
|
||||
context.fillStyle = oc.white;
|
||||
|
||||
context.fillText(
|
||||
username,
|
||||
usernameAndIdleState,
|
||||
offsetX + paddingHorizontal,
|
||||
offsetY + paddingVertical + measure.actualBoundingBoxAscent,
|
||||
);
|
||||
|
||||
+11
-7
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -40,6 +40,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -500,6 +502,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -966,6 +970,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -1741,6 +1747,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -1944,6 +1952,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -2401,6 +2411,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -2653,6 +2665,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -2816,6 +2830,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -3292,6 +3308,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -3599,6 +3617,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -3802,6 +3822,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -4045,6 +4067,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -4296,6 +4320,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -4678,6 +4704,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -4972,6 +5000,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -5278,6 +5308,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -5485,6 +5517,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -5648,6 +5682,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -6100,6 +6136,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -6417,6 +6455,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -8450,6 +8490,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -8811,6 +8853,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -9065,6 +9109,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -9317,6 +9363,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -9631,6 +9679,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -9794,6 +9844,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -9957,6 +10009,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -10120,6 +10174,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -10313,6 +10369,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -10506,6 +10564,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -10699,6 +10759,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -10892,6 +10954,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -11055,6 +11119,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -11218,6 +11284,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -11411,6 +11479,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -11574,6 +11644,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -11767,6 +11839,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -12482,6 +12556,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -12734,6 +12810,8 @@ Object {
|
||||
"lastPointerDownWith": "touch",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -12835,6 +12913,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -12934,6 +13014,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -13097,6 +13179,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -13404,6 +13488,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -13711,6 +13797,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -13874,6 +13962,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -14069,6 +14159,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -14317,6 +14409,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -14640,6 +14734,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -15478,6 +15574,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -15785,6 +15883,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -16092,6 +16192,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -16470,6 +16572,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -16636,6 +16740,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -16956,6 +17062,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -17194,6 +17302,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -17448,6 +17558,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -17774,6 +17886,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -17873,6 +17987,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -18036,6 +18152,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -18856,6 +18974,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -18955,6 +19075,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -19708,6 +19830,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -20112,6 +20236,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -20384,6 +20510,8 @@ Object {
|
||||
"lastPointerDownWith": "touch",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -20485,6 +20613,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -20982,6 +21112,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
@@ -21081,6 +21213,8 @@ Object {
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"networkPing": 0,
|
||||
"networkSpeed": 0,
|
||||
"offsetLeft": 0,
|
||||
"offsetTop": 0,
|
||||
"openMenu": null,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { fireEvent, GlobalTestState, render } from "./test-utils";
|
||||
import Excalidraw from "../packages/excalidraw/index";
|
||||
import { queryByText } from "@testing-library/react";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("<Excalidraw/>", () => {
|
||||
describe("Test zenModeEnabled prop", () => {
|
||||
it('should show exit zen mode button when zen mode is set and zen mode option in context menu when zenModeEnabled is "undefined"', async () => {
|
||||
const { container } = await render(<Excalidraw />);
|
||||
expect(
|
||||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(0);
|
||||
expect(h.state.zenModeEnabled).toBe(false);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Zen mode")!);
|
||||
expect(h.state.zenModeEnabled).toBe(true);
|
||||
expect(
|
||||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("should not show exit zen mode button and zen mode option in context menu when zenModeEnabled is set", async () => {
|
||||
const { container } = await render(<Excalidraw zenModeEnabled={true} />);
|
||||
expect(
|
||||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(0);
|
||||
expect(h.state.zenModeEnabled).toBe(true);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
expect(queryByText(contextMenu as HTMLElement, "Zen mode")).toBe(null);
|
||||
expect(h.state.zenModeEnabled).toBe(true);
|
||||
expect(
|
||||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test gridModeEnabled prop", () => {
|
||||
it('should show grid mode in context menu when gridModeEnabled is "undefined"', async () => {
|
||||
const { container } = await render(<Excalidraw />);
|
||||
expect(h.state.gridSize).toBe(null);
|
||||
|
||||
expect(
|
||||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(0);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Show grid")!);
|
||||
expect(h.state.gridSize).toBe(GRID_SIZE);
|
||||
});
|
||||
|
||||
it('should not show grid mode in context menu when gridModeEnabled is not "undefined"', async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw gridModeEnabled={false} />,
|
||||
);
|
||||
expect(h.state.gridSize).toBe(null);
|
||||
|
||||
expect(
|
||||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(0);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
expect(queryByText(contextMenu as HTMLElement, "Show grid")).toBe(null);
|
||||
expect(h.state.gridSize).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
@@ -86,6 +88,8 @@ export type AppState = {
|
||||
appearance: "light" | "dark";
|
||||
gridSize: number | null;
|
||||
viewModeEnabled: boolean;
|
||||
networkSpeed: number;
|
||||
networkPing: number;
|
||||
|
||||
/** top-most selected groups (i.e. does not include nested groups) */
|
||||
selectedGroupIds: { [groupId: string]: boolean };
|
||||
@@ -183,6 +187,8 @@ export interface ExcalidrawProps {
|
||||
renderFooter?: (isMobile: boolean) => JSX.Element;
|
||||
langCode?: Language["code"];
|
||||
viewModeEnabled?: boolean;
|
||||
zenModeEnabled?: boolean;
|
||||
gridModeEnabled?: boolean;
|
||||
}
|
||||
|
||||
export type SceneData = {
|
||||
|
||||
@@ -363,9 +363,47 @@ export const nFormatter = (num: number, digits: number): string => {
|
||||
);
|
||||
};
|
||||
|
||||
export const formatSpeedBits = (speed: number): string => {
|
||||
// source: https://en.wikipedia.org/wiki/Data-rate_units#Conversion_table
|
||||
const suffix = ["bps", "kbps", "Mbps", "Gbps"];
|
||||
let index = 0;
|
||||
while (speed > 1000) {
|
||||
index++;
|
||||
speed = speed / 1000;
|
||||
}
|
||||
return `${speed.toFixed(index > 1 ? 1 : 0)} ${suffix[index]}`;
|
||||
};
|
||||
|
||||
export const getVersion = () => {
|
||||
return (
|
||||
document.querySelector<HTMLMetaElement>('meta[name="version"]')?.content ||
|
||||
DEFAULT_VERSION
|
||||
);
|
||||
};
|
||||
|
||||
export const formatTime = (mseconds: number): string => {
|
||||
return mseconds < 1000
|
||||
? `${mseconds} ms`
|
||||
: `${(mseconds / 1000).toFixed(1)} s`;
|
||||
};
|
||||
|
||||
export const getAverage = (arr: Array<number>): number => {
|
||||
return arr.reduce((sum, currentVal) => sum + currentVal) / arr.length;
|
||||
};
|
||||
|
||||
// Adapted from https://github.com/Modernizr/Modernizr/blob/master/feature-detects/emoji.js
|
||||
export const supportsEmoji = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
return false;
|
||||
}
|
||||
const offset = 12;
|
||||
ctx.fillStyle = "#f00";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.font = "32px Arial";
|
||||
// Modernizr used 🐨, but it is sort of supported on Windows 7.
|
||||
// Luckily 😀 isn't supported.
|
||||
ctx.fillText("😀", 0, 0);
|
||||
return ctx.getImageData(offset, offset, 1, 1).data[0] !== 0;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user