Compare commits

..

13 Commits

Author SHA1 Message Date
Daniel J. Geiger 5e828da559 Align. 2023-06-16 11:25:08 -05:00
Daniel J. Geiger 8336edb4a0 Merge remote-tracking branch 'origin/release' into feat-custom-actions 2023-06-16 11:11:58 -05:00
Daniel J. Geiger 27e2888347 Address remaining comments. 2023-01-30 09:05:08 -06:00
Daniel J. Geiger e192538267 Merge remote-tracking branch 'origin/master' into feat-custom-actions 2023-01-28 18:49:03 -06:00
Daniel J. Geiger 1d3652a96c Simplify: universal Action predicates instead of action-specific guards. 2023-01-27 14:21:35 -06:00
Daniel J. Geiger bb96f322c6 Simplify per review comments. 2023-01-26 19:31:09 -06:00
Daniel J. Geiger e6fb7e3016 Merge remote-tracking branch 'origin/master' into feat-custom-actions 2023-01-26 17:54:32 -06:00
Daniel J. Geiger e385066b4b Merge remote-tracking branch 'origin/master' into feat-custom-actions 2023-01-19 18:32:16 -06:00
Daniel J. Geiger a5bd54b86d Filter context menu items through isActionEnabled. 2023-01-08 20:00:46 -06:00
Daniel J. Geiger 01432813a6 Fix lint. 2023-01-06 17:10:53 -06:00
Daniel J. Geiger 6e3b575fa5 Fix/cleanup. 2023-01-06 16:55:31 -06:00
Daniel J. Geiger 333cc53797 Merge remote-tracking branch 'origin/master' into feat-custom-actions 2023-01-06 16:32:37 -06:00
Daniel J. Geiger 8e5d376b49 feat: Custom actions and shortcuts 2023-01-06 13:34:39 -06:00
142 changed files with 4734 additions and 3368 deletions
+5 -1
View File
@@ -20,10 +20,14 @@ 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
+10 -1
View File
@@ -11,5 +11,14 @@ 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,32 +306,30 @@ This is the history API. history.clear() will clear the history.
## scrollToContent
```tsx
(
target?: ExcalidrawElement | ExcalidrawElement[],
opts?:
| {
fitToContent?: boolean;
animate?: boolean;
duration?: number;
}
| {
fitToViewport?: boolean;
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
}
) => void
```
<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>
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 | [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%) |
| 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. |
| 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,11 +2,6 @@
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
-1
View File
@@ -19,7 +19,6 @@
]
},
"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",
+38 -33
View File
@@ -148,6 +148,33 @@
// 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>
@@ -200,39 +227,17 @@
<h1 class="visually-hidden">Excalidraw</h1>
</header>
<div id="root"></div>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
<!-- 100% privacy friendly analytics -->
<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 -->
<% } %>
<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>
</body>
</html>
+10 -5
View File
@@ -1,4 +1,6 @@
import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import { deepCopyElement } from "../element/newElement";
import { randomId } from "../random";
import { t } from "../i18n";
@@ -7,11 +9,14 @@ export const actionAddToLibrary = register({
name: "addToLibrary",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
if (selectedElements.some((element) => element.type === "image")) {
return {
commitToHistory: false,
+34 -32
View File
@@ -13,18 +13,19 @@ import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
const alignActionsPredicate = (
elements: readonly ExcalidrawElement[],
appState: AppState,
_: unknown,
app: AppClassProperties,
) => {
const selectedElements = app.scene.getSelectedElements(appState);
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return (
selectedElements.length > 1 &&
// TODO enable aligning frames when implemented properly
@@ -35,10 +36,12 @@ const alignActionsPredicate = (
const alignSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
alignment: Alignment,
) => {
const selectedElements = app.scene.getSelectedElements(appState);
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const updatedElements = alignElements(selectedElements, alignment);
@@ -47,7 +50,6 @@ const alignSelectedElements = (
return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
app,
);
};
@@ -55,10 +57,10 @@ export const actionAlignTop = register({
name: "alignTop",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
elements: alignSelectedElements(elements, appState, {
position: "start",
axis: "y",
}),
@@ -67,9 +69,9 @@ export const actionAlignTop = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!alignActionsPredicate(elements, appState)}
type="button"
icon={AlignTopIcon}
onClick={() => updateData(null)}
@@ -86,10 +88,10 @@ export const actionAlignBottom = register({
name: "alignBottom",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
elements: alignSelectedElements(elements, appState, {
position: "end",
axis: "y",
}),
@@ -98,9 +100,9 @@ export const actionAlignBottom = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!alignActionsPredicate(elements, appState)}
type="button"
icon={AlignBottomIcon}
onClick={() => updateData(null)}
@@ -117,10 +119,10 @@ export const actionAlignLeft = register({
name: "alignLeft",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
elements: alignSelectedElements(elements, appState, {
position: "start",
axis: "x",
}),
@@ -129,9 +131,9 @@ export const actionAlignLeft = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!alignActionsPredicate(elements, appState)}
type="button"
icon={AlignLeftIcon}
onClick={() => updateData(null)}
@@ -148,10 +150,10 @@ export const actionAlignRight = register({
name: "alignRight",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
elements: alignSelectedElements(elements, appState, {
position: "end",
axis: "x",
}),
@@ -160,9 +162,9 @@ export const actionAlignRight = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!alignActionsPredicate(elements, appState)}
type="button"
icon={AlignRightIcon}
onClick={() => updateData(null)}
@@ -179,19 +181,19 @@ export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
elements: alignSelectedElements(elements, appState, {
position: "center",
axis: "y",
}),
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!alignActionsPredicate(elements, appState)}
type="button"
icon={CenterVerticallyIcon}
onClick={() => updateData(null)}
@@ -206,19 +208,19 @@ export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
elements: alignSelectedElements(elements, appState, {
position: "center",
axis: "x",
}),
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!alignActionsPredicate(elements, appState)}
type="button"
icon={CenterHorizontallyIcon}
onClick={() => updateData(null)}
+24 -15
View File
@@ -4,7 +4,7 @@ import {
VERTICAL_ALIGN,
TEXT_ALIGN,
} from "../constants";
import { isTextElement, newElement } from "../element";
import { getNonDeletedElements, isTextElement, newElement } from "../element";
import { mutateElement } from "../element/mutateElement";
import {
computeBoundTextPosition,
@@ -29,8 +29,8 @@ import {
ExcalidrawTextContainer,
ExcalidrawTextElement,
} from "../element/types";
import { getSelectedElements } from "../scene";
import { AppState } from "../types";
import { Mutable } from "../utility-types";
import { getFontString } from "../utils";
import { register } from "./register";
@@ -38,13 +38,16 @@ export const actionUnbindText = register({
name: "unbindText",
contextItemLabel: "labels.unbindText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return selectedElements.some((element) => hasBoundTextElement(element));
},
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
@@ -89,8 +92,8 @@ export const actionBindText = register({
name: "bindText",
contextItemLabel: "labels.bindText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 2) {
const textElement =
@@ -113,8 +116,11 @@ export const actionBindText = register({
}
return false;
},
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
let textElement: ExcalidrawTextElement;
let container: ExcalidrawTextContainer;
@@ -194,15 +200,18 @@ export const actionWrapTextInContainer = register({
name: "wrapTextInContainer",
contextItemLabel: "labels.createContainerFromText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
const areTextElements = selectedElements.every((el) => isTextElement(el));
return selectedElements.length > 0 && areTextElements;
},
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
const containerIds: AppState["selectedElementIds"] = {};
for (const textElement of selectedElements) {
if (isTextElement(textElement)) {
+36 -96
View File
@@ -6,7 +6,7 @@ import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { getNormalizedZoom } from "../scene";
import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
@@ -20,6 +20,7 @@ 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({
@@ -225,93 +226,52 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue;
};
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));
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,
}),
};
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,
scrollX,
scrollY,
zoom: { value: newZoomValue },
...centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: newZoom,
}),
zoom: newZoom,
},
commitToHistory: false,
};
};
// 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",
export const actionZoomToSelected = register({
name: "zoomToSelection",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return zoomToFit({
targetElements: selectedElements.length ? selectedElements : elements,
appState,
fitToViewport: false,
});
},
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
// TBD on how proceed
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
keyTest: (event) =>
event.code === CODES.TWO &&
event.shiftKey &&
@@ -319,31 +279,11 @@ export const actionZoomToFitSelectionInViewport = register({
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFitSelection = register({
name: "zoomToFitSelection",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(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) =>
zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
keyTest: (event) =>
event.code === CODES.ONE &&
event.shiftKey &&
+30 -24
View File
@@ -7,6 +7,7 @@ import {
probablySupportsClipboardWriteText,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { getSelectedElements } from "../scene/selection";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements, isTextElement } from "../element";
import { t } from "../i18n";
@@ -15,8 +16,7 @@ export const actionCopy = register({
name: "copy",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
const elementsToCopy = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
@@ -75,11 +75,14 @@ export const actionCopyAsSvg = register({
commitToHistory: false,
};
}
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
try {
await exportCanvas(
"clipboard-svg",
@@ -119,11 +122,14 @@ export const actionCopyAsPng = register({
commitToHistory: false,
};
}
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
try {
await exportCanvas(
"clipboard",
@@ -171,11 +177,14 @@ export const actionCopyAsPng = register({
export const copyText = register({
name: "copyText",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
);
const text = selectedElements
.reduce((acc: string[], element) => {
@@ -190,15 +199,12 @@ export const copyText = register({
commitToHistory: false,
};
},
predicate: (elements, appState, _, app) => {
predicate: (elements, appState) => {
return (
probablySupportsClipboardWriteText &&
app.scene
.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})
.some(isTextElement)
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).some(isTextElement)
);
},
contextItemLabel: "labels.copyText",
+22 -15
View File
@@ -9,13 +9,19 @@ import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return (
selectedElements.length > 1 &&
// TODO enable distributing frames when implemented properly
@@ -26,10 +32,12 @@ const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
const distributeSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
distribution: Distribution,
) => {
const selectedElements = app.scene.getSelectedElements(appState);
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const updatedElements = distributeElements(selectedElements, distribution);
@@ -38,17 +46,16 @@ const distributeSelectedElements = (
return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
app,
);
};
export const distributeHorizontally = register({
name: "distributeHorizontally",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: distributeSelectedElements(elements, appState, app, {
elements: distributeSelectedElements(elements, appState, {
space: "between",
axis: "x",
}),
@@ -57,9 +64,9 @@ export const distributeHorizontally = register({
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(appState, app)}
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={DistributeHorizontallyIcon}
onClick={() => updateData(null)}
@@ -75,10 +82,10 @@ export const distributeHorizontally = register({
export const distributeVertically = register({
name: "distributeVertically",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: distributeSelectedElements(elements, appState, app, {
elements: distributeSelectedElements(elements, appState, {
space: "between",
axis: "y",
}),
@@ -87,9 +94,9 @@ export const distributeVertically = register({
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(appState, app)}
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={DistributeVerticallyIcon}
onClick={() => updateData(null)}
-2
View File
@@ -274,8 +274,6 @@ const duplicateElements = (
),
},
getNonDeletedElements(finalElements),
appState,
null,
),
};
};
+9 -11
View File
@@ -1,6 +1,7 @@
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { arrayToMap } from "../utils";
import { register } from "./register";
@@ -10,15 +11,14 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
export const actionToggleElementLock = register({
name: "toggleElementLock",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return !selectedElements.some(
(element) => element.locked && element.frameId,
);
},
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
@@ -46,9 +46,8 @@ export const actionToggleElementLock = register({
commitToHistory: true,
};
},
contextItemLabel: (elements, appState, app) => {
const selected = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
contextItemLabel: (elements, appState) => {
const selected = getSelectedElements(elements, appState, {
includeBoundTextElement: false,
});
if (selected.length === 1 && selected[0].type !== "frame") {
@@ -61,13 +60,12 @@ export const actionToggleElementLock = register({
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
},
keyTest: (event, appState, elements, app) => {
keyTest: (event, appState, elements) => {
return (
event.key.toLocaleLowerCase() === KEYS.L &&
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
getSelectedElements(elements, appState, {
includeBoundTextElement: false,
}).length > 0
);
+7
View File
@@ -125,6 +125,13 @@ export const actionFinalize = register({
{ x, y },
);
}
if (
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
) {
appState.selectedElementIds[multiPointElement.id] = true;
}
}
if (
+2 -4
View File
@@ -17,12 +17,11 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "horizontal"),
appState,
app,
),
appState,
commitToHistory: true,
@@ -35,12 +34,11 @@ export const actionFlipHorizontal = register({
export const actionFlipVertical = register({
name: "flipVertical",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "vertical"),
appState,
app,
),
appState,
commitToHistory: true,
+27 -19
View File
@@ -3,12 +3,19 @@ import { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame";
import { KEYS } from "../keys";
import { AppClassProperties, AppState } from "../types";
import { getSelectedElements } from "../scene";
import { AppState } from "../types";
import { setCursorForShape, updateActiveTool } from "../utils";
import { register } from "./register";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
const isSingleFrameSelected = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return selectedElements.length === 1 && selectedElements[0].type === "frame";
};
@@ -16,8 +23,11 @@ const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedFrame = app.scene.getSelectedElements(appState)[0];
perform: (elements, appState) => {
const selectedFrame = getSelectedElements(
getNonDeletedElements(elements),
appState,
)[0];
if (selectedFrame && selectedFrame.type === "frame") {
const elementsInFrame = getFrameElements(
@@ -45,15 +55,17 @@ export const actionSelectAllElementsInFrame = register({
};
},
contextItemLabel: "labels.selectAllElementsInFrame",
predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
});
export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame",
trackEvent: { category: "history" },
perform: (elements, appState, _, app) => {
const selectedFrame = app.scene.getSelectedElements(appState)[0];
perform: (elements, appState) => {
const selectedFrame = getSelectedElements(
getNonDeletedElements(elements),
appState,
)[0];
if (selectedFrame && selectedFrame.type === "frame") {
return {
@@ -75,12 +87,11 @@ export const actionRemoveAllElementsFromFrame = register({
};
},
contextItemLabel: "labels.removeAllElementsFromFrame",
predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
});
export const actionupdateFrameRendering = register({
name: "updateFrameRendering",
export const actionToggleFrameRendering = register({
name: "toggleFrameRendering",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
@@ -88,16 +99,13 @@ export const actionupdateFrameRendering = register({
elements,
appState: {
...appState,
frameRendering: {
...appState.frameRendering,
enabled: !appState.frameRendering.enabled,
},
shouldRenderFrames: !appState.shouldRenderFrames,
},
commitToHistory: false,
};
},
contextItemLabel: "labels.updateFrameRendering",
checked: (appState: AppState) => appState.frameRendering.enabled,
contextItemLabel: "labels.toggleFrameRendering",
checked: (appState: AppState) => appState.shouldRenderFrames,
});
export const actionSetFrameAsActiveTool = register({
+22 -29
View File
@@ -4,7 +4,7 @@ import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
import { UngroupIcon, GroupIcon } from "../components/icons";
import { newElementWith } from "../element/mutateElement";
import { isSomeElementSelected } from "../scene";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import {
getSelectedGroupIds,
selectGroup,
@@ -22,7 +22,7 @@ import {
ExcalidrawFrameElement,
ExcalidrawTextElement,
} from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,
@@ -51,12 +51,14 @@ const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
);
return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
);
@@ -66,10 +68,13 @@ export const actionGroup = register({
name: "group",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
);
if (selectedElements.length < 2) {
// nothing to group
return { appState, elements, commitToHistory: false };
@@ -159,13 +164,12 @@ export const actionGroup = register({
};
},
contextItemLabel: "labels.group",
predicate: (elements, appState, _, app) =>
enableActionGroup(elements, appState, app),
predicate: (elements, appState) => enableActionGroup(elements, appState),
keyTest: (event) =>
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState, app)}
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={<GroupIcon theme={appState.theme} />}
onClick={() => updateData(null)}
@@ -187,7 +191,7 @@ export const actionUngroup = register({
let nextElements = [...elements];
const selectedElements = app.scene.getSelectedElements(appState);
const selectedElements = getSelectedElements(nextElements, appState);
const frames = selectedElements
.filter((element) => element.frameId)
.map((element) =>
@@ -214,8 +218,6 @@ export const actionUngroup = register({
const updateAppState = selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
appState,
null,
);
frames.forEach((frame) => {
@@ -230,18 +232,9 @@ export const actionUngroup = register({
});
// remove binded text elements from selection
updateAppState.selectedElementIds = Object.entries(
updateAppState.selectedElementIds,
).reduce(
(acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => {
if (selected && !boundTextElementIds.includes(id)) {
acc[id] = true;
}
return acc;
},
{},
boundTextElementIds.forEach(
(id) => (updateAppState.selectedElementIds[id] = false),
);
return {
appState: updateAppState,
elements: nextElements,
+19 -11
View File
@@ -1,6 +1,8 @@
import { getNonDeletedElements } from "../element";
import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks";
import { ExcalidrawLinearElement } from "../element/types";
import { getSelectedElements } from "../scene";
import { register } from "./register";
export const actionToggleLinearEditor = register({
@@ -8,18 +10,21 @@ export const actionToggleLinearEditor = register({
trackEvent: {
category: "element",
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
return true;
}
return false;
},
perform(elements, appState, _, app) {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
const selectedElement = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement;
const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id
@@ -33,11 +38,14 @@ export const actionToggleLinearEditor = register({
commitToHistory: false,
};
},
contextItemLabel: (elements, appState, app) => {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
contextItemLabel: (elements, appState) => {
const selectedElement = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement;
return appState.editingLinearElement?.elementId === selectedElement.id
? "labels.lineEditor.exit"
: "labels.lineEditor.edit";
-2
View File
@@ -41,8 +41,6 @@ export const actionSelectAll = register({
selectedElementIds,
},
getNonDeletedElements(elements),
appState,
app,
),
commitToHistory: true,
};
+36 -13
View File
@@ -2,10 +2,10 @@ import React from "react";
import {
Action,
UpdaterFn,
ActionName,
ActionResult,
PanelComponentProps,
ActionSource,
ActionPredicateFn,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
@@ -40,7 +40,8 @@ const trackAction = (
};
export class ActionManager {
actions = {} as Record<ActionName, Action>;
actions = {} as Record<Action["name"], Action>;
actionPredicates = [] as ActionPredicateFn[];
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@@ -68,6 +69,12 @@ export class ActionManager {
this.app = app;
}
registerActionPredicate(predicate: ActionPredicateFn) {
if (!this.actionPredicates.includes(predicate)) {
this.actionPredicates.push(predicate);
}
}
registerAction(action: Action) {
this.actions[action.name] = action;
}
@@ -84,13 +91,12 @@ export class ActionManager {
(action) =>
(action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions]
: true) &&
: this.isActionEnabled(action, { noPredicates: true })) &&
action.keyTest &&
action.keyTest(
event,
this.getAppState(),
this.getElementsIncludingDeleted(),
this.app,
),
);
@@ -135,7 +141,7 @@ export class ActionManager {
/**
* @param data additional data sent to the PanelComponent
*/
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
renderAction = (name: Action["name"], data?: PanelComponentProps["data"]) => {
const canvasActions = this.app.props.UIOptions.canvasActions;
if (
@@ -143,7 +149,7 @@ export class ActionManager {
"PanelComponent" in this.actions[name] &&
(name in canvasActions
? canvasActions[name as keyof typeof canvasActions]
: true)
: this.isActionEnabled(this.actions[name], { noPredicates: true }))
) {
const action = this.actions[name];
const PanelComponent = action.PanelComponent!;
@@ -169,7 +175,6 @@ export class ActionManager {
appState={this.getAppState()}
updateData={updateData}
appProps={this.app.props}
app={this.app}
data={data}
/>
);
@@ -178,13 +183,31 @@ export class ActionManager {
return null;
};
isActionEnabled = (action: Action) => {
const elements = this.getElementsIncludingDeleted();
isActionEnabled = (
action: Action,
opts?: {
elements?: readonly ExcalidrawElement[];
data?: Record<string, any>;
noPredicates?: boolean;
},
): boolean => {
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
const appState = this.getAppState();
const data = opts?.data;
return (
!action.predicate ||
action.predicate(elements, appState, this.app.props, this.app)
);
if (
!opts?.noPredicates &&
action.predicate &&
!action.predicate(elements, appState, this.app.props, this.app, data)
) {
return false;
}
let enabled = true;
this.actionPredicates.forEach((fn) => {
if (!fn(action, elements, appState, data)) {
enabled = false;
}
});
return enabled;
};
}
+13 -7
View File
@@ -32,6 +32,15 @@ type ActionFn = (
app: AppClassProperties,
) => ActionResult | Promise<ActionResult>;
// Return `true` *unless* `Action` should be disabled
// given `elements`, `appState`, and optionally `data`.
export type ActionPredicateFn = (
action: Action,
elements: readonly ExcalidrawElement[],
appState: AppState,
data?: Record<string, any>,
) => boolean;
export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void;
@@ -82,8 +91,7 @@ export type ActionName =
| "zoomOut"
| "resetZoom"
| "zoomToFit"
| "zoomToFitSelection"
| "zoomToFitSelectionInViewport"
| "zoomToSelection"
| "changeFontFamily"
| "changeTextAlign"
| "changeVerticalAlign"
@@ -119,7 +127,7 @@ export type ActionName =
| "toggleHandTool"
| "selectAllElementsInFrame"
| "removeAllElementsFromFrame"
| "updateFrameRendering"
| "toggleFrameRendering"
| "setFrameAsActiveTool"
| "createContainerFromText"
| "wrapTextInContainer";
@@ -130,11 +138,10 @@ export type PanelComponentProps = {
updateData: (formData?: any) => void;
appProps: ExcalidrawProps;
data?: Record<string, any>;
app: AppClassProperties;
};
export interface Action {
name: ActionName;
name: string;
PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn;
keyPriority?: number;
@@ -142,20 +149,19 @@ export interface Action {
event: React.KeyboardEvent | KeyboardEvent,
appState: AppState,
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
) => boolean;
contextItemLabel?:
| string
| ((
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
) => string);
predicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
appProps: ExcalidrawProps,
app: AppClassProperties,
data?: Record<string, any>,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
trackEvent:
+14 -5
View File
@@ -5,9 +5,6 @@ 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 });
@@ -15,8 +12,12 @@ export const trackEvent = (
return;
}
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
return;
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) {
window.gtag("event", action, {
event_category: category,
event_label: label,
value,
});
}
if (window.sa_event) {
@@ -26,6 +27,14 @@ 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: [],
frameRendering: { enabled: true, clip: true, name: true, outline: true },
shouldRenderFrames: 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 },
frameRendering: { browser: false, export: false, server: false },
shouldRenderFrames: { 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 getChartDimensions = (spreadsheet: Spreadsheet) => {
const getChartDimentions = (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 } = getChartDimensions(spreadsheet);
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const xLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
@@ -313,7 +313,7 @@ const chartBaseElements = (
backgroundColor: string,
debug?: boolean,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const title = spreadsheet.title
? newTextElement({
+254 -366
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -82,9 +82,7 @@ export const ContextMenu = React.memo(
let label = "";
if (item.contextItemLabel) {
if (typeof item.contextItemLabel === "function") {
label = t(
item.contextItemLabel(elements, appState, actionManager.app),
);
label = t(item.contextItemLabel(elements, appState));
} else {
label = t(item.contextItemLabel);
}
+4 -20
View File
@@ -17,34 +17,16 @@ 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?: DialogSize;
size?: "small" | "regular" | "wide";
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);
@@ -103,7 +85,9 @@ export const Dialog = (props: DialogProps) => {
<Modal
className={clsx("Dialog", props.className)}
labelledBy="dialog-title"
maxWidth={getDialogSize(props.size)}
maxWidth={
props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
}
onCloseRequest={onClose}
closeOnClickOutside={props.closeOnClickOutside}
>
+41 -137
View File
@@ -2,140 +2,20 @@
.excalidraw {
.ExcButton {
--text-color: transparent;
--border-color: transparent;
--back-color: transparent;
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);
color: var(--input-bg-color);
&: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);
}
}
--accent-color: var(--color-primary);
--accent-color-hover: var(--color-primary-darker);
--accent-color-active: var(--color-primary-darkest);
}
&--color-danger {
&.ExcButton--variant-filled {
--text-color: var(--color-danger-text);
--back-color: var(--color-danger-dark);
color: var(--input-bg-color);
&: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);
}
}
--accent-color: var(--color-danger);
--accent-color-hover: #d65550;
--accent-color-active: #d1413c;
}
display: flex;
@@ -145,8 +25,6 @@
flex-wrap: nowrap;
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: "Assistant";
@@ -155,9 +33,9 @@
transition: all 150ms ease-out;
&--size-large {
font-weight: 600;
font-weight: 400;
font-size: 0.875rem;
min-height: 3rem;
height: 3rem;
padding: 0.5rem 1.5rem;
gap: 0.75rem;
@@ -167,22 +45,48 @@
&--size-medium {
font-weight: 600;
font-size: 0.75rem;
min-height: 2.5rem;
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;
+1 -4
View File
@@ -4,7 +4,7 @@ import clsx from "clsx";
import "./FilledButton.scss";
export type ButtonVariant = "filled" | "outlined" | "icon";
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
export type ButtonColor = "primary" | "danger";
export type ButtonSize = "medium" | "large";
export type FilledButtonProps = {
@@ -17,7 +17,6 @@ export type FilledButtonProps = {
color?: ButtonColor;
size?: ButtonSize;
className?: string;
fullWidth?: boolean;
startIcon?: React.ReactNode;
};
@@ -32,7 +31,6 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
variant = "filled",
color = "primary",
size = "medium",
fullWidth,
className,
},
ref,
@@ -44,7 +42,6 @@ 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://docs.excalidraw.com"
href="https://github.com/excalidraw/excalidraw#documentation"
target="_blank"
rel="noopener noreferrer"
>
+13 -6
View File
@@ -1,5 +1,7 @@
import { t } from "../i18n";
import { AppClassProperties, Device, UIAppState } from "../types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene";
import { Device, UIAppState } from "../types";
import {
isImageElement,
isLinearElement,
@@ -13,12 +15,17 @@ import "./HintViewer.scss";
interface HintViewerProps {
appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[];
isMobile: boolean;
device: Device;
app: AppClassProperties;
}
const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
const getHints = ({
appState,
elements,
isMobile,
device,
}: HintViewerProps) => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
@@ -48,7 +55,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.placeImage");
}
const selectedElements = app.scene.getSelectedElements(appState);
const selectedElements = getSelectedElements(elements, appState);
if (
isResizing &&
@@ -108,15 +115,15 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
export const HintViewer = ({
appState,
elements,
isMobile,
device,
app,
}: HintViewerProps) => {
let hint = getHints({
appState,
elements,
isMobile,
device,
app,
});
if (!hint) {
return null;
+2 -17
View File
@@ -41,7 +41,6 @@ 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";
@@ -72,7 +71,6 @@ interface LayerUIProps {
onExportImage: AppClassProperties["onExportImage"];
renderWelcomeScreen: boolean;
children?: React.ReactNode;
app: AppClassProperties;
}
const DefaultMainMenu: React.FC<{
@@ -101,15 +99,6 @@ const DefaultMainMenu: React.FC<{
);
};
const DefaultOverwriteConfirmDialog = () => {
return (
<OverwriteConfirmDialog __fallback>
<OverwriteConfirmDialog.Actions.SaveToDisk />
<OverwriteConfirmDialog.Actions.ExportToImage />
</OverwriteConfirmDialog>
);
};
const LayerUI = ({
actionManager,
appState,
@@ -128,7 +117,6 @@ const LayerUI = ({
onExportImage,
renderWelcomeScreen,
children,
app,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@@ -242,9 +230,9 @@ const LayerUI = ({
>
<HintViewer
appState={appState}
elements={elements}
isMobile={device.isMobile}
device={device}
app={app}
/>
{heading}
<Stack.Row gap={1}>
@@ -355,7 +343,6 @@ const LayerUI = ({
>
{t("toolBar.library")}
</DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog />
{/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />}
@@ -387,7 +374,6 @@ const LayerUI = ({
/>
)}
<ActiveConfirmDialog />
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}
{renderJSONExportDialog()}
{appState.pasteDialog.shown && (
@@ -401,9 +387,8 @@ const LayerUI = ({
}
/>
)}
{device.isMobile && (
{device.isMobile && !eyeDropperState && (
<MobileMenu
app={app}
appState={appState}
elements={elements}
actionManager={actionManager}
+2 -10
View File
@@ -1,11 +1,5 @@
import React from "react";
import {
AppClassProperties,
AppState,
Device,
ExcalidrawProps,
UIAppState,
} from "../types";
import { AppState, Device, ExcalidrawProps, UIAppState } from "../types";
import { ActionManager } from "../actions/manager";
import { t } from "../i18n";
import Stack from "./Stack";
@@ -47,7 +41,6 @@ type MobileMenuProps = {
renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen: boolean;
app: AppClassProperties;
};
export const MobileMenu = ({
@@ -65,7 +58,6 @@ export const MobileMenu = ({
renderSidebars,
device,
renderWelcomeScreen,
app,
}: MobileMenuProps) => {
const {
WelcomeScreenCenterTunnel,
@@ -127,9 +119,9 @@ export const MobileMenu = ({
</Section>
<HintViewer
appState={appState}
elements={elements}
isMobile={true}
device={device}
app={app}
/>
</FixedSideContainer>
);
+1 -1
View File
@@ -3,7 +3,7 @@
.excalidraw {
&.excalidraw-modal-container {
position: absolute;
z-index: var(--zIndex-modal);
z-index: 10;
}
.Modal {
@@ -1,126 +0,0 @@
@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);
}
}
}
}
}
@@ -1,76 +0,0 @@
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 };
@@ -1,85 +0,0 @@
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 };
@@ -1,46 +0,0 @@
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
@@ -1,91 +0,0 @@
@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
@@ -1,91 +0,0 @@
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>
);
};
+2 -24
View File
@@ -1,10 +1,4 @@
import {
forwardRef,
useRef,
useImperativeHandle,
KeyboardEvent,
useLayoutEffect,
} from "react";
import { forwardRef, useRef, useImperativeHandle, KeyboardEvent } from "react";
import clsx from "clsx";
import "./TextField.scss";
@@ -18,7 +12,6 @@ export type TextFieldProps = {
readonly?: boolean;
fullWidth?: boolean;
selectOnRender?: boolean;
label?: string;
placeholder?: string;
@@ -26,28 +19,13 @@ export type TextFieldProps = {
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
(
{
value,
onChange,
label,
fullWidth,
placeholder,
readonly,
selectOnRender,
onKeyDown,
},
{ value, onChange, label, fullWidth, placeholder, readonly, onKeyDown },
ref,
) => {
const innerRef = useRef<HTMLInputElement | null>(null);
useImperativeHandle(ref, () => innerRef.current!);
useLayoutEffect(() => {
if (selectOnRender) {
innerRef.current?.select();
}
}, [selectOnRender]);
return (
<div
className={clsx("ExcTextField", {
+1 -1
View File
@@ -6,7 +6,7 @@
Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
position: fixed;
z-index: var(--zIndex-popup);
z-index: 1000;
padding: 8px;
border-radius: 6px;
-10
View File
@@ -1608,16 +1608,6 @@ 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>
+2 -29
View File
@@ -1,10 +1,6 @@
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { useI18n } from "../../i18n";
import {
useExcalidrawSetAppState,
useExcalidrawActionManager,
useExcalidrawElements,
} from "../App";
import { useExcalidrawSetAppState, useExcalidrawActionManager } from "../App";
import {
ExportIcon,
ExportImageIcon,
@@ -33,42 +29,19 @@ 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={handleSelect}
onSelect={() => actionManager.executeAction(actionLoadScene)}
data-testid="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
aria-label={t("buttons.load")}
-2
View File
@@ -12,7 +12,6 @@ type TunnelsContextValue = {
FooterCenterTunnel: Tunnel;
DefaultSidebarTriggerTunnel: Tunnel;
DefaultSidebarTabTriggersTunnel: Tunnel;
OverwriteConfirmDialogTunnel: Tunnel;
jotaiScope: symbol;
};
@@ -31,7 +30,6 @@ export const useInitializeTunnels = () => {
FooterCenterTunnel: tunnel(),
DefaultSidebarTriggerTunnel: tunnel(),
DefaultSidebarTabTriggersTunnel: tunnel(),
OverwriteConfirmDialogTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);
-9
View File
@@ -5,15 +5,6 @@
--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 {
+4 -45
View File
@@ -27,6 +27,10 @@
--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),
@@ -95,33 +99,9 @@
--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;
@@ -197,27 +177,6 @@
--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;
}
}
+1 -24
View File
@@ -41,7 +41,6 @@ import {
measureBaseline,
} from "../element/textElement";
import { COLOR_PALETTE } from "../colors";
import { normalizeLink } from "./url";
type RestoredAppState = Omit<
AppState,
@@ -143,7 +142,7 @@ const restoreElementWithProperties = <
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
: element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ? normalizeLink(element.link) : null,
link: element.link ?? null,
locked: element.locked ?? false,
};
@@ -371,24 +370,6 @@ 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 */
@@ -429,10 +410,6 @@ 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
@@ -1,30 +0,0 @@
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
@@ -1,9 +0,0 @@
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("/"));
};
+17 -9
View File
@@ -29,7 +29,6 @@ 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";
@@ -167,7 +166,7 @@ export const Hyperlink = ({
/>
) : (
<a
href={normalizeLink(element.link || "")}
href={element.link || ""}
className={clsx("excalidraw-hyperlinkContainer-link", {
"d-none": isEditing,
})}
@@ -178,13 +177,7 @@ export const Hyperlink = ({
EVENT.EXCALIDRAW_LINK,
event.nativeEvent,
);
onLinkOpen(
{
...element,
link: normalizeLink(element.link),
},
customEvent,
);
onLinkOpen(element, customEvent);
if (customEvent.defaultPrevented) {
event.preventDefault();
}
@@ -238,6 +231,21 @@ 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) => {
+4 -29
View File
@@ -27,7 +27,6 @@ import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getContainingFrame, isPointInFrame } from "../frame";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@@ -275,18 +274,6 @@ export const getHoveredElementForBinding = (
isBindableElement(element, false) &&
bindingBorderTest(element, pointerCoords),
);
if (hoveredElement) {
const frame = getContainingFrame(hoveredElement);
if (frame) {
if (isPointInFrame(pointerCoords, frame)) {
return hoveredElement as NonDeleted<ExcalidrawBindableElement>;
}
return null;
}
}
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
};
@@ -512,22 +499,10 @@ const getElligibleElementsForBindingElement = (
return [
getElligibleElementForBindingElement(linearElement, "start"),
getElligibleElementForBindingElement(linearElement, "end"),
].filter((element): element is NonDeleted<ExcalidrawBindableElement> => {
if (element != null) {
const frame = getContainingFrame(element);
return frame
? isPointInFrame(
getLinearElementEdgeCoors(linearElement, "start"),
frame,
) ||
isPointInFrame(
getLinearElementEdgeCoors(linearElement, "end"),
frame,
)
: true;
}
return false;
});
].filter(
(element): element is NonDeleted<ExcalidrawBindableElement> =>
element != null,
);
};
const getElligibleElementForBindingElement = (
+1
View File
@@ -862,6 +862,7 @@ const VALID_CONTAINER_TYPES = new Set([
"rectangle",
"ellipse",
"diamond",
"image",
"arrow",
]);
-49
View File
@@ -1459,54 +1459,5 @@ 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,6 +30,15 @@ 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", () => {
@@ -53,14 +62,5 @@ describe("Test TypeChecks", () => {
),
).toBeFalsy();
});
expect(
hasBoundTextElement(
API.createElement({
type: "image",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeFalsy();
});
});
+1
View File
@@ -126,6 +126,7 @@ export const isTextBindableContainer = (
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "image" ||
isArrowElement(element))
);
};
+1
View File
@@ -162,6 +162,7 @@ export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawImageElement
| ExcalidrawArrowElement;
export type ExcalidrawTextElementWithContainer = {
@@ -16,7 +16,7 @@ import { MIME_TYPES } from "../../constants";
import { trackEvent } from "../../analytics";
import { getFrame } from "../../utils";
export const exportToExcalidrawPlus = async (
const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
+6 -14
View File
@@ -282,15 +282,11 @@ 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(
@@ -331,18 +327,14 @@ export const exportToBackend = async (
files: filesToUpload,
});
return { url: urlString, errorMessage: null };
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
} else if (json.error_class === "RequestTooLargeError") {
return {
url: null,
errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
};
window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
} else {
window.alert(t("alerts.couldNotCreateShareableLink"));
}
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
} catch (error: any) {
console.error(error);
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
window.alert(t("alerts.couldNotCreateShareableLink"));
}
};
-135
View File
@@ -1,135 +0,0 @@
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;
+9 -67
View File
@@ -69,10 +69,7 @@ import {
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks";
@@ -91,10 +88,6 @@ 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();
@@ -105,19 +98,6 @@ 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;
@@ -149,7 +129,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
(await openConfirmModal(shareableLinkConfirmDialog))
window.confirm(t("alerts.loadSceneOverridePrompt"))
) {
if (jsonBackendMatch) {
scene = await loadScene(
@@ -188,7 +168,7 @@ const initializeScene = async (opts: {
const data = await loadFromBlob(await request.blob(), null, null);
if (
!scene.elements.length ||
(await openConfirmModal(shareableLinkConfirmDialog))
window.confirm(t("alerts.loadSceneOverridePrompt"))
) {
return { scene: data, isExternalScene };
}
@@ -574,10 +554,6 @@ const ExcalidrawWrapper = () => {
}
};
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
null,
);
const onExportToBackend = async (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>,
@@ -589,7 +565,7 @@ const ExcalidrawWrapper = () => {
}
if (canvas) {
try {
const { url, errorMessage } = await exportToBackend(
await exportToBackend(
exportedElements,
{
...appState,
@@ -599,14 +575,6 @@ const ExcalidrawWrapper = () => {
},
files,
);
if (errorMessage) {
setErrorMessage(errorMessage);
}
if (url) {
setLatestShareableLink(url);
}
} catch (error: any) {
if (error.name !== "AbortError") {
const { width, height } = canvas;
@@ -706,47 +674,21 @@ 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>
);
};
+4 -22
View File
@@ -1,7 +1,6 @@
import {
getCommonBounds,
getElementAbsoluteCoords,
getElementBounds,
isTextElement,
} from "./element";
import {
@@ -17,7 +16,7 @@ import {
} from "./element/textElement";
import { arrayToMap, findIndex } from "./utils";
import { mutateElement } from "./element/mutateElement";
import { AppClassProperties, AppState } from "./types";
import { AppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { isFrameElement } from "./element";
import { moveOneRight } from "./zindex";
@@ -300,21 +299,12 @@ export const groupsAreCompletelyOutOfFrame = (
);
};
export const isPointInFrame = (
{ x, y }: { x: number; y: number },
frame: ExcalidrawFrameElement,
) => {
const [x1, y1, x2, y2] = getElementBounds(frame);
return x >= x1 && x <= x2 && y >= y1 && y <= y2;
};
// --------------------------- Frame Utils ------------------------------------
/**
* Returns a map of frameId to frame elements. Includes empty frames.
*/
export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
export const groupByFrames = (elements: ExcalidrawElementsIncludingDeleted) => {
const frameElementsMap = new Map<
ExcalidrawElement["id"],
ExcalidrawElement[]
@@ -581,13 +571,8 @@ export const replaceAllElementsInFrame = (
export const updateFrameMembershipOfSelectedElements = (
allElements: ExcalidrawElementsIncludingDeleted,
appState: AppState,
app: AppClassProperties,
) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
// supplying elements explicitly in case we're passed non-state elements
elements: allElements,
});
const selectedElements = getSelectedElements(allElements, appState);
const elementsToFilter = new Set<ExcalidrawElement>(selectedElements);
if (appState.editingGroupId) {
@@ -606,7 +591,6 @@ export const updateFrameMembershipOfSelectedElements = (
elementsToFilter.forEach((element) => {
if (
element.frameId &&
!isFrameElement(element) &&
!isElementInFrame(element, allElements, appState)
) {
@@ -614,9 +598,7 @@ export const updateFrameMembershipOfSelectedElements = (
}
});
return elementsToRemove.size > 0
? removeElementsFromFrame(allElements, [...elementsToRemove], appState)
: allElements;
return removeElementsFromFrame(allElements, [...elementsToRemove], appState);
};
/**
+5 -35
View File
@@ -1,13 +1,7 @@
import {
GroupId,
ExcalidrawElement,
NonDeleted,
NonDeletedExcalidrawElement,
} from "./element/types";
import { AppClassProperties, AppState } from "./types";
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,
@@ -72,33 +66,14 @@ export const getSelectedGroupIds = (appState: AppState): GroupId[] =>
*/
export const selectGroupsForSelectedElements = (
appState: AppState,
elements: readonly NonDeletedExcalidrawElement[],
prevAppState: AppState,
/**
* supply null in cases where you don't have access to App instance and
* you don't care about optimizing selectElements retrieval
*/
app: AppClassProperties | null,
elements: readonly NonDeleted<ExcalidrawElement>[],
): AppState => {
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
const selectedElements = app
? app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
// supplying elements explicitly in case we're passed non-state elements
elements,
})
: getSelectedElements(elements, appState);
const selectedElements = getSelectedElements(elements, appState);
if (!selectedElements.length) {
return {
...nextAppState,
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds(
nextAppState.selectedElementIds,
prevAppState,
),
};
return { ...nextAppState, editingGroupId: null };
}
for (const selectedElement of selectedElements) {
@@ -116,11 +91,6 @@ export const selectGroupsForSelectedElements = (
}
}
nextAppState.selectedElementIds = makeNextSelectedElementIds(
nextAppState.selectedElementIds,
prevAppState,
);
return nextAppState;
};
-1
View File
@@ -10,7 +10,6 @@ export const CODES = {
BRACKET_LEFT: "BracketLeft",
ONE: "Digit1",
TWO: "Digit2",
THREE: "Digit3",
NINE: "Digit9",
QUOTE: "Quote",
ZERO: "Digit0",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "نُشر",
"sidebarLock": "إبقاء الشريط الجانبي مفتوح",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "وضع القلم - امنع اللمس",
"link": "إضافة/تحديث الرابط للشكل المحدد",
"eraser": "ممحاة",
"frame": "",
"hand": "",
"extraTools": ""
"hand": ""
},
"headings": {
"canvasActions": "إجراءات اللوحة",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "",
"link": "",
"eraser": "",
"frame": "",
"hand": "",
"extraTools": ""
"hand": ""
},
"headings": {
"canvasActions": "",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "",
"link": "",
"eraser": "",
"frame": "",
"hand": "",
"extraTools": ""
"hand": ""
},
"headings": {
"canvasActions": "Действия по платното",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "প্রকাশিত",
"sidebarLock": "লক",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "",
"link": "একটি নির্বাচিত আকৃতির জন্য লিঙ্ক যোগ বা আপডেট করুন",
"eraser": "ঝাড়ন",
"frame": "",
"hand": "",
"extraTools": ""
"hand": ""
},
"headings": {
"canvasActions": "ক্যানভাস কার্যকলাপ",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "Publicat",
"sidebarLock": "Manté la barra lateral oberta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "Mode de llapis - evita tocar",
"link": "Afegeix / actualitza l'enllaç per a la forma seleccionada",
"eraser": "Esborrador",
"frame": "",
"hand": "Mà (eina de desplaçament)",
"extraTools": ""
"hand": "Mà (eina de desplaçament)"
},
"headings": {
"canvasActions": "Accions del llenç",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "Zveřejněno",
"sidebarLock": "Ponechat postranní panel otevřený",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "Vyberte barvu z plátna"
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "Režim Pera - zabránit dotyku",
"link": "Přidat/aktualizovat odkaz pro vybraný tvar",
"eraser": "Guma",
"frame": "",
"hand": "Ruka (nástroj pro posouvání)",
"extraTools": ""
"hand": "Ruka (nástroj pro posouvání)"
},
"headings": {
"canvasActions": "Akce plátna",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "",
"link": "",
"eraser": "",
"frame": "",
"hand": "",
"extraTools": ""
"hand": ""
},
"headings": {
"canvasActions": "",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"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": {
@@ -223,9 +221,7 @@
"penMode": "Stift-Modus - Berührung verhindern",
"link": "Link für ausgewählte Form hinzufügen / aktualisieren",
"eraser": "Radierer",
"frame": "Rahmenwerkzeug",
"hand": "Hand (Schwenkwerkzeug)",
"extraTools": "Weitere Werkzeuge"
"hand": "Hand (Schwenkwerkzeug)"
},
"headings": {
"canvasActions": "Aktionen für Zeichenfläche",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "Δημοσιευμένο",
"sidebarLock": "Κρατήστε την πλαϊνή μπάρα ανοιχτή",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "Λειτουργία μολυβιού - αποτροπή αφής",
"link": "Προσθήκη/ Ενημέρωση συνδέσμου για ένα επιλεγμένο σχήμα",
"eraser": "Γόμα",
"frame": "",
"hand": "",
"extraTools": ""
"hand": ""
},
"headings": {
"canvasActions": "Ενέργειες καμβά",
-31
View File
@@ -449,36 +449,5 @@
"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."
}
}
}
}
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "Publicado",
"sidebarLock": "Mantener barra lateral abierta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "Modo Lápiz - previene toque",
"link": "Añadir/Actualizar enlace para una forma seleccionada",
"eraser": "Borrar",
"frame": "",
"hand": "Mano (herramienta de panoramización)",
"extraTools": ""
"hand": "Mano (herramienta de panoramización)"
},
"headings": {
"canvasActions": "Acciones del lienzo",
+35 -39
View File
@@ -124,9 +124,7 @@
},
"statusPublished": "Argitaratua",
"sidebarLock": "Mantendu alboko barra irekita",
"selectAllElementsInFrame": "Hautatu markoko elementu guztiak",
"removeAllElementsFromFrame": "Kendu markoko elementu guztiak",
"eyeDropper": "Aukeratu kolorea oihaletik"
"eyeDropper": ""
},
"library": {
"noItems": "Oraindik ez da elementurik gehitu...",
@@ -223,9 +221,7 @@
"penMode": "Luma modua - ukipena saihestu",
"link": "Gehitu / Eguneratu esteka hautatutako forma baterako",
"eraser": "Borragoma",
"frame": "Marko tresna",
"hand": "Eskua (panoratze tresna)",
"extraTools": "Tresna gehiago"
"hand": "Eskua (panoratze tresna)"
},
"headings": {
"canvasActions": "Canvas ekintzak",
@@ -360,27 +356,27 @@
"removeItemsFromLib": "Kendu hautatutako elementuak liburutegitik"
},
"imageExportDialog": {
"header": "Esportatu irudia",
"header": "",
"label": {
"withBackground": "Atzeko planoa",
"onlySelected": "Hautapena soilik",
"darkMode": "Modu iluna",
"embedScene": "Txertatu eszena",
"scale": "Eskala",
"padding": "Betegarria"
"withBackground": "",
"onlySelected": "",
"darkMode": "",
"embedScene": "",
"scale": "",
"padding": ""
},
"tooltip": {
"embedScene": "Eszenaren datuak esportatutako PNG/SVG fitxategian gordeko dira, eszena bertatik berrezartzeko.\nEsportatutako fitxategien tamaina handituko da."
"embedScene": ""
},
"title": {
"exportToPng": "Esportatu PNG gisa",
"exportToSvg": "Esportatu SVG gisa",
"copyPngToClipboard": "Kopiatu PNG arbelera"
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
},
"button": {
"exportToPng": "PNG",
"exportToSvg": "SVG",
"copyPngToClipboard": "Kopiatu arbelean"
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
}
},
"encrypted": {
@@ -415,20 +411,20 @@
},
"colors": {
"transparent": "Gardena",
"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"
"black": "",
"white": "",
"red": "",
"pink": "",
"grape": "",
"violet": "",
"gray": "",
"blue": "",
"cyan": "",
"teal": "",
"green": "",
"yellow": "",
"orange": "",
"bronze": ""
},
"welcomeScreen": {
"app": {
@@ -444,10 +440,10 @@
}
},
"colorPicker": {
"mostUsedCustomColors": "Gehien erabilitako kolore pertsonalizatuak",
"colors": "Koloreak",
"shades": "Ñabardurak",
"hexCode": "Hez kodea",
"noShades": "Kolore honetarako ez dago ñabardurarik eskuragarri"
"mostUsedCustomColors": "",
"colors": "",
"shades": "",
"hexCode": "",
"noShades": ""
}
}
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "منتشر شده",
"sidebarLock": "باز نگه داشتن سایدبار",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "حالت قلم - جلوگیری از تماس",
"link": "افزودن/به‌روزرسانی پیوند برای شکل انتخابی",
"eraser": "پاک کن",
"frame": "",
"hand": "دست (ابزار پانینگ)",
"extraTools": ""
"hand": "دست (ابزار پانینگ)"
},
"headings": {
"canvasActions": "عملیات روی بوم",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "Julkaistu",
"sidebarLock": "Pidä sivupalkki avoinna",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "Kynätila - estä kosketus",
"link": "Lisää/päivitä linkki valitulle muodolle",
"eraser": "Poistotyökalu",
"frame": "",
"hand": "Käsi (panning-työkalu)",
"extraTools": ""
"hand": "Käsi (panning-työkalu)"
},
"headings": {
"canvasActions": "Piirtoalueen toiminnot",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "Publié",
"sidebarLock": "Maintenir la barre latérale ouverte",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "Mode stylo - évite le toucher",
"link": "Ajouter/mettre à jour le lien pour une forme sélectionnée",
"eraser": "Gomme",
"frame": "",
"hand": "Mains (outil de déplacement de la vue)",
"extraTools": ""
"hand": "Mains (outil de déplacement de la vue)"
},
"headings": {
"canvasActions": "Actions du canevas",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "Publicado",
"sidebarLock": "Manter a barra lateral aberta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "Modo lapis - evitar o contacto",
"link": "Engadir/ Actualizar ligazón para a forma seleccionada",
"eraser": "Goma de borrar",
"frame": "",
"hand": "Man (ferramenta de desprazamento)",
"extraTools": ""
"hand": "Man (ferramenta de desprazamento)"
},
"headings": {
"canvasActions": "Accións do lenzo",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "פורסם",
"sidebarLock": "שמור את סרגל הצד פתוח",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "מצב עט - מנע נגיעה",
"link": "הוספה/עדכון קישור של הצורה שנבחרה",
"eraser": "מחק",
"frame": "",
"hand": "יד (כלי הזזה)",
"extraTools": ""
"hand": "יד (כלי הזזה)"
},
"headings": {
"canvasActions": "פעולות קנבאס",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "प्रकाशित",
"sidebarLock": "साइडबार खुला रखे.",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "चित्रफलक से रंग चुने"
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "पेन का मोड - स्पर्श टाले",
"link": "",
"eraser": "रबड़",
"frame": "",
"hand": "हाथ ( खिसकाने का औज़ार)",
"extraTools": ""
"hand": "हाथ ( खिसकाने का औज़ार)"
},
"headings": {
"canvasActions": "कैनवास क्रिया",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "",
"link": "Hivatkozás hozzáadása/frissítése a kiválasztott alakzathoz",
"eraser": "",
"frame": "",
"hand": "",
"extraTools": ""
"hand": ""
},
"headings": {
"canvasActions": "Vászon műveletek",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "Telah terbit",
"sidebarLock": "Biarkan sidebar tetap terbuka",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "Mode pena - mencegah sentuhan",
"link": "Tambah/Perbarui tautan untuk bentuk yang dipilih",
"eraser": "Penghapus",
"frame": "",
"hand": "Tangan (alat panning)",
"extraTools": ""
"hand": "Tangan (alat panning)"
},
"headings": {
"canvasActions": "Opsi Kanvas",
+3 -7
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "Pubblicato",
"sidebarLock": "Mantieni aperta la barra laterale",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "Modalità penna - previene il tocco",
"link": "Aggiungi/ aggiorna il link per una forma selezionata",
"eraser": "Gomma",
"frame": "",
"hand": "Mano (strumento di panoramica)",
"extraTools": "Altri strumenti"
"hand": "Mano (strumento di panoramica)"
},
"headings": {
"canvasActions": "Azioni sulla Tela",
@@ -365,12 +361,12 @@
"withBackground": "Sfondo",
"onlySelected": "Solo selezionato",
"darkMode": "Tema scuro",
"embedScene": "Includi scena",
"embedScene": "",
"scale": "Scala",
"padding": ""
},
"tooltip": {
"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."
"embedScene": ""
},
"title": {
"exportToPng": "Esporta come PNG",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "公開済み",
"sidebarLock": "サイドバーを開いたままにする",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "ペンモード - タッチ防止",
"link": "選択した図形のリンクを追加/更新",
"eraser": "消しゴム",
"frame": "",
"hand": "手 (パンニングツール)",
"extraTools": ""
"hand": "手 (パンニングツール)"
},
"headings": {
"canvasActions": "キャンバス操作",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "",
"link": "",
"eraser": "Óshirgish",
"frame": "",
"hand": "",
"extraTools": ""
"hand": ""
},
"headings": {
"canvasActions": "",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "Yeffeɣ-d",
"sidebarLock": "Eǧǧ afeggag n yidis yeldi",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "Askar n yimru - gdel tanalit",
"link": "Rnu/leqqem aseɣwen i talɣa yettwafernen",
"eraser": "Sfeḍ",
"frame": "",
"hand": "Afus (afecku n usmutti n tmuɣli)",
"extraTools": ""
"hand": "Afus (afecku n usmutti n tmuɣli)"
},
"headings": {
"canvasActions": "Tigawin n teɣzut n usuneɣ",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "",
"link": "",
"eraser": "",
"frame": "",
"hand": "",
"extraTools": ""
"hand": ""
},
"headings": {
"canvasActions": "",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "ត្រូវបានបោះពុម្ពផ្សាយ",
"sidebarLock": "ទុករបារចំហៀងបើក",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "របៀបប៊ិច - ជៀសវាងការប៉ះ",
"link": "បន្ថែម/ធ្វើបច្ចុប្បន្នភាពតំណភ្ជាប់សម្រាប់រូបរាងដែលបានជ្រើសរើស",
"eraser": "ជ័រលុប",
"frame": "",
"hand": "ដៃ (panning tool)",
"extraTools": ""
"hand": "ដៃ (panning tool)"
},
"headings": {
"canvasActions": "សកម្មភាពបាវ",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "게시됨",
"sidebarLock": "사이드바 유지",
"selectAllElementsInFrame": "프레임의 모든 요소 선택",
"removeAllElementsFromFrame": "프레임의 모든 요소 삭제",
"eyeDropper": "캔버스에서 색상 고르기"
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "펜 모드 - 터치 방지",
"link": "선택한 도형에 대해서 링크를 추가/업데이트",
"eraser": "지우개",
"frame": "프레임 도구",
"hand": "손 (패닝 도구)",
"extraTools": "다른 도구"
"hand": "손 (패닝 도구)"
},
"headings": {
"canvasActions": "캔버스 동작",
+36 -40
View File
@@ -71,7 +71,7 @@
"language": "زمان",
"liveCollaboration": "هاوکاریکردنی زیندو...",
"duplicateSelection": "لەبەرگرتنەوە",
"untitled": "بێ ناونیشان",
"untitled": "بێ-ناو",
"name": "ناو",
"yourName": "ناوەکەت",
"madeWithExcalidraw": "دروستکراوە بە Excalidraw",
@@ -124,9 +124,7 @@
},
"statusPublished": "بڵاوکراوەتەوە",
"sidebarLock": "هێشتنەوەی شریتی لا بە کراوەیی",
"selectAllElementsInFrame": "هەموو توخمەکانی ناو چوارچێوەکە دیاری بکە",
"removeAllElementsFromFrame": "هەموو توخمەکانی ناو چوارچێوەکە لابەرە",
"eyeDropper": "ڕەنگێک لەسەر تابلۆکە هەڵبژێرە"
"eyeDropper": ""
},
"library": {
"noItems": "هێشتا هیچ بڕگەیەک زیاد نەکراوە...",
@@ -223,9 +221,7 @@
"penMode": "شێوازی قەڵەم - دەست لێدان ڕابگرە",
"link": "زیادکردن/ نوێکردنەوەی لینک بۆ شێوەی دیاریکراو",
"eraser": "سڕەر",
"frame": "ئامرازی چوارچێوە",
"hand": "دەست (ئامرازی پانکردن)",
"extraTools": "ئامرازی زیاتر"
"hand": "دەست (ئامرازی پانکردن)"
},
"headings": {
"canvasActions": "کردارەکانی تابلۆ",
@@ -360,27 +356,27 @@
"removeItemsFromLib": "لابردنی ئایتمە دیاریکراوەکان لە کتێبخانە"
},
"imageExportDialog": {
"header": "وێنە هەناردە بکە",
"header": "",
"label": {
"withBackground": "پاشبنەما",
"onlySelected": "تەنها دیاریکراوەکان",
"darkMode": "دۆخی تاریک",
"embedScene": "دیمەنەکە بەکاربهێنەرەوە",
"scale": "قەبارە",
"padding": "بۆشایی"
"withBackground": "",
"onlySelected": "",
"darkMode": "",
"embedScene": "",
"scale": "",
"padding": ""
},
"tooltip": {
"embedScene": "داتاکانی دیمەنەکە لە فایلە هەناردەکراوەکەی PNG/SVG هەڵدەگیرێن بۆ ئەوەی دیمەنەکە لێیەوە بگەڕێتەوە.\nقەبارەی پەڕگەی هەناردەکراو زیاد دەکات."
"embedScene": ""
},
"title": {
"exportToPng": "هەناردە بکە وەک PNG",
"exportToSvg": "هەناردە بکە وەک SVG",
"copyPngToClipboard": "لەبەربگرەوە بۆ سەر تەختەنوس"
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
},
"button": {
"exportToPng": "PNG",
"exportToSvg": "SVG",
"copyPngToClipboard": "له‌به‌ری بگره‌وه‌ بۆ ته‌خته‌نووس"
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
}
},
"encrypted": {
@@ -415,20 +411,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": {
@@ -444,10 +440,10 @@
}
},
"colorPicker": {
"mostUsedCustomColors": "زۆرترین ڕەنگە باوە بەکارهاتووەکان",
"colors": "ڕەنگەکان",
"shades": "سێبەرەکان",
"hexCode": "کۆدی هێکس",
"noShades": "هیچ سێبەرێک بۆ ئەم ڕەنگە بەردەست نییە"
"mostUsedCustomColors": "",
"colors": "",
"shades": "",
"hexCode": "",
"noShades": ""
}
}
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "Rašyklio režimas - neleisti prisilietimų",
"link": "Pridėti / Atnaujinti pasirinktos figūros nuorodą",
"eraser": "Trintukas",
"frame": "",
"hand": "",
"extraTools": ""
"hand": ""
},
"headings": {
"canvasActions": "Veiksmai su drobe",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "Publicēts",
"sidebarLock": "Paturēt atvērtu sānjoslu",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "Pildspalvas režīms novērst pieskaršanos",
"link": "Pievienot/rediģēt atlasītās figūras saiti",
"eraser": "Dzēšgumija",
"frame": "",
"hand": "Roka (panoramēšanas rīks)",
"extraTools": ""
"hand": "Roka (panoramēšanas rīks)"
},
"headings": {
"canvasActions": "Tāfeles darbības",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "प्रकाशित करा",
"sidebarLock": "साइडबार उघडं ठेवा",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "चित्रफलकातून रंग निवडा"
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "पेन चा मोड - स्पर्श टाळा",
"link": "निवडलेल्या आकारासाठी दुवा जोडा/बदल करा",
"eraser": "खोड रबर",
"frame": "",
"hand": "हात ( सरकवण्या चे उपकरण)",
"extraTools": ""
"hand": "हात ( सरकवण्या चे उपकरण)"
},
"headings": {
"canvasActions": "पटल क्रिया",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "",
"link": "",
"eraser": "",
"frame": "",
"hand": "",
"extraTools": ""
"hand": ""
},
"headings": {
"canvasActions": "ကားချပ်လုပ်ဆောင်ချက်",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "Publisert",
"sidebarLock": "Holde sidemenyen åpen",
"selectAllElementsInFrame": "Velg alle elementene i rammen",
"removeAllElementsFromFrame": "Fjern alle elementer fra rammen",
"eyeDropper": "Velg farge fra lerretet"
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "Pennemodus - forhindre berøring",
"link": "Legg til / oppdater link for en valgt figur",
"eraser": "Viskelær",
"frame": "Rammeverktøy",
"hand": "Hånd (panoreringsverktøy)",
"extraTools": "Flere verktøy"
"hand": "Hånd (panoreringsverktøy)"
},
"headings": {
"canvasActions": "Handlinger: lerret",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "Gepubliceerd",
"sidebarLock": "Zijbalk open houden",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "Pen modus - Blokkeer aanraken",
"link": "Link toevoegen / bijwerken voor een geselecteerde vorm",
"eraser": "Gum",
"frame": "",
"hand": "",
"extraTools": ""
"hand": ""
},
"headings": {
"canvasActions": "Canvasacties",
+1 -5
View File
@@ -124,8 +124,6 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -223,9 +221,7 @@
"penMode": "",
"link": "Legg til/ oppdater lenke til valt figur",
"eraser": "Viskelêr",
"frame": "",
"hand": "",
"extraTools": ""
"hand": ""
},
"headings": {
"canvasActions": "Handlingar: lerret",

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