Compare commits

...

33 Commits

Author SHA1 Message Date
Arnošt Pleskot bef0f3e51d feat: add new socket message when user joins room 2023-07-15 14:27:39 +02:00
Arnošt Pleskot 28b0095c8a fix: making linter happy 2023-07-14 20:49:25 +02:00
Arnošt Pleskot c4ff0594e3 fix: get socketId from emitted message 2023-07-13 10:24:36 +02:00
Arnošt Pleskot 4608e809b1 feat: filter disconnected users 2023-07-13 10:07:31 +02:00
Arnošt Pleskot db5149ab5d feat: store userId in localStorage 2023-07-12 17:22:46 +02:00
Arnošt Pleskot 2bdf09153c feat: use userId instead of socketId 2023-07-12 16:56:21 +02:00
Arnošt Pleskot 62df03d78d feat: submit scene to firebase on collab pause 2023-07-11 16:55:21 +02:00
Arnošt Pleskot a1d3350131 fix: connect users when resumed from firebase 2023-07-11 16:55:20 +02:00
Arnošt Pleskot a2d371bf1d feat: when no users in room fetch data from firebase immediately 2023-07-11 16:55:19 +02:00
Arnošt Pleskot e340103250 feat: firebase fallback for resume collaboration 2023-07-11 16:55:18 +02:00
Arnošt Pleskot 0567af1bcb fix: properly sync after reconnecting 2023-07-11 16:55:17 +02:00
Arnošt Pleskot 2ffeff442a feat: disable viewMode on update and init message 2023-07-11 16:55:16 +02:00
Arnošt Pleskot ef190ebf30 feat: add optional spinner into toast message 2023-07-11 16:55:15 +02:00
Arnošt Pleskot e1ff9791f2 feat: move logic from index.tsx into Collab.tsx 2023-07-11 16:55:14 +02:00
Arnošt Pleskot aa91af8f7d fix: emit scene init event after reconnecting 2023-07-11 16:55:13 +02:00
Arnošt Pleskot 52254bca7c fix: do not fire pause logic on window focus/blur event 2023-07-11 16:55:12 +02:00
Arnošt Pleskot addf9d71fa feat: pause collab when user switches tabs in the browser 2023-07-11 16:55:11 +02:00
Arnošt Pleskot 1badf14a93 chore: update socket.io-client 2023-07-11 16:55:00 +02:00
David Luzar cf0413338e feat: support customizing what parts of frames are rendered (#6752) 2023-07-10 17:13:44 +02:00
David Luzar 49e4289878 feat: make appState.selectedElementIds more stable (#6745) 2023-07-08 23:33:34 +02:00
zsviczian 3ddcc48e4c fix: UI disappears when pressing the eyedropper shortcut on mobile (#6725) 2023-06-29 12:39:44 +02:00
Barnabás Molnár 29a5e982c3 feat: support scrollToContent opts.fitToViewport (#6581)
Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Arnošt Pleskot <arnostpleskot@gmail.com>
2023-06-29 12:36:38 +02:00
Christopher Chedeau b33fa6d6f6 fix: stronger enforcement of normalizeLink (#6728)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-06-29 12:14:42 +02:00
Ryan Di b7350f9707 fix: elements in non-existing frame getting removed (#6708)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-06-22 22:10:08 +00:00
David Luzar 8dfa2a98bb fix: scrollbars renders but disable (#6706) 2023-06-22 14:35:01 +02:00
Wu Kai fb01ce2a00 fix: typo in chart.ts (#6696)
fix: typo
2023-06-21 11:43:37 +05:30
Excalidraw Bot 3d57112480 chore: Update translations from Crowdin (#6677) 2023-06-19 17:10:09 +02:00
Are 7558a4e2be feat: overwrite confirmation dialogs (#6658)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-06-19 17:08:12 +02:00
Aakansha Doshi 6d56634289 fix: do not bind text to container using text tool when it has text already (#6694)
* fix: do not bind text to container using text tool when it has text already

* Update src/element/textWysiwyg.test.tsx
2023-06-19 17:28:45 +05:30
Milos Vetesnik 0aa1e66486 feat: simple analitycs (#6683)
* Simple analytics for iframe and webpage

* added logic for tracking specific categories of events to reduce it

* enviroment vars clean up

* fix: lint for index.html
2023-06-19 11:18:28 +02:00
Aakansha Doshi 7f7128ec09 fix: don't allow binding text to images (#6693) 2023-06-19 13:47:28 +05:30
Aakansha Doshi 6de6a96abf docs: add info about roadmap (#6687) 2023-06-16 20:55:33 +05:30
Sudharsan Aravind 28ab6531c9 fix: updated link for documentation page under help section (#6654)
* fix: updated link for documentation page under help section

* Update docs link

---------

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-06-15 14:58:11 +05:30
133 changed files with 3260 additions and 1078 deletions
+1 -5
View File
@@ -20,14 +20,10 @@ REACT_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
REACT_APP_DISABLE_TRACKING=true
FAST_REFRESH=false
# MATOMO
REACT_APP_MATOMO_URL=
REACT_APP_CDN_MATOMO_TRACKER_URL=
REACT_APP_MATOMO_SITE_ID=
#Debug flags
# To enable bounding box for text containers
+1 -10
View File
@@ -11,14 +11,5 @@ REACT_APP_WS_SERVER_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
# production-only vars
# GOOGLE ANALYTICS
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
# MATOMO
REACT_APP_MATOMO_URL=https://excalidraw.matomo.cloud/
REACT_APP_CDN_MATOMO_TRACKER_URL=//cdn.matomo.cloud/excalidraw.matomo.cloud/matomo.js
REACT_APP_MATOMO_SITE_ID=1
REACT_APP_PLUS_APP=https://app.excalidraw.com
REACT_APP_DISABLE_TRACKING=
@@ -306,30 +306,32 @@ This is the history API. history.clear() will clear the history.
## scrollToContent
<pre>
(<br />
{" "}
target?:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>{" "}
&#124;{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>
[],
<br />
{" "}opts?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number
&#125;
<br />) => void
</pre>
```tsx
(
target?: ExcalidrawElement | ExcalidrawElement[],
opts?:
| {
fitToContent?: boolean;
animate?: boolean;
duration?: number;
}
| {
fitToViewport?: boolean;
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
}
) => void
```
Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene.
| Attribute | type | default | Description |
| --- | --- | --- | --- |
| target | <code>ExcalidrawElement &#124; ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. |
| target | [ExcalidrawElement](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) &#124; [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. Note that the zoom range is between 10%-100%. |
| opts.fitToViewport | boolean | false | Similar to fitToContent but the zoom range is not limited. If elements are smaller than the viewport, zoom will go above 100%. |
| opts.viewportZoomFactor | number | 0.7 | when fitToViewport=true, how much screen should the content cover, between 0.1 (10%) and 1 (100%) |
| opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. |
| opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |
@@ -2,6 +2,11 @@
Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change.
We have a [roadmap](https://github.com/orgs/excalidraw/projects/3) which we strongly recommend to go through and check if something interests you.
For new contributors we would recommend to start with *Easy* tasks.
In case you want to pick up something from the roadmap, comment on that issue and one of the project maintainers will assign it to you, post which you can discuss in the issue and start working on it.
## Setup
### Option 1 - Manual
+2 -1
View File
@@ -19,6 +19,7 @@
]
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/random-username": "1.0.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2",
@@ -53,7 +54,7 @@
"react-scripts": "5.0.1",
"roughjs": "4.5.2",
"sass": "1.51.0",
"socket.io-client": "2.3.1",
"socket.io-client": "4.6.1",
"tunnel-rat": "0.1.2",
"workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4",
+33 -38
View File
@@ -148,33 +148,6 @@
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
<!-- Fathom - privacy-friendly analytics -->
<script
src="https://cdn.usefathom.com/script.js"
data-site="VMSBUEYA"
defer
></script>
<!-- / Fathom -->
<!-- LEGACY GOOGLE ANALYTICS -->
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
</script>
<% } %>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
<style>
@@ -227,17 +200,39 @@
<h1 class="visually-hidden">Excalidraw</h1>
</header>
<div id="root"></div>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
<!-- 100% privacy friendly analytics -->
<script
async
defer
src="https://scripts.simpleanalyticscdn.com/latest.js"
></script>
<noscript
><img
src="https://queue.simpleanalyticscdn.com/noscript.gif"
alt=""
referrerpolicy="no-referrer-when-downgrade"
/></noscript>
<script>
// need to load this script dynamically bcs. of iframe embed tracking
var scriptEle = document.createElement("script");
scriptEle.setAttribute(
"src",
"https://scripts.simpleanalyticscdn.com/latest.js",
);
scriptEle.setAttribute("type", "text/javascript");
scriptEle.setAttribute("defer", true);
scriptEle.setAttribute("async", true);
// if iframe
if (window.self !== window.top) {
scriptEle.setAttribute("data-auto-collect", true);
}
document.body.appendChild(scriptEle);
// if iframe
if (window.self !== window.top) {
scriptEle.addEventListener("load", () => {
if (window.sa_pageview) {
window.window.sa_event(action, {
category: "iframe",
label: "embed",
value: window.location.pathname,
});
}
});
}
</script>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body>
</html>
+2 -1
View File
@@ -31,6 +31,7 @@ import {
} from "../element/types";
import { getSelectedElements } from "../scene";
import { AppState } from "../types";
import { Mutable } from "../utility-types";
import { getFontString } from "../utils";
import { register } from "./register";
@@ -211,7 +212,7 @@ export const actionWrapTextInContainer = register({
appState,
);
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
const containerIds: AppState["selectedElementIds"] = {};
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
for (const textElement of selectedElements) {
if (isTextElement(textElement)) {
+101 -35
View File
@@ -20,7 +20,6 @@ import {
isHandToolActive,
} from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { Bounds } from "../element/bounds";
export const actionChangeViewBackgroundColor = register({
@@ -226,52 +225,96 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue;
};
export const zoomToFitElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
zoomToSelection: boolean,
) => {
const nonDeletedElements = getNonDeletedElements(elements);
const selectedElements = getSelectedElements(nonDeletedElements, appState);
const commonBounds =
zoomToSelection && selectedElements.length > 0
? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements))
: getCommonBounds(
excludeElementsInFramesFromSelection(nonDeletedElements),
);
const newZoom = {
value: zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
}),
};
export const zoomToFit = ({
targetElements,
appState,
fitToViewport = false,
viewportZoomFactor = 0.7,
}: {
targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number;
}) => {
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
let newZoomValue;
let scrollX;
let scrollY;
if (fitToViewport) {
const commonBoundsWidth = x2 - x1;
const commonBoundsHeight = y2 - y1;
newZoomValue =
Math.min(
appState.width / commonBoundsWidth,
appState.height / commonBoundsHeight,
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
// Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min(
Math.max(newZoomValue, 0.1),
30.0,
) as NormalizedZoomValue;
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else {
newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
});
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: { value: newZoomValue },
});
scrollX = centerScroll.scrollX;
scrollY = centerScroll.scrollY;
}
return {
appState: {
...appState,
...centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: newZoom,
}),
zoom: newZoom,
scrollX,
scrollY,
zoom: { value: newZoomValue },
},
commitToHistory: false,
};
};
export const actionZoomToSelected = register({
name: "zoomToSelection",
// Note, this action differs from actionZoomToFitSelection in that it doesn't
// zoom beyond 100%. In other words, if the content is smaller than viewport
// size, it won't be zoomed in.
export const actionZoomToFitSelectionInViewport = register({
name: "zoomToFitSelectionInViewport",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return zoomToFit({
targetElements: selectedElements.length ? selectedElements : elements,
appState,
fitToViewport: false,
});
},
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
// TBD on how proceed
keyTest: (event) =>
event.code === CODES.TWO &&
event.shiftKey &&
@@ -279,11 +322,34 @@ export const actionZoomToSelected = register({
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFitSelection = register({
name: "zoomToFitSelection",
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return zoomToFit({
targetElements: selectedElements.length ? selectedElements : elements,
appState,
fitToViewport: true,
});
},
// NOTE this action should use shift-2 per figma, alas
keyTest: (event) =>
event.code === CODES.THREE &&
event.shiftKey &&
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFit = register({
name: "zoomToFit",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
perform: (elements, appState) =>
zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
keyTest: (event) =>
event.code === CODES.ONE &&
event.shiftKey &&
+1
View File
@@ -274,6 +274,7 @@ const duplicateElements = (
),
},
getNonDeletedElements(finalElements),
appState,
),
};
};
-7
View File
@@ -125,13 +125,6 @@ export const actionFinalize = register({
{ x, y },
);
}
if (
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
) {
appState.selectedElementIds[multiPointElement.id] = true;
}
}
if (
+8 -5
View File
@@ -90,8 +90,8 @@ export const actionRemoveAllElementsFromFrame = register({
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
});
export const actionToggleFrameRendering = register({
name: "toggleFrameRendering",
export const actionupdateFrameRendering = register({
name: "updateFrameRendering",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
@@ -99,13 +99,16 @@ export const actionToggleFrameRendering = register({
elements,
appState: {
...appState,
shouldRenderFrames: !appState.shouldRenderFrames,
frameRendering: {
...appState.frameRendering,
enabled: !appState.frameRendering.enabled,
},
},
commitToHistory: false,
};
},
contextItemLabel: "labels.toggleFrameRendering",
checked: (appState: AppState) => appState.shouldRenderFrames,
contextItemLabel: "labels.updateFrameRendering",
checked: (appState: AppState) => appState.frameRendering.enabled,
});
export const actionSetFrameAsActiveTool = register({
+12 -2
View File
@@ -218,6 +218,7 @@ export const actionUngroup = register({
const updateAppState = selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
appState,
);
frames.forEach((frame) => {
@@ -232,9 +233,18 @@ export const actionUngroup = register({
});
// remove binded text elements from selection
boundTextElementIds.forEach(
(id) => (updateAppState.selectedElementIds[id] = false),
updateAppState.selectedElementIds = Object.entries(
updateAppState.selectedElementIds,
).reduce(
(acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => {
if (selected && !boundTextElementIds.includes(id)) {
acc[id] = true;
}
return acc;
},
{},
);
return {
appState: updateAppState,
elements: nextElements,
+1
View File
@@ -41,6 +41,7 @@ export const actionSelectAll = register({
selectedElementIds,
},
getNonDeletedElements(elements),
appState,
),
commitToHistory: true,
};
+3 -2
View File
@@ -82,7 +82,8 @@ export type ActionName =
| "zoomOut"
| "resetZoom"
| "zoomToFit"
| "zoomToSelection"
| "zoomToFitSelection"
| "zoomToFitSelectionInViewport"
| "changeFontFamily"
| "changeTextAlign"
| "changeVerticalAlign"
@@ -118,7 +119,7 @@ export type ActionName =
| "toggleHandTool"
| "selectAllElementsInFrame"
| "removeAllElementsFromFrame"
| "toggleFrameRendering"
| "updateFrameRendering"
| "setFrameAsActiveTool"
| "createContainerFromText"
| "wrapTextInContainer";
+5 -14
View File
@@ -5,6 +5,9 @@ export const trackEvent = (
value?: number,
) => {
try {
// place here categories that you want to track as events
// KEEP IN MIND THE PRICING
const ALLOWED_CATEGORIES_TO_TRACK = [] as string[];
// Uncomment the next line to track locally
// console.log("Track Event", { category, action, label, value });
@@ -12,12 +15,8 @@ export const trackEvent = (
return;
}
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) {
window.gtag("event", action, {
event_category: category,
event_label: label,
value,
});
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
return;
}
if (window.sa_event) {
@@ -27,14 +26,6 @@ export const trackEvent = (
value,
});
}
if (window.fathom) {
window.fathom.trackEvent(action, {
category,
label,
value,
});
}
} catch (error) {
console.error("error during analytics", error);
}
+2 -2
View File
@@ -84,7 +84,7 @@ export const getDefaultAppState = (): Omit<
showStats: false,
startBoundElement: null,
suggestedBindings: [],
shouldRenderFrames: true,
frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null,
editingFrame: null,
elementsToHighlight: null,
@@ -191,7 +191,7 @@ const APP_STATE_STORAGE_CONF = (<
showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
shouldRenderFrames: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false },
editingFrame: { browser: false, export: false, server: false },
elementsToHighlight: { browser: false, export: false, server: false },
+3 -3
View File
@@ -180,7 +180,7 @@ const commonProps = {
locked: false,
} as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => {
const getChartDimensions = (spreadsheet: Spreadsheet) => {
const chartWidth =
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
@@ -250,7 +250,7 @@ const chartLines = (
groupId: string,
backgroundColor: string,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const xLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
@@ -313,7 +313,7 @@ const chartBaseElements = (
backgroundColor: string,
debug?: boolean,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const title = spreadsheet.title
? newTextElement({
+310 -163
View File
@@ -245,6 +245,7 @@ import {
isTransparent,
easeToValuesRAF,
muteFSAbortError,
easeOut,
} from "../utils";
import {
ContextMenu,
@@ -291,13 +292,12 @@ import {
} from "../element/textElement";
import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
import {
normalizeLink,
showHyperlinkTooltip,
hideHyperlinkToolip,
Hyperlink,
isPointHittingLinkIcon,
isLocalLink,
} from "../element/Hyperlink";
import { isLocalLink, normalizeLink } from "../data/url";
import { shouldShowBoundingBox } from "../element/transformHandles";
import { actionUnlockAllElements } from "../actions/actionElementLock";
import { Fonts } from "../scene/Fonts";
@@ -315,16 +315,16 @@ import {
updateFrameMembershipOfSelectedElements,
isElementInFrame,
} from "../frame";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import {
excludeElementsInFramesFromSelection,
makeNextSelectedElementIds,
} from "../scene/selection";
import { actionPaste } from "../actions/actionClipboard";
import {
actionRemoveAllElementsFromFrame,
actionSelectAllElementsInFrame,
} from "../actions/actionFrame";
import {
actionToggleHandTool,
zoomToFitElements,
} from "../actions/actionCanvas";
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
import { jotaiStore } from "../jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
@@ -505,7 +505,7 @@ class App extends React.Component<AppProps, AppState> {
setActiveTool: this.setActiveTool,
setCursor: this.setCursor,
resetCursor: this.resetCursor,
toggleFrameRendering: this.toggleFrameRendering,
updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar,
} as const;
if (typeof excalidrawRef === "function") {
@@ -651,7 +651,7 @@ class App extends React.Component<AppProps, AppState> {
};
private renderFrameNames = () => {
if (!this.state.shouldRenderFrames) {
if (!this.state.frameRendering.enabled || !this.state.frameRendering.name) {
return null;
}
@@ -1356,6 +1356,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.destroy();
this.library.destroy();
clearTimeout(touchTimeout);
isSomeElementSelected.clearCache();
touchTimeout = 0;
}
@@ -1720,7 +1721,7 @@ class App extends React.Component<AppProps, AppState> {
theme: this.state.theme,
imageCache: this.imageCache,
isExporting: false,
renderScrollbars: !this.device.isMobile,
renderScrollbars: false,
},
callback: ({ atLeastOneVisibleElement, scrollBars }) => {
if (scrollBars) {
@@ -1828,7 +1829,7 @@ class App extends React.Component<AppProps, AppState> {
if (event.touches.length === 2) {
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
});
}
};
@@ -1838,7 +1839,10 @@ class App extends React.Component<AppProps, AppState> {
if (event.touches.length > 0) {
this.setState({
previousSelectedElementIds: {},
selectedElementIds: this.state.previousSelectedElementIds,
selectedElementIds: makeNextSelectedElementIds(
this.state.previousSelectedElementIds,
this.state,
),
});
} else {
gesture.pointers.clear();
@@ -1898,7 +1902,14 @@ class App extends React.Component<AppProps, AppState> {
const imageElement = this.createImageElement({ sceneX, sceneY });
this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement);
this.setState({ selectedElementIds: { [imageElement.id]: true } });
this.setState({
selectedElementIds: makeNextSelectedElementIds(
{
[imageElement.id]: true,
},
this.state,
),
});
return;
}
@@ -2035,6 +2046,7 @@ class App extends React.Component<AppProps, AppState> {
selectedGroupIds: {},
},
this.scene.getNonDeletedElements(),
this.state,
),
() => {
if (opts.files) {
@@ -2133,8 +2145,9 @@ class App extends React.Component<AppProps, AppState> {
}
this.setState({
selectedElementIds: Object.fromEntries(
textElements.map((el) => [el.id, true]),
selectedElementIds: makeNextSelectedElementIds(
Object.fromEntries(textElements.map((el) => [el.id, true])),
this.state,
),
});
@@ -2195,10 +2208,23 @@ class App extends React.Component<AppProps, AppState> {
});
};
toggleFrameRendering = () => {
updateFrameRendering = (
opts:
| Partial<AppState["frameRendering"]>
| ((
prevState: AppState["frameRendering"],
) => Partial<AppState["frameRendering"]>),
) => {
this.setState((prevState) => {
const next =
typeof opts === "function" ? opts(prevState.frameRendering) : opts;
return {
shouldRenderFrames: !prevState.shouldRenderFrames,
frameRendering: {
enabled: next?.enabled ?? prevState.frameRendering.enabled,
clip: next?.clip ?? prevState.frameRendering.clip,
name: next?.name ?? prevState.frameRendering.name,
outline: next?.outline ?? prevState.frameRendering.outline,
},
};
});
};
@@ -2240,27 +2266,51 @@ class App extends React.Component<AppProps, AppState> {
target:
| ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: { fitToContent?: boolean; animate?: boolean; duration?: number },
opts?:
| {
fitToContent?: boolean;
fitToViewport?: never;
viewportZoomFactor?: never;
animate?: boolean;
duration?: number;
}
| {
fitToContent?: never;
fitToViewport?: boolean;
/** when fitToViewport=true, how much screen should the content cover,
* between 0.1 (10%) and 1 (100%)
*/
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
},
) => {
this.cancelInProgresAnimation?.();
// convert provided target into ExcalidrawElement[] if necessary
const targets = Array.isArray(target) ? target : [target];
const targetElements = Array.isArray(target) ? target : [target];
let zoom = this.state.zoom;
let scrollX = this.state.scrollX;
let scrollY = this.state.scrollY;
if (opts?.fitToContent) {
// compute an appropriate viewport location (scroll X, Y) and zoom level
// that fit the target elements on the scene
const { appState } = zoomToFitElements(targets, this.state, false);
if (opts?.fitToContent || opts?.fitToViewport) {
const { appState } = zoomToFit({
targetElements,
appState: this.state,
fitToViewport: !!opts?.fitToViewport,
viewportZoomFactor: opts?.viewportZoomFactor,
});
zoom = appState.zoom;
scrollX = appState.scrollX;
scrollY = appState.scrollY;
} else {
// compute only the viewport location, without any zoom adjustment
const scroll = calculateScrollCenter(targets, this.state, this.canvas);
const scroll = calculateScrollCenter(
targetElements,
this.state,
this.canvas,
);
scrollX = scroll.scrollX;
scrollY = scroll.scrollY;
}
@@ -2270,19 +2320,42 @@ class App extends React.Component<AppProps, AppState> {
if (opts?.animate) {
const origScrollX = this.state.scrollX;
const origScrollY = this.state.scrollY;
const origZoom = this.state.zoom.value;
// zoom animation could become problematic on scenes with large number
// of elements, setting it to its final value to improve user experience.
//
// using zoomCanvas() to zoom on current viewport center
this.zoomCanvas(zoom.value);
const cancel = easeToValuesRAF({
fromValues: {
scrollX: origScrollX,
scrollY: origScrollY,
zoom: origZoom,
},
toValues: { scrollX, scrollY, zoom: zoom.value },
interpolateValue: (from, to, progress, key) => {
// for zoom, use different easing
if (key === "zoom") {
return from * Math.pow(to / from, easeOut(progress));
}
// handle using default
return undefined;
},
onStep: ({ scrollX, scrollY, zoom }) => {
this.setState({
scrollX,
scrollY,
zoom: { value: zoom },
});
},
onStart: () => {
this.setState({ shouldCacheIgnoreZoom: true });
},
onEnd: () => {
this.setState({ shouldCacheIgnoreZoom: false });
},
onCancel: () => {
this.setState({ shouldCacheIgnoreZoom: false });
},
duration: opts?.duration ?? 500,
});
const cancel = easeToValuesRAF(
[origScrollX, origScrollY],
[scrollX, scrollY],
(scrollX, scrollY) => this.setState({ scrollX, scrollY }),
{ duration: opts?.duration ?? 500 },
);
this.cancelInProgresAnimation = () => {
cancel();
this.cancelInProgresAnimation = null;
@@ -2304,6 +2377,7 @@ class App extends React.Component<AppProps, AppState> {
toast: {
message: string;
closable?: boolean;
spinner?: boolean;
duration?: number;
} | null,
) => {
@@ -2705,7 +2779,7 @@ class App extends React.Component<AppProps, AppState> {
} else {
setCursorForShape(this.canvas, this.state);
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
});
@@ -2750,7 +2824,7 @@ class App extends React.Component<AppProps, AppState> {
if (nextActiveTool.type !== "selection") {
this.setState({
activeTool: nextActiveTool,
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
});
@@ -2787,7 +2861,7 @@ class App extends React.Component<AppProps, AppState> {
// elements by mistake while zooming
if (this.isTouchScreenMultiTouchGesture()) {
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
});
}
gesture.initialScale = this.state.zoom.value;
@@ -2832,7 +2906,10 @@ class App extends React.Component<AppProps, AppState> {
if (this.isTouchScreenMultiTouchGesture()) {
this.setState({
previousSelectedElementIds: {},
selectedElementIds: this.state.previousSelectedElementIds,
selectedElementIds: makeNextSelectedElementIds(
this.state.previousSelectedElementIds,
this.state,
),
});
}
gesture.initialScale = null;
@@ -2897,10 +2974,13 @@ class App extends React.Component<AppProps, AppState> {
? element.containerId
: element.id;
this.setState((prevState) => ({
selectedElementIds: {
...prevState.selectedElementIds,
[elementIdToSelect]: true,
},
selectedElementIds: makeNextSelectedElementIds(
{
...prevState.selectedElementIds,
[elementIdToSelect]: true,
},
prevState,
),
}));
}
if (isDeleted) {
@@ -2936,7 +3016,7 @@ class App extends React.Component<AppProps, AppState> {
private deselectElements() {
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
});
@@ -3023,7 +3103,9 @@ class App extends React.Component<AppProps, AppState> {
).filter((element) => {
// hitting a frame's element from outside the frame is not considered a hit
const containingFrame = getContainingFrame(element);
return containingFrame && this.state.shouldRenderFrames
return containingFrame &&
this.state.frameRendering.enabled &&
this.state.frameRendering.clip
? isCursorInFrame({ x, y }, containingFrame)
: true;
});
@@ -3054,7 +3136,10 @@ class App extends React.Component<AppProps, AppState> {
container,
);
if (container && parentCenterPosition) {
shouldBindToContainer = true;
const boundTextElementToContainer = getBoundTextElement(container);
if (!boundTextElementToContainer) {
shouldBindToContainer = true;
}
}
let existingTextElement: NonDeleted<ExcalidrawTextElement> | null = null;
@@ -3244,6 +3329,7 @@ class App extends React.Component<AppProps, AppState> {
selectedGroupIds: {},
},
this.scene.getNonDeletedElements(),
prevState,
),
);
return;
@@ -3349,12 +3435,19 @@ class App extends React.Component<AppProps, AppState> {
this.device.isMobile,
);
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
const url = this.hitLinkElement.link;
let url = this.hitLinkElement.link;
if (url) {
url = normalizeLink(url);
let customEvent;
if (this.props.onLinkOpen) {
customEvent = wrapEvent(EVENT.EXCALIDRAW_LINK, event.nativeEvent);
this.props.onLinkOpen(this.hitLinkElement, customEvent);
this.props.onLinkOpen(
{
...this.hitLinkElement,
link: url,
},
customEvent,
);
}
if (!customEvent?.defaultPrevented) {
const target = isLocalLink(url) ? "_self" : "_blank";
@@ -3362,7 +3455,7 @@ class App extends React.Component<AppProps, AppState> {
// https://mathiasbynens.github.io/rel-noopener/
if (newWindow) {
newWindow.opener = null;
newWindow.location = normalizeLink(url);
newWindow.location = url;
}
}
}
@@ -3944,12 +4037,15 @@ class App extends React.Component<AppProps, AppState> {
editingElement: null,
startBoundElement: null,
suggestedBindings: [],
selectedElementIds: Object.keys(this.state.selectedElementIds)
.filter((key) => key !== element.id)
.reduce((obj: { [id: string]: boolean }, key) => {
obj[key] = this.state.selectedElementIds[key];
return obj;
}, {}),
selectedElementIds: makeNextSelectedElementIds(
Object.keys(this.state.selectedElementIds)
.filter((key) => key !== element.id)
.reduce((obj: { [id: string]: true }, key) => {
obj[key] = this.state.selectedElementIds[key];
return obj;
}, {}),
this.state,
),
},
});
return;
@@ -4418,7 +4514,7 @@ class App extends React.Component<AppProps, AppState> {
private clearSelectionIfNotUsingSelection = (): void => {
if (this.state.activeTool.type !== "selection") {
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
});
@@ -4550,9 +4646,12 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.editingLinearElement) {
this.setState({
selectedElementIds: {
[this.state.editingLinearElement.elementId]: true,
},
selectedElementIds: makeNextSelectedElementIds(
{
[this.state.editingLinearElement.elementId]: true,
},
this.state,
),
});
// If we click on something
} else if (hitElement != null) {
@@ -4580,7 +4679,7 @@ class App extends React.Component<AppProps, AppState> {
!isElementInGroup(hitElement, this.state.editingGroupId)
) {
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
});
@@ -4596,7 +4695,7 @@ class App extends React.Component<AppProps, AppState> {
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
) {
this.setState((prevState) => {
const nextSelectedElementIds = {
const nextSelectedElementIds: { [id: string]: true } = {
...prevState.selectedElementIds,
[hitElement.id]: true,
};
@@ -4614,13 +4713,13 @@ class App extends React.Component<AppProps, AppState> {
previouslySelectedElements,
hitElement.id,
).forEach((element) => {
nextSelectedElementIds[element.id] = false;
delete nextSelectedElementIds[element.id];
});
} else if (hitElement.frameId) {
// if hitElement is in a frame and its frame has been selected
// disable selection for the given element
if (nextSelectedElementIds[hitElement.frameId]) {
nextSelectedElementIds[hitElement.id] = false;
delete nextSelectedElementIds[hitElement.id];
}
} else {
// hitElement is neither a frame nor an element in a frame
@@ -4650,7 +4749,7 @@ class App extends React.Component<AppProps, AppState> {
framesInGroups.has(element.frameId)
) {
// deselect element and groups containing the element
nextSelectedElementIds[element.id] = false;
delete nextSelectedElementIds[element.id];
element.groupIds
.flatMap((gid) =>
getElementsInGroup(
@@ -4658,10 +4757,9 @@ class App extends React.Component<AppProps, AppState> {
gid,
),
)
.forEach(
(element) =>
(nextSelectedElementIds[element.id] = false),
);
.forEach((element) => {
delete nextSelectedElementIds[element.id];
});
}
});
}
@@ -4674,6 +4772,7 @@ class App extends React.Component<AppProps, AppState> {
showHyperlinkPopup: hitElement.link ? "info" : false,
},
this.scene.getNonDeletedElements(),
prevState,
);
});
pointerDownState.hit.wasAddedToSelection = true;
@@ -4790,12 +4889,18 @@ class App extends React.Component<AppProps, AppState> {
frameId: topLayerFrame ? topLayerFrame.id : null,
});
this.setState((prevState) => ({
selectedElementIds: {
this.setState((prevState) => {
const nextSelectedElementIds = {
...prevState.selectedElementIds,
[element.id]: false,
},
}));
};
delete nextSelectedElementIds[element.id];
return {
selectedElementIds: makeNextSelectedElementIds(
nextSelectedElementIds,
prevState,
),
};
});
const pressures = element.simulatePressure
? element.pressures
@@ -4891,10 +4996,13 @@ class App extends React.Component<AppProps, AppState> {
}
this.setState((prevState) => ({
selectedElementIds: {
...prevState.selectedElementIds,
[multiElement.id]: true,
},
selectedElementIds: makeNextSelectedElementIds(
{
...prevState.selectedElementIds,
[multiElement.id]: true,
},
prevState,
),
}));
// clicking outside commit zone → update reference for last committed
// point
@@ -4945,12 +5053,18 @@ class App extends React.Component<AppProps, AppState> {
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
this.setState((prevState) => ({
selectedElementIds: {
this.setState((prevState) => {
const nextSelectedElementIds = {
...prevState.selectedElementIds,
[element.id]: false,
},
}));
};
delete nextSelectedElementIds[element.id];
return {
selectedElementIds: makeNextSelectedElementIds(
nextSelectedElementIds,
prevState,
),
};
});
mutateElement(element, {
points: [...element.points, [0, 0]],
});
@@ -5324,15 +5438,16 @@ class App extends React.Component<AppProps, AppState> {
const oldIdToDuplicatedId = new Map();
const hitElement = pointerDownState.hit.element;
const elements = this.scene.getElementsIncludingDeleted();
const selectedElementIds: Array<ExcalidrawElement["id"]> =
const selectedElementIds = new Set(
getSelectedElements(elements, this.state, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}).map((element) => element.id);
}).map((element) => element.id),
);
for (const element of elements) {
if (
selectedElementIds.includes(element.id) ||
selectedElementIds.has(element.id) ||
// case: the state.selectedElementIds might not have been
// updated yet by the time this mousemove event is fired
(element.id === hitElement?.id &&
@@ -5470,14 +5585,9 @@ class App extends React.Component<AppProps, AppState> {
},
},
this.scene.getNonDeletedElements(),
prevState,
),
);
} else {
this.setState({
selectedElementIds: {},
selectedGroupIds: {},
editingGroupId: null,
});
}
}
// box-select line editor points
@@ -5493,28 +5603,29 @@ class App extends React.Component<AppProps, AppState> {
elements,
draggingElement,
);
this.setState((prevState) =>
selectGroupsForSelectedElements(
this.setState((prevState) => {
const nextSelectedElementIds = elementsWithinSelection.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
acc[element.id] = true;
return acc;
},
{},
);
if (pointerDownState.hit.element) {
// if using ctrl/cmd, select the hitElement only if we
// haven't box-selected anything else
if (!elementsWithinSelection.length) {
nextSelectedElementIds[pointerDownState.hit.element.id] = true;
} else {
delete nextSelectedElementIds[pointerDownState.hit.element.id];
}
}
return selectGroupsForSelectedElements(
{
...prevState,
selectedElementIds: {
...prevState.selectedElementIds,
...elementsWithinSelection.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
acc[element.id] = true;
return acc;
},
{},
),
...(pointerDownState.hit.element
? {
// if using ctrl/cmd, select the hitElement only if we
// haven't box-selected anything else
[pointerDownState.hit.element.id]:
!elementsWithinSelection.length,
}
: null),
},
selectedElementIds: nextSelectedElementIds,
showHyperlinkPopup:
elementsWithinSelection.length === 1 &&
elementsWithinSelection[0].link
@@ -5531,8 +5642,9 @@ class App extends React.Component<AppProps, AppState> {
: null,
},
this.scene.getNonDeletedElements(),
),
);
prevState,
);
});
}
}
});
@@ -5726,7 +5838,12 @@ class App extends React.Component<AppProps, AppState> {
try {
this.initializeImageDimensions(imageElement);
this.setState(
{ selectedElementIds: { [imageElement.id]: true } },
{
selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true },
this.state,
),
},
() => {
this.actionManager.executeAction(actionFinalize);
},
@@ -5790,10 +5907,13 @@ class App extends React.Component<AppProps, AppState> {
activeTool: updateActiveTool(this.state, {
type: "selection",
}),
selectedElementIds: {
...prevState.selectedElementIds,
[draggingElement.id]: true,
},
selectedElementIds: makeNextSelectedElementIds(
{
...prevState.selectedElementIds,
[draggingElement.id]: true,
},
prevState,
),
selectedLinearElement: new LinearElementEditor(
draggingElement,
this.scene,
@@ -6087,31 +6207,37 @@ class App extends React.Component<AppProps, AppState> {
if (childEvent.shiftKey && !this.state.editingLinearElement) {
if (this.state.selectedElementIds[hitElement.id]) {
if (isSelectedViaGroup(this.state, hitElement)) {
// We want to unselect all groups hitElement is part of
// as well as all elements that are part of the groups
// hitElement is part of
const idsOfSelectedElementsThatAreInGroups = hitElement.groupIds
.flatMap((groupId) =>
getElementsInGroup(
this.scene.getNonDeletedElements(),
groupId,
),
)
.map((element) => ({ [element.id]: false }))
.reduce((prevId, acc) => ({ ...prevId, ...acc }), {});
this.setState((_prevState) => {
const nextSelectedElementIds = {
..._prevState.selectedElementIds,
};
this.setState((_prevState) => ({
selectedGroupIds: {
..._prevState.selectedElementIds,
...hitElement.groupIds
.map((gId) => ({ [gId]: false }))
.reduce((prev, acc) => ({ ...prev, ...acc }), {}),
},
selectedElementIds: {
..._prevState.selectedElementIds,
...idsOfSelectedElementsThatAreInGroups,
},
}));
// We want to unselect all groups hitElement is part of
// as well as all elements that are part of the groups
// hitElement is part of
for (const groupedElement of hitElement.groupIds.flatMap(
(groupId) =>
getElementsInGroup(
this.scene.getNonDeletedElements(),
groupId,
),
)) {
delete nextSelectedElementIds[groupedElement.id];
}
return {
selectedGroupIds: {
..._prevState.selectedElementIds,
...hitElement.groupIds
.map((gId) => ({ [gId]: false }))
.reduce((prev, acc) => ({ ...prev, ...acc }), {}),
},
selectedElementIds: makeNextSelectedElementIds(
nextSelectedElementIds,
_prevState,
),
};
});
// if not gragging a linear element point (outside editor)
} else if (!this.state.selectedLinearElement?.isDragging) {
// remove element from selection while
@@ -6120,8 +6246,8 @@ class App extends React.Component<AppProps, AppState> {
this.setState((prevState) => {
const newSelectedElementIds = {
...prevState.selectedElementIds,
[hitElement!.id]: false,
};
delete newSelectedElementIds[hitElement!.id];
const newSelectedElements = getSelectedElements(
this.scene.getNonDeletedElements(),
{ ...prevState, selectedElementIds: newSelectedElementIds },
@@ -6142,6 +6268,7 @@ class App extends React.Component<AppProps, AppState> {
: prevState.selectedLinearElement,
},
this.scene.getNonDeletedElements(),
prevState,
);
});
}
@@ -6152,21 +6279,23 @@ class App extends React.Component<AppProps, AppState> {
// when hitElement is part of a selected frame, deselect the frame
// to avoid frame and containing elements selected simultaneously
this.setState((prevState) => {
const nextSelectedElementIds = {
const nextSelectedElementIds: {
[id: string]: true;
} = {
...prevState.selectedElementIds,
[hitElement.id]: true,
// deselect the frame
[hitElement.frameId!]: false,
};
// deselect the frame
delete nextSelectedElementIds[hitElement.frameId!];
// deselect groups containing the frame
(this.scene.getElement(hitElement.frameId!)?.groupIds ?? [])
.flatMap((gid) =>
getElementsInGroup(this.scene.getNonDeletedElements(), gid),
)
.forEach(
(element) => (nextSelectedElementIds[element.id] = false),
);
.forEach((element) => {
delete nextSelectedElementIds[element.id];
});
return selectGroupsForSelectedElements(
{
@@ -6175,15 +6304,19 @@ class App extends React.Component<AppProps, AppState> {
showHyperlinkPopup: hitElement.link ? "info" : false,
},
this.scene.getNonDeletedElements(),
prevState,
);
});
} else {
// add element to selection while keeping prev elements selected
this.setState((_prevState) => ({
selectedElementIds: {
..._prevState.selectedElementIds,
[hitElement!.id]: true,
},
selectedElementIds: makeNextSelectedElementIds(
{
..._prevState.selectedElementIds,
[hitElement!.id]: true,
},
_prevState,
),
}));
}
} else {
@@ -6201,6 +6334,7 @@ class App extends React.Component<AppProps, AppState> {
: prevState.selectedLinearElement,
},
this.scene.getNonDeletedElements(),
prevState,
),
}));
}
@@ -6225,7 +6359,7 @@ class App extends React.Component<AppProps, AppState> {
} else {
// Deselect selected elements
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
});
@@ -6236,13 +6370,17 @@ class App extends React.Component<AppProps, AppState> {
if (
!activeTool.locked &&
activeTool.type !== "freedraw" &&
draggingElement
draggingElement &&
draggingElement.type !== "selection"
) {
this.setState((prevState) => ({
selectedElementIds: {
...prevState.selectedElementIds,
[draggingElement.id]: true,
},
selectedElementIds: makeNextSelectedElementIds(
{
...prevState.selectedElementIds,
[draggingElement.id]: true,
},
prevState,
),
}));
}
@@ -6556,7 +6694,10 @@ class App extends React.Component<AppProps, AppState> {
this.initializeImageDimensions(imageElement);
this.setState(
{
selectedElementIds: { [imageElement.id]: true },
selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true },
this.state,
),
},
() => {
this.actionManager.executeAction(actionFinalize);
@@ -6783,7 +6924,7 @@ class App extends React.Component<AppProps, AppState> {
private clearSelection(hitElement: ExcalidrawElement | null): void {
this.setState((prevState) => ({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, prevState),
selectedGroupIds: {},
// Continue editing the same group if the user selected a different
// element from it
@@ -6795,7 +6936,7 @@ class App extends React.Component<AppProps, AppState> {
: null,
}));
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
previousSelectedElementIds: this.state.selectedElementIds,
});
}
@@ -6864,7 +7005,12 @@ class App extends React.Component<AppProps, AppState> {
const imageElement = this.createImageElement({ sceneX, sceneY });
this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement);
this.setState({ selectedElementIds: { [imageElement.id]: true } });
this.setState({
selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true },
this.state,
),
});
return;
}
@@ -6989,6 +7135,7 @@ class App extends React.Component<AppProps, AppState> {
: null,
},
this.scene.getNonDeletedElements(),
this.state,
)
: this.state),
showHyperlinkPopup: false,
+20 -4
View File
@@ -17,16 +17,34 @@ import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { jotaiScope } from "../jotai";
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
export interface DialogProps {
children: React.ReactNode;
className?: string;
size?: "small" | "regular" | "wide";
size?: DialogSize;
onCloseRequest(): void;
title: React.ReactNode | false;
autofocus?: boolean;
closeOnClickOutside?: boolean;
}
function getDialogSize(size: DialogSize): number {
if (size && typeof size === "number") {
return size;
}
switch (size) {
case "small":
return 550;
case "wide":
return 1024;
case "regular":
default:
return 800;
}
}
export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
@@ -85,9 +103,7 @@ export const Dialog = (props: DialogProps) => {
<Modal
className={clsx("Dialog", props.className)}
labelledBy="dialog-title"
maxWidth={
props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
}
maxWidth={getDialogSize(props.size)}
onCloseRequest={onClose}
closeOnClickOutside={props.closeOnClickOutside}
>
+138 -42
View File
@@ -2,20 +2,140 @@
.excalidraw {
.ExcButton {
&--color-primary {
color: var(--input-bg-color);
--text-color: transparent;
--border-color: transparent;
--back-color: transparent;
--accent-color: var(--color-primary);
--accent-color-hover: var(--color-primary-darker);
--accent-color-active: var(--color-primary-darkest);
color: var(--text-color);
background-color: var(--back-color);
border-color: var(--border-color);
&--color-primary {
&.ExcButton--variant-filled {
--text-color: var(--input-bg-color);
--back-color: var(--color-primary);
&:hover {
--back-color: var(--color-primary-darker);
}
&:active {
--back-color: var(--color-primary-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-primary);
--border-color: var(--color-primary);
--back-color: var(--input-bg-color);
&:hover {
--text-color: var(--color-primary-darker);
--border-color: var(--color-primary-darker);
}
&:active {
--text-color: var(--color-primary-darkest);
--border-color: var(--color-primary-darkest);
}
}
}
&--color-danger {
color: var(--input-bg-color);
&.ExcButton--variant-filled {
--text-color: var(--color-danger-text);
--back-color: var(--color-danger-dark);
--accent-color: var(--color-danger);
--accent-color-hover: #d65550;
--accent-color-active: #d1413c;
&:hover {
--back-color: var(--color-danger-darker);
}
&:active {
--back-color: var(--color-danger-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-danger);
--border-color: var(--color-danger);
--back-color: transparent;
&:hover {
--text-color: var(--color-danger-darkest);
--border-color: var(--color-danger-darkest);
}
&:active {
--text-color: var(--color-danger-darker);
--border-color: var(--color-danger-darker);
}
}
}
&--color-muted {
&.ExcButton--variant-filled {
--text-color: var(--island-bg-color);
--back-color: var(--color-gray-50);
&:hover {
--back-color: var(--color-gray-60);
}
&:active {
--back-color: var(--color-gray-80);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-muted-background);
--border-color: var(--color-muted);
--back-color: var(--island-bg-color);
&:hover {
--text-color: var(--color-muted-background-darker);
--border-color: var(--color-muted-darker);
}
&:active {
--text-color: var(--color-muted-background-darker);
--border-color: var(--color-muted-darkest);
}
}
}
&--color-warning {
&.ExcButton--variant-filled {
--text-color: black;
--back-color: var(--color-warning-dark);
&:hover {
--back-color: var(--color-warning-darker);
}
&:active {
--back-color: var(--color-warning-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-warning-dark);
--border-color: var(--color-warning-dark);
--back-color: var(--input-bg-color);
&:hover {
--text-color: var(--color-warning-darker);
--border-color: var(--color-warning-darker);
}
&:active {
--text-color: var(--color-warning-darkest);
--border-color: var(--color-warning-darkest);
}
}
}
display: flex;
@@ -25,6 +145,8 @@
flex-wrap: nowrap;
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: "Assistant";
@@ -33,9 +155,9 @@
transition: all 150ms ease-out;
&--size-large {
font-weight: 400;
font-weight: 600;
font-size: 0.875rem;
height: 3rem;
min-height: 3rem;
padding: 0.5rem 1.5rem;
gap: 0.75rem;
@@ -45,48 +167,22 @@
&--size-medium {
font-weight: 600;
font-size: 0.75rem;
height: 2.5rem;
min-height: 2.5rem;
padding: 0.5rem 1rem;
gap: 0.5rem;
letter-spacing: normal;
}
&--variant-filled {
background: var(--accent-color);
border: 1px solid transparent;
&:hover {
background: var(--accent-color-hover);
}
&:active {
background: var(--accent-color-active);
}
}
&--variant-outlined,
&--variant-icon {
border: 1px solid var(--accent-color);
color: var(--accent-color);
background: transparent;
&:hover {
border: 1px solid var(--accent-color-hover);
color: var(--accent-color-hover);
}
&:active {
border: 1px solid var(--accent-color-active);
color: var(--accent-color-active);
}
}
&--variant-icon {
padding: 0.5rem 0.75rem;
width: 3rem;
}
&--fullWidth {
width: 100%;
}
&__icon {
width: 1.25rem;
height: 1.25rem;
+4 -1
View File
@@ -4,7 +4,7 @@ import clsx from "clsx";
import "./FilledButton.scss";
export type ButtonVariant = "filled" | "outlined" | "icon";
export type ButtonColor = "primary" | "danger";
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
export type ButtonSize = "medium" | "large";
export type FilledButtonProps = {
@@ -17,6 +17,7 @@ export type FilledButtonProps = {
color?: ButtonColor;
size?: ButtonSize;
className?: string;
fullWidth?: boolean;
startIcon?: React.ReactNode;
};
@@ -31,6 +32,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
variant = "filled",
color = "primary",
size = "medium",
fullWidth,
className,
},
ref,
@@ -42,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
`ExcButton--color-${color}`,
`ExcButton--variant-${variant}`,
`ExcButton--size-${size}`,
{ "ExcButton--fullWidth": fullWidth },
className,
)}
onClick={onClick}
+1 -1
View File
@@ -12,7 +12,7 @@ const Header = () => (
<div className="HelpDialog__header">
<a
className="HelpDialog__btn"
href="https://github.com/excalidraw/excalidraw#documentation"
href="https://docs.excalidraw.com"
target="_blank"
rel="noopener noreferrer"
>
+13 -1
View File
@@ -41,6 +41,7 @@ import { jotaiScope } from "../jotai";
import { Provider, useAtom, useAtomValue } from "jotai";
import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState";
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
@@ -99,6 +100,15 @@ const DefaultMainMenu: React.FC<{
);
};
const DefaultOverwriteConfirmDialog = () => {
return (
<OverwriteConfirmDialog __fallback>
<OverwriteConfirmDialog.Actions.SaveToDisk />
<OverwriteConfirmDialog.Actions.ExportToImage />
</OverwriteConfirmDialog>
);
};
const LayerUI = ({
actionManager,
appState,
@@ -343,6 +353,7 @@ const LayerUI = ({
>
{t("toolBar.library")}
</DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog />
{/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />}
@@ -374,6 +385,7 @@ const LayerUI = ({
/>
)}
<ActiveConfirmDialog />
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}
{renderJSONExportDialog()}
{appState.pasteDialog.shown && (
@@ -387,7 +399,7 @@ const LayerUI = ({
}
/>
)}
{device.isMobile && !eyeDropperState && (
{device.isMobile && (
<MobileMenu
appState={appState}
elements={elements}
+1 -1
View File
@@ -3,7 +3,7 @@
.excalidraw {
&.excalidraw-modal-container {
position: absolute;
z-index: 10;
z-index: var(--zIndex-modal);
}
.Modal {
@@ -0,0 +1,126 @@
@import "../../css/variables.module";
.excalidraw {
.OverwriteConfirm {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
isolation: isolate;
h3 {
margin: 0;
font-weight: 700;
font-size: 1.3125rem;
line-height: 130%;
align-self: flex-start;
color: var(--text-primary-color);
}
&__Description {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
gap: 1rem;
@include isMobile {
flex-direction: column;
text-align: center;
}
padding: 2.5rem;
background: var(--color-danger-background);
border-radius: 0.5rem;
font-family: "Assistant";
font-style: normal;
font-weight: 400;
font-size: 1rem;
line-height: 150%;
color: var(--color-danger-color);
&__spacer {
flex-grow: 1;
}
&__icon {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2.5rem;
background: var(--color-danger-icon-background);
width: 3.5rem;
height: 3.5rem;
padding: 0.75rem;
svg {
color: var(--color-danger-icon-color);
width: 1.5rem;
height: 1.5rem;
}
}
&.OverwriteConfirm__Description--color-warning {
background: var(--color-warning-background);
color: var(--color-warning-color);
.OverwriteConfirm__Description__icon {
background: var(--color-warning-icon-background);
flex: 0 0 auto;
svg {
color: var(--color-warning-icon-color);
}
}
}
}
&__Actions {
display: flex;
flex-direction: row;
align-items: stretch;
justify-items: stretch;
justify-content: center;
gap: 1.5rem;
@include isMobile {
flex-direction: column;
}
&__Action {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem;
gap: 0.75rem;
flex-basis: 50%;
flex-grow: 0;
&__content {
height: 100%;
font-size: 0.875rem;
text-align: center;
}
h4 {
font-weight: 700;
font-size: 1.125rem;
line-height: 130%;
margin: 0;
color: var(--text-primary-color);
}
}
}
}
}
@@ -0,0 +1,76 @@
import React from "react";
import { useAtom } from "jotai";
import { useTunnels } from "../../context/tunnels";
import { jotaiScope } from "../../jotai";
import { Dialog } from "../Dialog";
import { withInternalFallback } from "../hoc/withInternalFallback";
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
import { FilledButton } from "../FilledButton";
import { alertTriangleIcon } from "../icons";
import { Actions, Action } from "./OverwriteConfirmActions";
import "./OverwriteConfirm.scss";
export type OverwriteConfirmDialogProps = {
children: React.ReactNode;
};
const OverwriteConfirmDialog = Object.assign(
withInternalFallback(
"OverwriteConfirmDialog",
({ children }: OverwriteConfirmDialogProps) => {
const { OverwriteConfirmDialogTunnel } = useTunnels();
const [overwriteConfirmState, setState] = useAtom(
overwriteConfirmStateAtom,
jotaiScope,
);
if (!overwriteConfirmState.active) {
return null;
}
const handleClose = () => {
overwriteConfirmState.onClose();
setState((state) => ({ ...state, active: false }));
};
const handleConfirm = () => {
overwriteConfirmState.onConfirm();
setState((state) => ({ ...state, active: false }));
};
return (
<OverwriteConfirmDialogTunnel.In>
<Dialog onCloseRequest={handleClose} title={false} size={916}>
<div className="OverwriteConfirm">
<h3>{overwriteConfirmState.title}</h3>
<div
className={`OverwriteConfirm__Description OverwriteConfirm__Description--color-${overwriteConfirmState.color}`}
>
<div className="OverwriteConfirm__Description__icon">
{alertTriangleIcon}
</div>
<div>{overwriteConfirmState.description}</div>
<div className="OverwriteConfirm__Description__spacer"></div>
<FilledButton
color={overwriteConfirmState.color}
size="large"
label={overwriteConfirmState.actionLabel}
onClick={handleConfirm}
/>
</div>
<Actions>{children}</Actions>
</div>
</Dialog>
</OverwriteConfirmDialogTunnel.In>
);
},
),
{
Actions,
Action,
},
);
export { OverwriteConfirmDialog };
@@ -0,0 +1,85 @@
import React from "react";
import { FilledButton } from "../FilledButton";
import { useExcalidrawActionManager, useExcalidrawSetAppState } from "../App";
import { actionSaveFileToDisk } from "../../actions";
import { useI18n } from "../../i18n";
import { actionChangeExportEmbedScene } from "../../actions/actionExport";
export type ActionProps = {
title: string;
children: React.ReactNode;
actionLabel: string;
onClick: () => void;
};
export const Action = ({
title,
children,
actionLabel,
onClick,
}: ActionProps) => {
return (
<div className="OverwriteConfirm__Actions__Action">
<h4>{title}</h4>
<div className="OverwriteConfirm__Actions__Action__content">
{children}
</div>
<FilledButton
variant="outlined"
color="muted"
label={actionLabel}
size="large"
fullWidth
onClick={onClick}
/>
</div>
);
};
export const ExportToImage = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const setAppState = useExcalidrawSetAppState();
return (
<Action
title={t("overwriteConfirm.action.exportToImage.title")}
actionLabel={t("overwriteConfirm.action.exportToImage.button")}
onClick={() => {
actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
setAppState({ openDialog: "imageExport" });
}}
>
{t("overwriteConfirm.action.exportToImage.description")}
</Action>
);
};
export const SaveToDisk = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
return (
<Action
title={t("overwriteConfirm.action.saveToDisk.title")}
actionLabel={t("overwriteConfirm.action.saveToDisk.button")}
onClick={() => {
actionManager.executeAction(actionSaveFileToDisk, "ui");
}}
>
{t("overwriteConfirm.action.saveToDisk.description")}
</Action>
);
};
const Actions = Object.assign(
({ children }: { children: React.ReactNode }) => {
return <div className="OverwriteConfirm__Actions">{children}</div>;
},
{
ExportToImage,
SaveToDisk,
},
);
export { Actions };
@@ -0,0 +1,46 @@
import { atom } from "jotai";
import { jotaiStore } from "../../jotai";
import React from "react";
export type OverwriteConfirmState =
| {
active: true;
title: string;
description: React.ReactNode;
actionLabel: string;
color: "danger" | "warning";
onClose: () => void;
onConfirm: () => void;
onReject: () => void;
}
| { active: false };
export const overwriteConfirmStateAtom = atom<OverwriteConfirmState>({
active: false,
});
export async function openConfirmModal({
title,
description,
actionLabel,
color,
}: {
title: string;
description: React.ReactNode;
actionLabel: string;
color: "danger" | "warning";
}) {
return new Promise<boolean>((resolve) => {
jotaiStore.set(overwriteConfirmStateAtom, {
active: true,
onConfirm: () => resolve(true),
onClose: () => resolve(false),
onReject: () => resolve(false),
title,
description,
actionLabel,
color,
});
});
}
+91
View File
@@ -0,0 +1,91 @@
@import "../css/variables.module";
.excalidraw {
.ShareableLinkDialog {
display: flex;
flex-direction: column;
gap: 1.5rem;
color: var(--text-primary-color);
::selection {
background: var(--color-primary-light-darker);
}
h3 {
font-family: "Assistant";
font-weight: 700;
font-size: 1.313rem;
line-height: 130%;
margin: 0;
}
&__popover {
@keyframes RoomDialog__popover__scaleIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
box-sizing: border-box;
z-index: 100;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
padding: 0.125rem 0.5rem;
gap: 0.125rem;
height: 1.125rem;
border: none;
border-radius: 0.6875rem;
font-family: "Assistant";
font-style: normal;
font-weight: 600;
font-size: 0.75rem;
line-height: 110%;
background: var(--color-success-lighter);
color: var(--color-success);
& > svg {
width: 0.875rem;
height: 0.875rem;
}
transform-origin: var(--radix-popover-content-transform-origin);
animation: RoomDialog__popover__scaleIn 150ms ease-out;
}
&__linkRow {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 0.75rem;
}
&__description {
border-top: 1px solid var(--color-gray-20);
padding: 0.5rem 0.5rem 0;
font-weight: 400;
font-size: 0.75rem;
line-height: 150%;
& p {
margin: 0;
}
& p + p {
margin-top: 1em;
}
}
}
}
+91
View File
@@ -0,0 +1,91 @@
import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../clipboard";
import { useI18n } from "../i18n";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { FilledButton } from "./FilledButton";
import { copyIcon, tablerCheckIcon } from "./icons";
import "./ShareableLinkDialog.scss";
export type ShareableLinkDialogProps = {
link: string;
onCloseRequest: () => void;
setErrorMessage: (error: string) => void;
};
export const ShareableLinkDialog = ({
link,
onCloseRequest,
setErrorMessage,
}: ShareableLinkDialogProps) => {
const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false);
const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null);
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(link);
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
} catch (error: any) {
setErrorMessage(error.message);
}
ref.current?.select();
};
return (
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
<div className="ShareableLinkDialog">
<h3>Shareable link</h3>
<div className="ShareableLinkDialog__linkRow">
<TextField
ref={ref}
label="Link"
readonly
fullWidth
value={link}
selectOnRender
/>
<Popover.Root open={justCopied}>
<Popover.Trigger asChild>
<FilledButton
size="large"
label="Copy link"
startIcon={copyIcon}
onClick={copyRoomLink}
/>
</Popover.Trigger>
<Popover.Content
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="ShareableLinkDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div>
<div className="ShareableLinkDialog__description">
🔒 {t("alerts.uploadedSecurly")}
</div>
</div>
</Dialog>
);
};
+24 -2
View File
@@ -1,4 +1,10 @@
import { forwardRef, useRef, useImperativeHandle, KeyboardEvent } from "react";
import {
forwardRef,
useRef,
useImperativeHandle,
KeyboardEvent,
useLayoutEffect,
} from "react";
import clsx from "clsx";
import "./TextField.scss";
@@ -12,6 +18,7 @@ export type TextFieldProps = {
readonly?: boolean;
fullWidth?: boolean;
selectOnRender?: boolean;
label?: string;
placeholder?: string;
@@ -19,13 +26,28 @@ export type TextFieldProps = {
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
(
{ value, onChange, label, fullWidth, placeholder, readonly, onKeyDown },
{
value,
onChange,
label,
fullWidth,
placeholder,
readonly,
selectOnRender,
onKeyDown,
},
ref,
) => {
const innerRef = useRef<HTMLInputElement | null>(null);
useImperativeHandle(ref, () => innerRef.current!);
useLayoutEffect(() => {
if (selectOnRender) {
innerRef.current?.select();
}
}, [selectOnRender]);
return (
<div
className={clsx("ExcTextField", {
+11
View File
@@ -25,6 +25,17 @@
white-space: pre-wrap;
}
.Toast__message--spinner {
padding: 0 3rem;
}
.Toast__spinner {
position: absolute;
left: 1.5rem;
top: 50%;
margin-top: -8px;
}
.close {
position: absolute;
top: 0;
+16 -1
View File
@@ -1,5 +1,7 @@
import clsx from "clsx";
import { useCallback, useEffect, useRef } from "react";
import { CloseIcon } from "./icons";
import Spinner from "./Spinner";
import "./Toast.scss";
import { ToolButton } from "./ToolButton";
@@ -9,12 +11,14 @@ export const Toast = ({
message,
onClose,
closable = false,
spinner = true,
// To prevent autoclose, pass duration as Infinity
duration = DEFAULT_TOAST_TIMEOUT,
}: {
message: string;
onClose: () => void;
closable?: boolean;
spinner?: boolean;
duration?: number;
}) => {
const timerRef = useRef<number>(0);
@@ -44,7 +48,18 @@ export const Toast = ({
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<p className="Toast__message">{message}</p>
{spinner && (
<div className="Toast__spinner">
<Spinner />
</div>
)}
<p
className={clsx("Toast__message", {
"Toast__message--spinner": spinner,
})}
>
{message}
</p>
{closable && (
<ToolButton
icon={CloseIcon}
+1 -1
View File
@@ -6,7 +6,7 @@
Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
position: fixed;
z-index: 1000;
z-index: var(--zIndex-popup);
padding: 8px;
border-radius: 6px;
+10
View File
@@ -1608,6 +1608,16 @@ export const tablerCheckIcon = createIcon(
tablerIconProps,
);
export const alertTriangleIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.24 3.957l-8.422 14.06a1.989 1.989 0 0 0 1.7 2.983h16.845a1.989 1.989 0 0 0 1.7 -2.983l-8.423 -14.06a1.989 1.989 0 0 0 -3.4 0z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</>,
tablerIconProps,
);
export const eyeDropperIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
+29 -2
View File
@@ -1,6 +1,10 @@
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { useI18n } from "../../i18n";
import { useExcalidrawSetAppState, useExcalidrawActionManager } from "../App";
import {
useExcalidrawSetAppState,
useExcalidrawActionManager,
useExcalidrawElements,
} from "../App";
import {
ExportIcon,
ExportImageIcon,
@@ -29,19 +33,42 @@ import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai";
import { useUIAppState } from "../../context/ui-appState";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans";
export const LoadScene = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const elements = useExcalidrawElements();
if (!actionManager.isActionEnabled(actionLoadScene)) {
return null;
}
const handleSelect = async () => {
if (
!elements.length ||
(await openConfirmModal({
title: t("overwriteConfirm.modal.loadFromFile.title"),
actionLabel: t("overwriteConfirm.modal.loadFromFile.button"),
color: "warning",
description: (
<Trans
i18nKey="overwriteConfirm.modal.loadFromFile.description"
bold={(text) => <strong>{text}</strong>}
br={() => <br />}
/>
),
}))
) {
actionManager.executeAction(actionLoadScene);
}
};
return (
<DropdownMenuItem
icon={LoadIcon}
onSelect={() => actionManager.executeAction(actionLoadScene)}
onSelect={handleSelect}
data-testid="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
aria-label={t("buttons.load")}
+2
View File
@@ -12,6 +12,7 @@ type TunnelsContextValue = {
FooterCenterTunnel: Tunnel;
DefaultSidebarTriggerTunnel: Tunnel;
DefaultSidebarTabTriggersTunnel: Tunnel;
OverwriteConfirmDialogTunnel: Tunnel;
jotaiScope: symbol;
};
@@ -30,6 +31,7 @@ export const useInitializeTunnels = () => {
FooterCenterTunnel: tunnel(),
DefaultSidebarTriggerTunnel: tunnel(),
DefaultSidebarTabTriggersTunnel: tunnel(),
OverwriteConfirmDialogTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);
+9
View File
@@ -5,6 +5,15 @@
--zIndex-canvas: 1;
--zIndex-wysiwyg: 2;
--zIndex-layerUI: 3;
--zIndex-modal: 1000;
--zIndex-popup: 1001;
--zIndex-toast: 999999;
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
--sar: env(safe-area-inset-right);
--sat: env(safe-area-inset-top);
}
.excalidraw {
+45 -4
View File
@@ -27,10 +27,6 @@
--popup-secondary-bg-color: #{$oc-gray-1};
--popup-text-color: #{$oc-black};
--popup-text-inverted-color: #{$oc-white};
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
--sar: env(safe-area-inset-right);
--sat: env(safe-area-inset-top);
--select-highlight-color: #{$oc-blue-5};
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
@@ -99,9 +95,33 @@
--color-gray-100: #121212;
--color-warning: #fceeca;
--color-warning-dark: #f5c354;
--color-warning-darker: #f3ab2c;
--color-warning-darkest: #ec8b14;
--color-text-warning: var(--text-primary-color);
--color-danger: #db6965;
--color-danger-dark: #db6965;
--color-danger-darker: #d65550;
--color-danger-darkest: #d1413c;
--color-danger-text: black;
--color-danger-background: #fff0f0;
--color-danger-icon-background: #ffdad6;
--color-danger-color: #700000;
--color-danger-icon-color: #700000;
--color-warning-background: var(--color-warning);
--color-warning-icon-background: var(--color-warning-dark);
--color-warning-color: var(--text-primary-color);
--color-warning-icon-color: var(--text-primary-color);
--color-muted: var(--color-gray-30);
--color-muted-darker: var(--color-gray-60);
--color-muted-darkest: var(--color-gray-100);
--color-muted-background: var(--color-gray-80);
--color-muted-background-darker: var(--color-gray-100);
--color-promo: #e70078;
--color-success: #268029;
--color-success-lighter: #cafccc;
@@ -177,6 +197,27 @@
--color-text-warning: var(--color-gray-80);
--color-danger: #ffa8a5;
--color-danger-dark: #672120;
--color-danger-darker: #8f2625;
--color-danger-darkest: #ac2b29;
--color-danger-text: #fbcbcc;
--color-danger-background: #fbcbcc;
--color-danger-icon-background: #672120;
--color-danger-color: #261919;
--color-danger-icon-color: #fbcbcc;
--color-warning-background: var(--color-warning);
--color-warning-icon-background: var(--color-warning-dark);
--color-warning-color: var(--color-gray-80);
--color-warning-icon-color: var(--color-gray-80);
--color-muted: var(--color-gray-80);
--color-muted-darker: var(--color-gray-60);
--color-muted-darkest: var(--color-gray-20);
--color-muted-background: var(--color-gray-40);
--color-muted-background-darker: var(--color-gray-20);
--color-promo: #d297ff;
}
}
+24 -1
View File
@@ -41,6 +41,7 @@ import {
measureBaseline,
} from "../element/textElement";
import { COLOR_PALETTE } from "../colors";
import { normalizeLink } from "./url";
type RestoredAppState = Omit<
AppState,
@@ -142,7 +143,7 @@ const restoreElementWithProperties = <
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
: element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ?? null,
link: element.link ? normalizeLink(element.link) : null,
locked: element.locked ?? false,
};
@@ -370,6 +371,24 @@ const repairBoundElement = (
}
};
/**
* Remove an element's frameId if its containing frame is non-existent
*
* NOTE mutates elements.
*/
const repairFrameMembership = (
element: Mutable<ExcalidrawElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
if (element.frameId) {
const containingFrame = elementsMap.get(element.frameId);
if (!containingFrame) {
element.frameId = null;
}
}
};
export const restoreElements = (
elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
@@ -410,6 +429,10 @@ export const restoreElements = (
// repair binding. Mutates elements.
const restoredElementsMap = arrayToMap(restoredElements);
for (const element of restoredElements) {
if (element.frameId) {
repairFrameMembership(element, restoredElementsMap);
}
if (isTextElement(element) && element.containerId) {
repairBoundElement(element, restoredElementsMap);
} else if (element.boundElements) {
+30
View File
@@ -0,0 +1,30 @@
import { normalizeLink } from "./url";
describe("normalizeLink", () => {
// NOTE not an extensive XSS test suite, just to check if we're not
// regressing in sanitization
it("should sanitize links", () => {
expect(
// eslint-disable-next-line no-script-url
normalizeLink(`javascript://%0aalert(document.domain)`).startsWith(
// eslint-disable-next-line no-script-url
`javascript:`,
),
).toBe(false);
expect(normalizeLink("ola")).toBe("ola");
expect(normalizeLink(" ola")).toBe("ola");
expect(normalizeLink("https://www.excalidraw.com")).toBe(
"https://www.excalidraw.com",
);
expect(normalizeLink("www.excalidraw.com")).toBe("www.excalidraw.com");
expect(normalizeLink("/ola")).toBe("/ola");
expect(normalizeLink("http://test")).toBe("http://test");
expect(normalizeLink("ftp://test")).toBe("ftp://test");
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
expect(normalizeLink("[[test]]")).toBe("[[test]]");
expect(normalizeLink("<test>")).toBe("<test>");
});
});
+9
View File
@@ -0,0 +1,9 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
export const normalizeLink = (link: string) => {
return sanitizeUrl(link);
};
export const isLocalLink = (link: string | null) => {
return !!(link?.includes(location.origin) || link?.startsWith("/"));
};
+9 -17
View File
@@ -29,6 +29,7 @@ import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip";
import { getSelectedElements } from "../scene";
import { isPointHittingElementBoundingBox } from "./collision";
import { getElementAbsoluteCoords } from "./";
import { isLocalLink, normalizeLink } from "../data/url";
import "./Hyperlink.scss";
import { trackEvent } from "../analytics";
@@ -166,7 +167,7 @@ export const Hyperlink = ({
/>
) : (
<a
href={element.link || ""}
href={normalizeLink(element.link || "")}
className={clsx("excalidraw-hyperlinkContainer-link", {
"d-none": isEditing,
})}
@@ -177,7 +178,13 @@ export const Hyperlink = ({
EVENT.EXCALIDRAW_LINK,
event.nativeEvent,
);
onLinkOpen(element, customEvent);
onLinkOpen(
{
...element,
link: normalizeLink(element.link),
},
customEvent,
);
if (customEvent.defaultPrevented) {
event.preventDefault();
}
@@ -231,21 +238,6 @@ const getCoordsForPopover = (
return { x, y };
};
export const normalizeLink = (link: string) => {
link = link.trim();
if (link) {
// prefix with protocol if not fully-qualified
if (!link.includes("://") && !/^[[\\/]/.test(link)) {
link = `https://${link}`;
}
}
return link;
};
export const isLocalLink = (link: string | null) => {
return !!(link?.includes(location.origin) || link?.startsWith("/"));
};
export const actionLink = register({
name: "hyperlink",
perform: (elements, appState) => {
-1
View File
@@ -862,7 +862,6 @@ const VALID_CONTAINER_TYPES = new Set([
"rectangle",
"ellipse",
"diamond",
"image",
"arrow",
]);
+49
View File
@@ -1459,5 +1459,54 @@ describe("textWysiwyg", () => {
}),
);
});
it("shouldn't bind to container if container has bound text not centered and text tool is used", async () => {
expect(h.elements.length).toBe(1);
Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(2);
// Bind first text
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello!");
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
).toBe(VERTICAL_ALIGN.MIDDLE);
fireEvent.click(screen.getByTitle("Align bottom"));
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
).toBe(VERTICAL_ALIGN.BOTTOM);
// Attempt to Bind 2nd text using text tool
UI.clickTool("text");
mouse.clickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Excalidraw");
editor.blur();
expect(h.elements.length).toBe(3);
expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" },
]);
text = h.elements[2] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(null);
expect(text.text).toBe("Excalidraw");
});
});
});
+9 -9
View File
@@ -30,15 +30,6 @@ describe("Test TypeChecks", () => {
}),
),
).toBeTruthy();
expect(
hasBoundTextElement(
API.createElement({
type: "image",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeTruthy();
});
it("should return false for text bindable containers without bound text", () => {
@@ -62,5 +53,14 @@ describe("Test TypeChecks", () => {
),
).toBeFalsy();
});
expect(
hasBoundTextElement(
API.createElement({
type: "image",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeFalsy();
});
});
-1
View File
@@ -126,7 +126,6 @@ export const isTextBindableContainer = (
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "image" ||
isArrowElement(element))
);
};
-1
View File
@@ -162,7 +162,6 @@ export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawImageElement
| ExcalidrawArrowElement;
export type ExcalidrawTextElementWithContainer = {
+2
View File
@@ -7,6 +7,8 @@ export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
export const PAUSE_COLLABORATION_TIMEOUT = 30000;
export const RESUME_FALLBACK_TIMEOUT = 5000;
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631)
+226 -36
View File
@@ -1,6 +1,6 @@
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import { ExcalidrawImperativeAPI } from "../../types";
import { ExcalidrawImperativeAPI, PauseCollaborationState } from "../../types";
import { ErrorDialog } from "../../components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../constants";
import { ImportedDataState } from "../../data/types";
@@ -16,6 +16,7 @@ import { Collaborator, Gesture } from "../../types";
import {
preventUnload,
resolvablePromise,
upsertMap,
withBatchedUpdates,
} from "../../utils";
import {
@@ -24,12 +25,15 @@ import {
FIREBASE_STORAGE_PREFIXES,
INITIAL_SCENE_UPDATE_TIMEOUT,
LOAD_IMAGES_TIMEOUT,
PAUSE_COLLABORATION_TIMEOUT,
WS_SCENE_EVENT_TYPES,
SYNC_FULL_SCENE_INTERVAL_MS,
RESUME_FALLBACK_TIMEOUT,
} from "../app_constants";
import {
generateCollaborationLinkData,
getCollaborationLink,
getCollaborationLinkData,
getCollabServer,
getSyncableElements,
SocketUpdateDataSource,
@@ -43,8 +47,8 @@ import {
saveToFirebase,
} from "../data/firebase";
import {
importUsernameFromLocalStorage,
saveUsernameToLocalStorage,
importUsernameAndIdFromLocalStorage,
saveUsernameAndIdToLocalStorage,
} from "../data/localStorage";
import Portal from "./Portal";
import RoomDialog from "./RoomDialog";
@@ -71,16 +75,19 @@ import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
import { atom, useAtom } from "jotai";
import { appJotaiStore } from "../app-jotai";
import { nanoid } from "nanoid";
export const collabAPIAtom = atom<CollabAPI | null>(null);
export const collabDialogShownAtom = atom(false);
export const isCollaboratingAtom = atom(false);
export const isOfflineAtom = atom(false);
export const isCollaborationPausedAtom = atom(false);
interface CollabState {
errorMessage: string;
username: string;
activeRoomLink: string;
userId: string;
}
type CollabInstance = InstanceType<typeof Collab>;
@@ -94,6 +101,7 @@ export interface CollabAPI {
syncElements: CollabInstance["syncElements"];
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
setUsername: (username: string) => void;
isPaused: () => boolean;
}
interface PublicProps {
@@ -108,6 +116,7 @@ class Collab extends PureComponent<Props, CollabState> {
excalidrawAPI: Props["excalidrawAPI"];
activeIntervalId: number | null;
idleTimeoutId: number | null;
pauseTimeoutId: number | null;
private socketInitializationTimer?: number;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
@@ -115,9 +124,13 @@ class Collab extends PureComponent<Props, CollabState> {
constructor(props: Props) {
super(props);
const { username, userId } = importUsernameAndIdFromLocalStorage() || {};
this.state = {
errorMessage: "",
username: importUsernameFromLocalStorage() || "",
username: username || "",
userId: userId || "",
activeRoomLink: "",
};
this.portal = new Portal(this);
@@ -149,6 +162,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.excalidrawAPI = props.excalidrawAPI;
this.activeIntervalId = null;
this.idleTimeoutId = null;
this.pauseTimeoutId = null;
}
componentDidMount() {
@@ -167,6 +181,7 @@ class Collab extends PureComponent<Props, CollabState> {
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
stopCollaboration: this.stopCollaboration,
setUsername: this.setUsername,
isPaused: this.isPaused,
};
appJotaiStore.set(collabAPIAtom, collabAPI);
@@ -207,6 +222,10 @@ class Collab extends PureComponent<Props, CollabState> {
window.clearTimeout(this.idleTimeoutId);
this.idleTimeoutId = null;
}
if (this.pauseTimeoutId) {
window.clearTimeout(this.pauseTimeoutId);
this.pauseTimeoutId = null;
}
}
isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
@@ -310,6 +329,126 @@ class Collab extends PureComponent<Props, CollabState> {
}
};
fallbackResumeTimeout: null | ReturnType<typeof setTimeout> = null;
/**
* Handles the pause and resume states of a collaboration session.
* This function gets triggered when a change in the collaboration pause state is detected.
* Based on the state, the function carries out the following actions:
* 1. `PAUSED`: Saves the current scene to Firebase, disconnects the socket, and updates the scene to view mode.
* 2. `RESUMED`: Connects the socket, shows a toast message, sets a fallback to fetch data from Firebase, and resets the pause timeout if any.
* 3. `SYNCED`: Clears the fallback timeout if any, updates the collaboration pause state, and updates the scene to editing mode.
*
* @param state - The new state of the collaboration session. It is one of the values of `PauseCollaborationState` enum, which includes `PAUSED`, `RESUMED`, and `SYNCED`.
*/
onPauseCollaborationChange = (state: PauseCollaborationState) => {
switch (state) {
case PauseCollaborationState.PAUSED: {
if (this.portal.socket) {
// Save current scene to firebase
this.saveCollabRoomToFirebase(
getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
);
this.portal.socket.disconnect();
this.portal.socketInitialized = false;
this.setIsCollaborationPaused(true);
this.excalidrawAPI.updateScene({
appState: { viewModeEnabled: true },
});
}
break;
}
case PauseCollaborationState.RESUMED: {
if (this.portal.socket && this.isPaused()) {
this.portal.socket.connect();
this.portal.socket.emit(WS_SCENE_EVENT_TYPES.INIT);
this.excalidrawAPI.setToast({
message: t("toast.reconnectRoomServer"),
duration: Infinity,
spinner: true,
closable: false,
});
// Fallback to fetch data from firebase when reconnecting to scene without collaborators
const fallbackResumeHandler = async () => {
const roomLinkData = getCollaborationLinkData(
this.state.activeRoomLink,
);
if (!roomLinkData) {
return;
}
const elements = await loadFromFirebase(
roomLinkData.roomId,
roomLinkData.roomKey,
this.portal.socket,
);
if (elements) {
this.setLastBroadcastedOrReceivedSceneVersion(
getSceneVersion(elements),
);
this.excalidrawAPI.updateScene({
elements,
});
}
this.onPauseCollaborationChange(PauseCollaborationState.SYNCED);
};
// Set timeout to fallback to fetch data from firebase
this.fallbackResumeTimeout = setTimeout(
fallbackResumeHandler,
RESUME_FALLBACK_TIMEOUT,
);
// When no users are in the room, we fallback to fetch data from firebase immediately and clear fallback timeout
this.portal.socket.on("first-in-room", () => {
if (this.portal.socket) {
this.portal.socket.off("first-in-room");
// Recall init event to initialize collab with other users (fixes https://github.com/excalidraw/excalidraw/pull/6638#issuecomment-1600799080)
this.portal.socket.emit(WS_SCENE_EVENT_TYPES.INIT);
}
fallbackResumeHandler();
});
}
// Clear pause timeout if exists
if (this.pauseTimeoutId) {
clearTimeout(this.pauseTimeoutId);
}
break;
}
case PauseCollaborationState.SYNCED: {
if (this.fallbackResumeTimeout) {
clearTimeout(this.fallbackResumeTimeout);
this.fallbackResumeTimeout = null;
}
if (this.isPaused()) {
this.setIsCollaborationPaused(false);
this.excalidrawAPI.updateScene({
appState: { viewModeEnabled: false },
});
this.excalidrawAPI.setToast(null);
this.excalidrawAPI.scrollToContent();
}
}
}
};
isPaused = () => appJotaiStore.get(isCollaborationPausedAtom)!;
setIsCollaborationPaused = (isPaused: boolean) => {
appJotaiStore.set(isCollaborationPausedAtom, isPaused);
};
private destroySocketClient = (opts?: { isUnload: boolean }) => {
this.lastBroadcastedOrReceivedSceneVersion = -1;
this.portal.close();
@@ -388,6 +527,11 @@ class Collab extends PureComponent<Props, CollabState> {
});
}
if (!this.state.userId) {
const userId = nanoid();
this.onUserIdChange(userId);
}
if (this.portal.socket) {
return null;
}
@@ -502,6 +646,7 @@ class Collab extends PureComponent<Props, CollabState> {
elements: reconciledElements,
scrollToContent: true,
});
this.onPauseCollaborationChange(PauseCollaborationState.SYNCED);
}
break;
}
@@ -511,36 +656,63 @@ class Collab extends PureComponent<Props, CollabState> {
);
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);
const {
pointer,
button,
username,
selectedElementIds,
userId,
socketId,
} = decryptedData.payload;
const collaborators = upsertMap(
userId,
{
username,
pointer,
button,
selectedElementIds,
socketId,
},
this.collaborators,
);
this.excalidrawAPI.updateScene({
collaborators,
collaborators: new Map(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;
const { userState, username, userId, socketId } =
decryptedData.payload;
const collaborators = upsertMap(
userId,
{
username,
userState,
userId,
socketId,
},
this.collaborators,
);
this.excalidrawAPI.updateScene({
collaborators,
collaborators: new Map(collaborators),
});
break;
}
case "USER_JOINED": {
const { username, userId, socketId } = decryptedData.payload;
const collaborators = upsertMap(
userId,
{
username,
userId,
socketId,
},
this.collaborators,
);
this.excalidrawAPI.updateScene({
collaborators: new Map(collaborators),
});
}
}
},
);
@@ -609,6 +781,15 @@ class Collab extends PureComponent<Props, CollabState> {
} else {
this.portal.socketInitialized = true;
}
if (this.portal.socket) {
this.portal.brodcastUserJoinedRoom({
username: this.state.username,
userId: this.state.userId,
socketId: this.portal.socket.id,
});
}
return null;
};
@@ -696,6 +877,10 @@ class Collab extends PureComponent<Props, CollabState> {
window.clearInterval(this.activeIntervalId);
this.activeIntervalId = null;
}
this.pauseTimeoutId = window.setTimeout(
() => this.onPauseCollaborationChange(PauseCollaborationState.PAUSED),
PAUSE_COLLABORATION_TIMEOUT,
);
this.onIdleStateChange(UserIdleState.AWAY);
} else {
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
@@ -704,6 +889,11 @@ class Collab extends PureComponent<Props, CollabState> {
ACTIVE_THRESHOLD,
);
this.onIdleStateChange(UserIdleState.ACTIVE);
if (this.pauseTimeoutId) {
window.clearTimeout(this.pauseTimeoutId);
this.onPauseCollaborationChange(PauseCollaborationState.RESUMED);
this.pauseTimeoutId = null;
}
}
};
@@ -725,17 +915,12 @@ class Collab extends PureComponent<Props, CollabState> {
};
setCollaborators(sockets: string[]) {
const collaborators: InstanceType<typeof Collab>["collaborators"] =
new Map();
for (const socketId of sockets) {
if (this.collaborators.has(socketId)) {
collaborators.set(socketId, this.collaborators.get(socketId)!);
} else {
collaborators.set(socketId, {});
this.collaborators.forEach((value, key) => {
if (value.socketId && !sockets.includes(value.socketId)) {
this.collaborators.delete(key);
}
}
this.collaborators = collaborators;
this.excalidrawAPI.updateScene({ collaborators });
});
this.excalidrawAPI.updateScene({ collaborators: this.collaborators });
}
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
@@ -821,7 +1006,12 @@ class Collab extends PureComponent<Props, CollabState> {
onUsernameChange = (username: string) => {
this.setUsername(username);
saveUsernameToLocalStorage(username);
saveUsernameAndIdToLocalStorage(username, this.state.userId);
};
onUserIdChange = (userId: string) => {
this.setState({ userId });
saveUsernameAndIdToLocalStorage(this.state.username, userId);
};
render() {
+46 -19
View File
@@ -37,6 +37,29 @@ class Portal {
this.roomId = id;
this.roomKey = key;
this.initializeSocketListeners();
return socket;
}
close() {
if (!this.socket) {
return;
}
this.queueFileUpload.flush();
this.socket.close();
this.socket = null;
this.roomId = null;
this.roomKey = null;
this.socketInitialized = false;
this.broadcastedElementVersions = new Map();
}
initializeSocketListeners() {
if (!this.socket) {
return;
}
// Initialize socket listeners
this.socket.on("init-room", () => {
if (this.socket) {
@@ -54,21 +77,6 @@ class Portal {
this.socket.on("room-user-change", (clients: string[]) => {
this.collab.setCollaborators(clients);
});
return socket;
}
close() {
if (!this.socket) {
return;
}
this.queueFileUpload.flush();
this.socket.close();
this.socket = null;
this.roomId = null;
this.roomKey = null;
this.socketInitialized = false;
this.broadcastedElementVersions = new Map();
}
isOpen() {
@@ -181,13 +189,14 @@ class Portal {
};
broadcastIdleChange = (userState: UserIdleState) => {
if (this.socket?.id) {
if (this.socket) {
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
type: "IDLE_STATUS",
payload: {
socketId: this.socket.id,
userState,
username: this.collab.state.username,
userId: this.collab.state.userId,
socketId: this.socket.id,
},
};
return this._broadcastSocketData(
@@ -201,16 +210,17 @@ class Portal {
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
}) => {
if (this.socket?.id) {
if (this.socket) {
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
type: "MOUSE_LOCATION",
payload: {
socketId: this.socket.id,
pointer: payload.pointer,
button: payload.button || "up",
selectedElementIds:
this.collab.excalidrawAPI.getAppState().selectedElementIds,
username: this.collab.state.username,
userId: this.collab.state.userId,
socketId: this.socket.id,
},
};
return this._broadcastSocketData(
@@ -219,6 +229,23 @@ class Portal {
);
}
};
brodcastUserJoinedRoom = (payload: {
username: string;
userId: string;
socketId: string;
}) => {
if (this.socket) {
const data: SocketUpdateDataSource["USER_JOINED"] = {
type: "USER_JOINED",
payload,
};
return this._broadcastSocketData(
data as SocketUpdateData,
false, // volatile
);
}
};
}
export default Portal;
@@ -16,7 +16,7 @@ import { MIME_TYPES } from "../../constants";
import { trackEvent } from "../../analytics";
import { getFrame } from "../../utils";
const exportToExcalidrawPlus = async (
export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
+26 -8
View File
@@ -106,19 +106,29 @@ export type SocketUpdateDataSource = {
MOUSE_LOCATION: {
type: "MOUSE_LOCATION";
payload: {
socketId: string;
pointer: { x: number; y: number };
button: "down" | "up";
selectedElementIds: AppState["selectedElementIds"];
username: string;
userId: string;
socketId: string;
};
};
IDLE_STATUS: {
type: "IDLE_STATUS";
payload: {
socketId: string;
userState: UserIdleState;
username: string;
userId: string;
socketId: string;
};
};
USER_JOINED: {
type: "USER_JOINED";
payload: {
username: string;
userId: string;
socketId: string;
};
};
};
@@ -282,11 +292,15 @@ export const loadScene = async (
};
};
type ExportToBackendResult =
| { url: null; errorMessage: string }
| { url: string; errorMessage: null };
export const exportToBackend = async (
elements: readonly ExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
) => {
): Promise<ExportToBackendResult> => {
const encryptionKey = await generateEncryptionKey("string");
const payload = await compressData(
@@ -327,14 +341,18 @@ export const exportToBackend = async (
files: filesToUpload,
});
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
return { url: urlString, errorMessage: null };
} else if (json.error_class === "RequestTooLargeError") {
window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
} else {
window.alert(t("alerts.couldNotCreateShareableLink"));
return {
url: null,
errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
};
}
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
} catch (error: any) {
console.error(error);
window.alert(t("alerts.couldNotCreateShareableLink"));
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
}
};
+10 -4
View File
@@ -8,11 +8,14 @@ import { clearElementsForLocalStorage } from "../../element";
import { STORAGE_KEYS } from "../app_constants";
import { ImportedDataState } from "../../data/types";
export const saveUsernameToLocalStorage = (username: string) => {
export const saveUsernameAndIdToLocalStorage = (
username: string,
userId: string,
) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_COLLAB,
JSON.stringify({ username }),
JSON.stringify({ username, userId }),
);
} catch (error: any) {
// Unable to access window.localStorage
@@ -20,11 +23,14 @@ export const saveUsernameToLocalStorage = (username: string) => {
}
};
export const importUsernameFromLocalStorage = (): string | null => {
export const importUsernameAndIdFromLocalStorage = (): {
username: string;
userId: string;
} | null => {
try {
const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
if (data) {
return JSON.parse(data).username;
return JSON.parse(data);
}
} catch (error: any) {
// Unable to access localStorage
+135
View File
@@ -0,0 +1,135 @@
declare global {
interface Window {
debug: typeof Debug;
}
}
const lessPrecise = (num: number, precision = 5) =>
parseFloat(num.toPrecision(precision));
const getAvgFrameTime = (times: number[]) =>
lessPrecise(times.reduce((a, b) => a + b) / times.length);
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
export class Debug {
public static DEBUG_LOG_TIMES = true;
private static TIMES_AGGR: Record<string, { t: number; times: number[] }> =
{};
private static TIMES_AVG: Record<
string,
{ t: number; times: number[]; avg: number | null }
> = {};
private static LAST_DEBUG_LOG_CALL = 0;
private static DEBUG_LOG_INTERVAL_ID: null | number = null;
private static setupInterval = () => {
if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
console.info("%c(starting perf recording)", "color: lime");
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
}
Debug.LAST_DEBUG_LOG_CALL = Date.now();
};
private static debugLogger = () => {
if (
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
Debug.DEBUG_LOG_INTERVAL_ID !== null
) {
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
Debug.DEBUG_LOG_INTERVAL_ID = null;
for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
if (avg != null) {
console.info(
`%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
"color: blue",
);
}
}
console.info("%c(stopping perf recording)", "color: red");
Debug.TIMES_AGGR = {};
Debug.TIMES_AVG = {};
return;
}
if (Debug.DEBUG_LOG_TIMES) {
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
if (times.length) {
console.info(
name,
lessPrecise(times.reduce((a, b) => a + b)),
times.sort((a, b) => a - b).map((x) => lessPrecise(x)),
);
Debug.TIMES_AGGR[name] = { t, times: [] };
}
}
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
if (times.length) {
const avgFrameTime = getAvgFrameTime(times);
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
Debug.TIMES_AVG[name] = {
t,
times: [],
avg:
avg != null ? getAvgFrameTime([avg, avgFrameTime]) : avgFrameTime,
};
}
}
}
};
public static logTime = (time?: number, name = "default") => {
Debug.setupInterval();
const now = performance.now();
const { t, times } = (Debug.TIMES_AGGR[name] = Debug.TIMES_AGGR[name] || {
t: 0,
times: [],
});
if (t) {
times.push(time != null ? time : now - t);
}
Debug.TIMES_AGGR[name].t = now;
};
public static logTimeAverage = (time?: number, name = "default") => {
Debug.setupInterval();
const now = performance.now();
const { t, times } = (Debug.TIMES_AVG[name] = Debug.TIMES_AVG[name] || {
t: 0,
times: [],
});
if (t) {
times.push(time != null ? time : now - t);
}
Debug.TIMES_AVG[name].t = now;
};
private static logWrapper =
(type: "logTime" | "logTimeAverage") =>
<T extends any[], R>(fn: (...args: T) => R, name = "default") => {
return (...args: T) => {
const t0 = performance.now();
const ret = fn(...args);
Debug.logTime(performance.now() - t0, name);
return ret;
};
};
public static logTimeWrap = Debug.logWrapper("logTime");
public static logTimeAverageWrap = Debug.logWrapper("logTimeAverage");
public static perfWrap = <T extends any[], R>(
fn: (...args: T) => R,
name = "default",
) => {
return (...args: T) => {
// eslint-disable-next-line no-console
console.time(name);
const ret = fn(...args);
// eslint-disable-next-line no-console
console.timeEnd(name);
return ret;
};
};
}
window.debug = Debug;
+70 -11
View File
@@ -65,11 +65,14 @@ import {
import {
getLibraryItemsFromStorage,
importFromLocalStorage,
importUsernameFromLocalStorage,
importUsernameAndIdFromLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks";
@@ -88,6 +91,10 @@ import { appJotaiStore } from "./app-jotai";
import "./index.scss";
import { ResolutionType } from "../utility-types";
import { ShareableLinkDialog } from "../components/ShareableLinkDialog";
import { openConfirmModal } from "../components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "../components/OverwriteConfirm/OverwriteConfirm";
import Trans from "../components/Trans";
polyfill();
@@ -98,6 +105,19 @@ languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"),
description: (
<Trans
i18nKey="overwriteConfirm.modal.shareableLink.description"
bold={(text) => <strong>{text}</strong>}
br={() => <br />}
/>
),
actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
color: "danger",
} as const;
const initializeScene = async (opts: {
collabAPI: CollabAPI | null;
excalidrawAPI: ExcalidrawImperativeAPI;
@@ -129,7 +149,7 @@ const initializeScene = async (opts: {
// 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"))
(await openConfirmModal(shareableLinkConfirmDialog))
) {
if (jsonBackendMatch) {
scene = await loadScene(
@@ -168,7 +188,7 @@ const initializeScene = async (opts: {
const data = await loadFromBlob(await request.blob(), null, null);
if (
!scene.elements.length ||
window.confirm(t("alerts.loadSceneOverridePrompt"))
(await openConfirmModal(shareableLinkConfirmDialog))
) {
return { scene: data, isExternalScene };
}
@@ -391,7 +411,8 @@ const ExcalidrawWrapper = () => {
// don't sync if local state is newer or identical to browser state
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage();
const username = importUsernameFromLocalStorage();
const username =
importUsernameAndIdFromLocalStorage()?.username ?? "";
let langCode = languageDetector.detect() || defaultLang.code;
if (Array.isArray(langCode)) {
langCode = langCode[0];
@@ -554,6 +575,10 @@ const ExcalidrawWrapper = () => {
}
};
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
null,
);
const onExportToBackend = async (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>,
@@ -565,7 +590,7 @@ const ExcalidrawWrapper = () => {
}
if (canvas) {
try {
await exportToBackend(
const { url, errorMessage } = await exportToBackend(
exportedElements,
{
...appState,
@@ -575,6 +600,14 @@ const ExcalidrawWrapper = () => {
},
files,
);
if (errorMessage) {
setErrorMessage(errorMessage);
}
if (url) {
setLatestShareableLink(url);
}
} catch (error: any) {
if (error.name !== "AbortError") {
const { width, height } = canvas;
@@ -674,21 +707,47 @@ const ExcalidrawWrapper = () => {
setCollabDialogShown={setCollabDialogShown}
isCollabEnabled={!isCollabDisabled}
/>
<OverwriteConfirmDialog>
<OverwriteConfirmDialog.Actions.ExportToImage />
<OverwriteConfirmDialog.Actions.SaveToDisk />
{excalidrawAPI && (
<OverwriteConfirmDialog.Action
title={t("overwriteConfirm.action.excalidrawPlus.title")}
actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
onClick={() => {
exportToExcalidrawPlus(
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(),
);
}}
>
{t("overwriteConfirm.action.excalidrawPlus.description")}
</OverwriteConfirmDialog.Action>
)}
</OverwriteConfirmDialog>
<AppFooter />
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")}
</div>
)}
{latestShareableLink && (
<ShareableLinkDialog
link={latestShareableLink}
onCloseRequest={() => setLatestShareableLink(null)}
setErrorMessage={setErrorMessage}
/>
)}
{excalidrawAPI && !isCollabDisabled && (
<Collab excalidrawAPI={excalidrawAPI} />
)}
{errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}
</ErrorDialog>
)}
</Excalidraw>
{errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}
</ErrorDialog>
)}
</div>
);
};
+5 -2
View File
@@ -304,7 +304,7 @@ export const groupsAreCompletelyOutOfFrame = (
/**
* Returns a map of frameId to frame elements. Includes empty frames.
*/
export const groupByFrames = (elements: ExcalidrawElementsIncludingDeleted) => {
export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
const frameElementsMap = new Map<
ExcalidrawElement["id"],
ExcalidrawElement[]
@@ -591,6 +591,7 @@ export const updateFrameMembershipOfSelectedElements = (
elementsToFilter.forEach((element) => {
if (
element.frameId &&
!isFrameElement(element) &&
!isElementInFrame(element, allElements, appState)
) {
@@ -598,7 +599,9 @@ export const updateFrameMembershipOfSelectedElements = (
}
});
return removeElementsFromFrame(allElements, [...elementsToRemove], appState);
return elementsToRemove.size > 0
? removeElementsFromFrame(allElements, [...elementsToRemove], appState)
: allElements;
};
/**
+15 -1
View File
@@ -2,6 +2,7 @@ import { GroupId, ExcalidrawElement, NonDeleted } from "./element/types";
import { AppState } from "./types";
import { getSelectedElements } from "./scene";
import { getBoundTextElement } from "./element/textElement";
import { makeNextSelectedElementIds } from "./scene/selection";
export const selectGroup = (
groupId: GroupId,
@@ -67,13 +68,21 @@ export const getSelectedGroupIds = (appState: AppState): GroupId[] =>
export const selectGroupsForSelectedElements = (
appState: AppState,
elements: readonly NonDeleted<ExcalidrawElement>[],
prevAppState: AppState,
): AppState => {
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
const selectedElements = getSelectedElements(elements, appState);
if (!selectedElements.length) {
return { ...nextAppState, editingGroupId: null };
return {
...nextAppState,
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds(
nextAppState.selectedElementIds,
prevAppState,
),
};
}
for (const selectedElement of selectedElements) {
@@ -91,6 +100,11 @@ export const selectGroupsForSelectedElements = (
}
}
nextAppState.selectedElementIds = makeNextSelectedElementIds(
nextAppState.selectedElementIds,
prevAppState,
);
return nextAppState;
};
+1
View File
@@ -10,6 +10,7 @@ export const CODES = {
BRACKET_LEFT: "BracketLeft",
ONE: "Digit1",
TWO: "Digit2",
THREE: "Digit3",
NINE: "Digit9",
QUOTE: "Quote",
ZERO: "Digit0",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "نُشر",
"sidebarLock": "إبقاء الشريط الجانبي مفتوح",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "وضع القلم - امنع اللمس",
"link": "إضافة/تحديث الرابط للشكل المحدد",
"eraser": "ممحاة",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "إجراءات اللوحة",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Действия по платното",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "প্রকাশিত",
"sidebarLock": "লক",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "একটি নির্বাচিত আকৃতির জন্য লিঙ্ক যোগ বা আপডেট করুন",
"eraser": "ঝাড়ন",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "ক্যানভাস কার্যকলাপ",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicat",
"sidebarLock": "Manté la barra lateral oberta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Mode de llapis - evita tocar",
"link": "Afegeix / actualitza l'enllaç per a la forma seleccionada",
"eraser": "Esborrador",
"hand": "Mà (eina de desplaçament)"
"frame": "",
"hand": "Mà (eina de desplaçament)",
"extraTools": ""
},
"headings": {
"canvasActions": "Accions del llenç",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Zveřejněno",
"sidebarLock": "Ponechat postranní panel otevřený",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "Vyberte barvu z plátna"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Režim Pera - zabránit dotyku",
"link": "Přidat/aktualizovat odkaz pro vybraný tvar",
"eraser": "Guma",
"hand": "Ruka (nástroj pro posouvání)"
"frame": "",
"hand": "Ruka (nástroj pro posouvání)",
"extraTools": ""
},
"headings": {
"canvasActions": "Akce plátna",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Veröffentlicht",
"sidebarLock": "Seitenleiste offen lassen",
"selectAllElementsInFrame": "Alle Elemente im Rahmen auswählen",
"removeAllElementsFromFrame": "Alle Elemente aus dem Rahmen entfernen",
"eyeDropper": "Farbe von der Zeichenfläche auswählen"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Stift-Modus - Berührung verhindern",
"link": "Link für ausgewählte Form hinzufügen / aktualisieren",
"eraser": "Radierer",
"hand": "Hand (Schwenkwerkzeug)"
"frame": "Rahmenwerkzeug",
"hand": "Hand (Schwenkwerkzeug)",
"extraTools": "Weitere Werkzeuge"
},
"headings": {
"canvasActions": "Aktionen für Zeichenfläche",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Δημοσιευμένο",
"sidebarLock": "Κρατήστε την πλαϊνή μπάρα ανοιχτή",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Λειτουργία μολυβιού - αποτροπή αφής",
"link": "Προσθήκη/ Ενημέρωση συνδέσμου για ένα επιλεγμένο σχήμα",
"eraser": "Γόμα",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Ενέργειες καμβά",
+33 -1
View File
@@ -411,7 +411,8 @@
"fileSavedToFilename": "Saved to {filename}",
"canvas": "canvas",
"selection": "selection",
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor"
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor",
"reconnectRoomServer": "Reconnecting to server"
},
"colors": {
"transparent": "Transparent",
@@ -449,5 +450,36 @@
"shades": "Shades",
"hexCode": "Hex code",
"noShades": "No shades available for this color"
},
"overwriteConfirm": {
"action": {
"exportToImage": {
"title": "Export as image",
"button": "Export as image",
"description": "Export the scene data as an image from which you can import later."
},
"saveToDisk": {
"title": "Save to disk",
"button": "Save to disk",
"description": "Export the scene data to a file from which you can import later."
},
"excalidrawPlus": {
"title": "Excalidraw+",
"button": "Export to Excalidraw+",
"description": "Save the scene to your Excalidraw+ workspace."
}
},
"modal": {
"loadFromFile": {
"title": "Load from file",
"button": "Load from file",
"description": "Loading from a file will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first using one of the options below."
},
"shareableLink": {
"title": "Load from link",
"button": "Replace my content",
"description": "Loading external drawing will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first by using one of the options below."
}
}
}
}
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicado",
"sidebarLock": "Mantener barra lateral abierta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Modo Lápiz - previene toque",
"link": "Añadir/Actualizar enlace para una forma seleccionada",
"eraser": "Borrar",
"hand": "Mano (herramienta de panoramización)"
"frame": "",
"hand": "Mano (herramienta de panoramización)",
"extraTools": ""
},
"headings": {
"canvasActions": "Acciones del lienzo",
+39 -35
View File
@@ -124,7 +124,9 @@
},
"statusPublished": "Argitaratua",
"sidebarLock": "Mantendu alboko barra irekita",
"eyeDropper": ""
"selectAllElementsInFrame": "Hautatu markoko elementu guztiak",
"removeAllElementsFromFrame": "Kendu markoko elementu guztiak",
"eyeDropper": "Aukeratu kolorea oihaletik"
},
"library": {
"noItems": "Oraindik ez da elementurik gehitu...",
@@ -221,7 +223,9 @@
"penMode": "Luma modua - ukipena saihestu",
"link": "Gehitu / Eguneratu esteka hautatutako forma baterako",
"eraser": "Borragoma",
"hand": "Eskua (panoratze tresna)"
"frame": "Marko tresna",
"hand": "Eskua (panoratze tresna)",
"extraTools": "Tresna gehiago"
},
"headings": {
"canvasActions": "Canvas ekintzak",
@@ -356,27 +360,27 @@
"removeItemsFromLib": "Kendu hautatutako elementuak liburutegitik"
},
"imageExportDialog": {
"header": "",
"header": "Esportatu irudia",
"label": {
"withBackground": "",
"onlySelected": "",
"darkMode": "",
"embedScene": "",
"scale": "",
"padding": ""
"withBackground": "Atzeko planoa",
"onlySelected": "Hautapena soilik",
"darkMode": "Modu iluna",
"embedScene": "Txertatu eszena",
"scale": "Eskala",
"padding": "Betegarria"
},
"tooltip": {
"embedScene": ""
"embedScene": "Eszenaren datuak esportatutako PNG/SVG fitxategian gordeko dira, eszena bertatik berrezartzeko.\nEsportatutako fitxategien tamaina handituko da."
},
"title": {
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
"exportToPng": "Esportatu PNG gisa",
"exportToSvg": "Esportatu SVG gisa",
"copyPngToClipboard": "Kopiatu PNG arbelera"
},
"button": {
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
"exportToPng": "PNG",
"exportToSvg": "SVG",
"copyPngToClipboard": "Kopiatu arbelean"
}
},
"encrypted": {
@@ -411,20 +415,20 @@
},
"colors": {
"transparent": "Gardena",
"black": "",
"white": "",
"red": "",
"pink": "",
"grape": "",
"violet": "",
"gray": "",
"blue": "",
"cyan": "",
"teal": "",
"green": "",
"yellow": "",
"orange": "",
"bronze": ""
"black": "Beltza",
"white": "Zuria",
"red": "Gorria",
"pink": "Arrosa",
"grape": "Mahats kolorea",
"violet": "Bioleta",
"gray": "Grisa",
"blue": "Urdina",
"cyan": "Ziana",
"teal": "Berde urdinxka",
"green": "Berdea",
"yellow": "Horia",
"orange": "Laranja",
"bronze": "Brontzea"
},
"welcomeScreen": {
"app": {
@@ -440,10 +444,10 @@
}
},
"colorPicker": {
"mostUsedCustomColors": "",
"colors": "",
"shades": "",
"hexCode": "",
"noShades": ""
"mostUsedCustomColors": "Gehien erabilitako kolore pertsonalizatuak",
"colors": "Koloreak",
"shades": "Ñabardurak",
"hexCode": "Hez kodea",
"noShades": "Kolore honetarako ez dago ñabardurarik eskuragarri"
}
}
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "منتشر شده",
"sidebarLock": "باز نگه داشتن سایدبار",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "حالت قلم - جلوگیری از تماس",
"link": "افزودن/به‌روزرسانی پیوند برای شکل انتخابی",
"eraser": "پاک کن",
"hand": "دست (ابزار پانینگ)"
"frame": "",
"hand": "دست (ابزار پانینگ)",
"extraTools": ""
},
"headings": {
"canvasActions": "عملیات روی بوم",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Julkaistu",
"sidebarLock": "Pidä sivupalkki avoinna",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Kynätila - estä kosketus",
"link": "Lisää/päivitä linkki valitulle muodolle",
"eraser": "Poistotyökalu",
"hand": "Käsi (panning-työkalu)"
"frame": "",
"hand": "Käsi (panning-työkalu)",
"extraTools": ""
},
"headings": {
"canvasActions": "Piirtoalueen toiminnot",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publié",
"sidebarLock": "Maintenir la barre latérale ouverte",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Mode stylo - évite le toucher",
"link": "Ajouter/mettre à jour le lien pour une forme sélectionnée",
"eraser": "Gomme",
"hand": "Mains (outil de déplacement de la vue)"
"frame": "",
"hand": "Mains (outil de déplacement de la vue)",
"extraTools": ""
},
"headings": {
"canvasActions": "Actions du canevas",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicado",
"sidebarLock": "Manter a barra lateral aberta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Modo lapis - evitar o contacto",
"link": "Engadir/ Actualizar ligazón para a forma seleccionada",
"eraser": "Goma de borrar",
"hand": "Man (ferramenta de desprazamento)"
"frame": "",
"hand": "Man (ferramenta de desprazamento)",
"extraTools": ""
},
"headings": {
"canvasActions": "Accións do lenzo",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "פורסם",
"sidebarLock": "שמור את סרגל הצד פתוח",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "מצב עט - מנע נגיעה",
"link": "הוספה/עדכון קישור של הצורה שנבחרה",
"eraser": "מחק",
"hand": "יד (כלי הזזה)"
"frame": "",
"hand": "יד (כלי הזזה)",
"extraTools": ""
},
"headings": {
"canvasActions": "פעולות קנבאס",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "प्रकाशित",
"sidebarLock": "साइडबार खुला रखे.",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "चित्रफलक से रंग चुने"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "पेन का मोड - स्पर्श टाले",
"link": "",
"eraser": "रबड़",
"hand": "हाथ ( खिसकाने का औज़ार)"
"frame": "",
"hand": "हाथ ( खिसकाने का औज़ार)",
"extraTools": ""
},
"headings": {
"canvasActions": "कैनवास क्रिया",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "Hivatkozás hozzáadása/frissítése a kiválasztott alakzathoz",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Vászon műveletek",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Telah terbit",
"sidebarLock": "Biarkan sidebar tetap terbuka",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Mode pena - mencegah sentuhan",
"link": "Tambah/Perbarui tautan untuk bentuk yang dipilih",
"eraser": "Penghapus",
"hand": "Tangan (alat panning)"
"frame": "",
"hand": "Tangan (alat panning)",
"extraTools": ""
},
"headings": {
"canvasActions": "Opsi Kanvas",
+7 -3
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Pubblicato",
"sidebarLock": "Mantieni aperta la barra laterale",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Modalità penna - previene il tocco",
"link": "Aggiungi/ aggiorna il link per una forma selezionata",
"eraser": "Gomma",
"hand": "Mano (strumento di panoramica)"
"frame": "",
"hand": "Mano (strumento di panoramica)",
"extraTools": "Altri strumenti"
},
"headings": {
"canvasActions": "Azioni sulla Tela",
@@ -361,12 +365,12 @@
"withBackground": "Sfondo",
"onlySelected": "Solo selezionato",
"darkMode": "Tema scuro",
"embedScene": "",
"embedScene": "Includi scena",
"scale": "Scala",
"padding": ""
},
"tooltip": {
"embedScene": ""
"embedScene": "I dati della scena saranno salvati nel file PNG/SVG esportato in modo che la scena possa essere ripristinata da esso.\nQuesto aumenterà la dimensione del file esportato."
},
"title": {
"exportToPng": "Esporta come PNG",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "公開済み",
"sidebarLock": "サイドバーを開いたままにする",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "ペンモード - タッチ防止",
"link": "選択した図形のリンクを追加/更新",
"eraser": "消しゴム",
"hand": "手 (パンニングツール)"
"frame": "",
"hand": "手 (パンニングツール)",
"extraTools": ""
},
"headings": {
"canvasActions": "キャンバス操作",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "Óshirgish",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Yeffeɣ-d",
"sidebarLock": "Eǧǧ afeggag n yidis yeldi",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Askar n yimru - gdel tanalit",
"link": "Rnu/leqqem aseɣwen i talɣa yettwafernen",
"eraser": "Sfeḍ",
"hand": "Afus (afecku n usmutti n tmuɣli)"
"frame": "",
"hand": "Afus (afecku n usmutti n tmuɣli)",
"extraTools": ""
},
"headings": {
"canvasActions": "Tigawin n teɣzut n usuneɣ",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "ត្រូវបានបោះពុម្ពផ្សាយ",
"sidebarLock": "ទុករបារចំហៀងបើក",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "របៀបប៊ិច - ជៀសវាងការប៉ះ",
"link": "បន្ថែម/ធ្វើបច្ចុប្បន្នភាពតំណភ្ជាប់សម្រាប់រូបរាងដែលបានជ្រើសរើស",
"eraser": "ជ័រលុប",
"hand": "ដៃ (panning tool)"
"frame": "",
"hand": "ដៃ (panning tool)",
"extraTools": ""
},
"headings": {
"canvasActions": "សកម្មភាពបាវ",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "게시됨",
"sidebarLock": "사이드바 유지",
"selectAllElementsInFrame": "프레임의 모든 요소 선택",
"removeAllElementsFromFrame": "프레임의 모든 요소 삭제",
"eyeDropper": "캔버스에서 색상 고르기"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "펜 모드 - 터치 방지",
"link": "선택한 도형에 대해서 링크를 추가/업데이트",
"eraser": "지우개",
"hand": "손 (패닝 도구)"
"frame": "프레임 도구",
"hand": "손 (패닝 도구)",
"extraTools": "다른 도구"
},
"headings": {
"canvasActions": "캔버스 동작",
+40 -36
View File
@@ -71,7 +71,7 @@
"language": "زمان",
"liveCollaboration": "هاوکاریکردنی زیندو...",
"duplicateSelection": "لەبەرگرتنەوە",
"untitled": "بێ-ناو",
"untitled": "بێ ناونیشان",
"name": "ناو",
"yourName": "ناوەکەت",
"madeWithExcalidraw": "دروستکراوە بە Excalidraw",
@@ -124,7 +124,9 @@
},
"statusPublished": "بڵاوکراوەتەوە",
"sidebarLock": "هێشتنەوەی شریتی لا بە کراوەیی",
"eyeDropper": ""
"selectAllElementsInFrame": "هەموو توخمەکانی ناو چوارچێوەکە دیاری بکە",
"removeAllElementsFromFrame": "هەموو توخمەکانی ناو چوارچێوەکە لابەرە",
"eyeDropper": "ڕەنگێک لەسەر تابلۆکە هەڵبژێرە"
},
"library": {
"noItems": "هێشتا هیچ بڕگەیەک زیاد نەکراوە...",
@@ -221,7 +223,9 @@
"penMode": "شێوازی قەڵەم - دەست لێدان ڕابگرە",
"link": "زیادکردن/ نوێکردنەوەی لینک بۆ شێوەی دیاریکراو",
"eraser": "سڕەر",
"hand": "دەست (ئامرازی پانکردن)"
"frame": "ئامرازی چوارچێوە",
"hand": "دەست (ئامرازی پانکردن)",
"extraTools": "ئامرازی زیاتر"
},
"headings": {
"canvasActions": "کردارەکانی تابلۆ",
@@ -356,27 +360,27 @@
"removeItemsFromLib": "لابردنی ئایتمە دیاریکراوەکان لە کتێبخانە"
},
"imageExportDialog": {
"header": "",
"header": "وێنە هەناردە بکە",
"label": {
"withBackground": "",
"onlySelected": "",
"darkMode": "",
"embedScene": "",
"scale": "",
"padding": ""
"withBackground": "پاشبنەما",
"onlySelected": "تەنها دیاریکراوەکان",
"darkMode": "دۆخی تاریک",
"embedScene": "دیمەنەکە بەکاربهێنەرەوە",
"scale": "قەبارە",
"padding": "بۆشایی"
},
"tooltip": {
"embedScene": ""
"embedScene": "داتاکانی دیمەنەکە لە فایلە هەناردەکراوەکەی PNG/SVG هەڵدەگیرێن بۆ ئەوەی دیمەنەکە لێیەوە بگەڕێتەوە.\nقەبارەی پەڕگەی هەناردەکراو زیاد دەکات."
},
"title": {
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
"exportToPng": "هەناردە بکە وەک PNG",
"exportToSvg": "هەناردە بکە وەک SVG",
"copyPngToClipboard": "لەبەربگرەوە بۆ سەر تەختەنوس"
},
"button": {
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
"exportToPng": "PNG",
"exportToSvg": "SVG",
"copyPngToClipboard": "له‌به‌ری بگره‌وه‌ بۆ ته‌خته‌نووس"
}
},
"encrypted": {
@@ -411,20 +415,20 @@
},
"colors": {
"transparent": "ڕوون",
"black": "",
"white": "",
"red": "",
"pink": "",
"grape": "",
"violet": "",
"gray": "",
"blue": "",
"cyan": "",
"teal": "",
"green": "",
"yellow": "",
"orange": "",
"bronze": ""
"black": "ڕەش",
"white": "سپی",
"red": "سور",
"pink": "پەمەیی",
"grape": "مێوژی",
"violet": "مۆری کاڵ",
"gray": "خۆڵەمێشی",
"blue": "شین",
"cyan": "شینی ئاسمانی",
"teal": "شەدری",
"green": "سه‌وز",
"yellow": "زەرد",
"orange": "پرتەقاڵی",
"bronze": "برۆنزی"
},
"welcomeScreen": {
"app": {
@@ -440,10 +444,10 @@
}
},
"colorPicker": {
"mostUsedCustomColors": "",
"colors": "",
"shades": "",
"hexCode": "",
"noShades": ""
"mostUsedCustomColors": "زۆرترین ڕەنگە باوە بەکارهاتووەکان",
"colors": "ڕەنگەکان",
"shades": "سێبەرەکان",
"hexCode": "کۆدی هێکس",
"noShades": "هیچ سێبەرێک بۆ ئەم ڕەنگە بەردەست نییە"
}
}
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Rašyklio režimas - neleisti prisilietimų",
"link": "Pridėti / Atnaujinti pasirinktos figūros nuorodą",
"eraser": "Trintukas",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Veiksmai su drobe",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicēts",
"sidebarLock": "Paturēt atvērtu sānjoslu",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Pildspalvas režīms novērst pieskaršanos",
"link": "Pievienot/rediģēt atlasītās figūras saiti",
"eraser": "Dzēšgumija",
"hand": "Roka (panoramēšanas rīks)"
"frame": "",
"hand": "Roka (panoramēšanas rīks)",
"extraTools": ""
},
"headings": {
"canvasActions": "Tāfeles darbības",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "प्रकाशित करा",
"sidebarLock": "साइडबार उघडं ठेवा",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "चित्रफलकातून रंग निवडा"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "पेन चा मोड - स्पर्श टाळा",
"link": "निवडलेल्या आकारासाठी दुवा जोडा/बदल करा",
"eraser": "खोड रबर",
"hand": "हात ( सरकवण्या चे उपकरण)"
"frame": "",
"hand": "हात ( सरकवण्या चे उपकरण)",
"extraTools": ""
},
"headings": {
"canvasActions": "पटल क्रिया",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "ကားချပ်လုပ်ဆောင်ချက်",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publisert",
"sidebarLock": "Holde sidemenyen åpen",
"selectAllElementsInFrame": "Velg alle elementene i rammen",
"removeAllElementsFromFrame": "Fjern alle elementer fra rammen",
"eyeDropper": "Velg farge fra lerretet"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Pennemodus - forhindre berøring",
"link": "Legg til / oppdater link for en valgt figur",
"eraser": "Viskelær",
"hand": "Hånd (panoreringsverktøy)"
"frame": "Rammeverktøy",
"hand": "Hånd (panoreringsverktøy)",
"extraTools": "Flere verktøy"
},
"headings": {
"canvasActions": "Handlinger: lerret",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Gepubliceerd",
"sidebarLock": "Zijbalk open houden",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Pen modus - Blokkeer aanraken",
"link": "Link toevoegen / bijwerken voor een geselecteerde vorm",
"eraser": "Gum",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Canvasacties",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "Legg til/ oppdater lenke til valt figur",
"eraser": "Viskelêr",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Handlingar: lerret",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicat",
"sidebarLock": "Gardar la barra laterala dobèrta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Mòde estilo - empachar lo contact",
"link": "Apondre/Actualizar lo ligam per una fòrma seleccionada",
"eraser": "Goma",
"hand": "Man (aisina de desplaçament de la vista)"
"frame": "",
"hand": "Man (aisina de desplaçament de la vista)",
"extraTools": ""
},
"headings": {
"canvasActions": "Accions del canabàs",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "ਪ੍ਰਕਾਸ਼ਤ ਹੈ",
"sidebarLock": "ਸਾਈਡਬਾਰ ਨੂੰ ਖੁੱਲ੍ਹਾ ਰੱਖੋ",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "ਪੈੱਨ ਮੋਡ - ਟੱਚ ਤੋਂ ਬਚਾਉਂਦਾ ਹੈ",
"link": "",
"eraser": "ਰਬੜ",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "ਕੈਨਵਸ ਦੀਆਂ ਕਾਰਵਾਈਆਂ",
+36 -36
View File
@@ -1,56 +1,56 @@
{
"ar-SA": 80,
"ar-SA": 79,
"az-AZ": 20,
"bg-BG": 54,
"bn-BD": 60,
"ca-ES": 88,
"cs-CZ": 100,
"bn-BD": 59,
"ca-ES": 87,
"cs-CZ": 98,
"da-DK": 33,
"de-DE": 100,
"el-GR": 93,
"el-GR": 92,
"en": 100,
"es-ES": 89,
"eu-ES": 90,
"fa-IR": 89,
"fi-FI": 88,
"fr-FR": 89,
"gl-ES": 89,
"he-IL": 87,
"hi-IN": 78,
"es-ES": 88,
"eu-ES": 100,
"fa-IR": 88,
"fi-FI": 87,
"fr-FR": 88,
"gl-ES": 88,
"he-IL": 86,
"hi-IN": 77,
"hu-HU": 76,
"id-ID": 95,
"id-ID": 94,
"it-IT": 98,
"ja-JP": 89,
"ja-JP": 88,
"kaa": 18,
"kab-KAB": 88,
"kab-KAB": 87,
"kk-KZ": 21,
"km-KH": 95,
"km-KH": 94,
"ko-KR": 100,
"ku-TR": 90,
"lt-LT": 56,
"lv-LV": 89,
"mr-IN": 96,
"my-MM": 41,
"ku-TR": 100,
"lt-LT": 55,
"lv-LV": 88,
"mr-IN": 95,
"my-MM": 40,
"nb-NO": 100,
"nl-NL": 83,
"nn-NO": 77,
"oc-FR": 87,
"pa-IN": 90,
"pl-PL": 100,
"pt-BR": 99,
"pt-PT": 95,
"ro-RO": 100,
"ru-RU": 100,
"nn-NO": 76,
"oc-FR": 86,
"pa-IN": 89,
"pl-PL": 98,
"pt-BR": 98,
"pt-PT": 94,
"ro-RO": 98,
"ru-RU": 98,
"si-LK": 9,
"sk-SK": 100,
"sk-SK": 98,
"sl-SI": 100,
"sv-SE": 100,
"ta-IN": 85,
"th-TH": 39,
"tr-TR": 87,
"uk-UA": 97,
"ta-IN": 84,
"th-TH": 38,
"tr-TR": 86,
"uk-UA": 96,
"vi-VN": 56,
"zh-CN": 100,
"zh-HK": 26,
"zh-HK": 25,
"zh-TW": 100
}
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Opublikowano",
"sidebarLock": "Panel boczny zawsze otwarty",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "Wybierz kolor z płótna"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Tryb pióra — zapobiegaj dotknięciom",
"link": "Dodaj/aktualizuj link dla wybranego kształtu",
"eraser": "Gumka",
"hand": "Ręka (narzędzie do przesuwania)"
"frame": "",
"hand": "Ręka (narzędzie do przesuwania)",
"extraTools": ""
},
"headings": {
"canvasActions": "Narzędzia",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicado",
"sidebarLock": "Manter barra lateral aberta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Modo caneta — impede o toque",
"link": "Adicionar/Atualizar link para uma forma selecionada",
"eraser": "Borracha",
"hand": "Mão (ferramenta de rolagem)"
"frame": "",
"hand": "Mão (ferramenta de rolagem)",
"extraTools": ""
},
"headings": {
"canvasActions": "Ações da tela",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicado",
"sidebarLock": "Manter a barra lateral aberta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Modo caneta - impedir toque",
"link": "Acrescentar/ Adicionar ligação para uma forma seleccionada",
"eraser": "Borracha",
"hand": "Mão (ferramenta de movimento da tela)"
"frame": "",
"hand": "Mão (ferramenta de movimento da tela)",
"extraTools": ""
},
"headings": {
"canvasActions": "Ações da área de desenho",

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