Compare commits

...

23 Commits

Author SHA1 Message Date
barnabasmolnar 502cee7b7a Added MainMenu.Sub docs. 2023-07-27 13:35:24 +02:00
Aakansha Doshi e57dc405fa chore: add style-loader as deps instead of using from react-scripts (#6791) 2023-07-21 13:48:48 +05:30
Aakansha Doshi 41ed019bc2 chore: remove size-limit deps from root package.json (#6790)
* chore: remove size-limit deps from root package.json

* add size limit preset
2023-07-21 13:35:20 +05:30
Ajay Kumbhare f7c3644342 refactor: add typeScript support to enforce valid translation keys (#6776) 2023-07-20 18:15:32 +02:00
Aakansha Doshi 5e3550fc14 ci: structured build output from size-limit (#6788)
* ci: better build output from size-limit

* add size-limit.json

* try with pull request target

* fix

* revert pull request target
2023-07-20 13:54:13 +05:30
Aakansha Doshi 70888327a3 fix: use subdirectory for @excalidraw/excalidraw size limit (#6787)
* fix: use subdirectory for @excalidraw/excalidraw size limit

* fix

* update yml

* update path

* fix

* fix

* better
2023-07-19 22:07:18 +05:30
Aakansha Doshi 9fc15d81a0 ci: introduce bundle size for package @excalidraw/excalidraw (#6785)
* ci: update bundle size limit

* change the size script to track bundle size on the package excalidraw

* fix build command

* fix

* remove

* fix

* update script

* fix
2023-07-19 21:19:10 +05:30
Aakansha Doshi a80ac4c748 ci: add bundle size limit action (#6783)
* ci: add bundle size limit action

* chore: fix lint

* ci: fix

* ci: fix workflow

* ci: fix workflow

* add size limit deps

* use node 18

---------

Co-authored-by: Nitin Kumar <nitin.kumar@razorpay.com>
2023-07-19 13:55:50 +05:30
David Luzar 9f76f8677b feat: cache most of element selection (#6747) 2023-07-17 01:09:44 +02:00
David Luzar 2e46e27490 fix: use actual dock state to not close docked library on insert (#6766) 2023-07-14 20:21:02 +02:00
David Luzar cf0413338e feat: support customizing what parts of frames are rendered (#6752) 2023-07-10 17:13:44 +02:00
David Luzar 49e4289878 feat: make appState.selectedElementIds more stable (#6745) 2023-07-08 23:33:34 +02:00
zsviczian 3ddcc48e4c fix: UI disappears when pressing the eyedropper shortcut on mobile (#6725) 2023-06-29 12:39:44 +02:00
Barnabás Molnár 29a5e982c3 feat: support scrollToContent opts.fitToViewport (#6581)
Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Arnošt Pleskot <arnostpleskot@gmail.com>
2023-06-29 12:36:38 +02:00
Christopher Chedeau b33fa6d6f6 fix: stronger enforcement of normalizeLink (#6728)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-06-29 12:14:42 +02:00
Ryan Di b7350f9707 fix: elements in non-existing frame getting removed (#6708)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-06-22 22:10:08 +00:00
David Luzar 8dfa2a98bb fix: scrollbars renders but disable (#6706) 2023-06-22 14:35:01 +02:00
Wu Kai fb01ce2a00 fix: typo in chart.ts (#6696)
fix: typo
2023-06-21 11:43:37 +05:30
Excalidraw Bot 3d57112480 chore: Update translations from Crowdin (#6677) 2023-06-19 17:10:09 +02:00
Are 7558a4e2be feat: overwrite confirmation dialogs (#6658)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-06-19 17:08:12 +02:00
Aakansha Doshi 6d56634289 fix: do not bind text to container using text tool when it has text already (#6694)
* fix: do not bind text to container using text tool when it has text already

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

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

* enviroment vars clean up

* fix: lint for index.html
2023-06-19 11:18:28 +02:00
Aakansha Doshi 7f7128ec09 fix: don't allow binding text to images (#6693) 2023-06-19 13:47:28 +05:30
148 changed files with 4156 additions and 4633 deletions
+1 -5
View File
@@ -20,14 +20,10 @@ REACT_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
REACT_APP_DISABLE_TRACKING=true
FAST_REFRESH=false
# MATOMO
REACT_APP_MATOMO_URL=
REACT_APP_CDN_MATOMO_TRACKER_URL=
REACT_APP_MATOMO_SITE_ID=
#Debug flags
# To enable bounding box for text containers
+1 -10
View File
@@ -11,14 +11,5 @@ REACT_APP_WS_SERVER_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
# production-only vars
# GOOGLE ANALYTICS
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
# MATOMO
REACT_APP_MATOMO_URL=https://excalidraw.matomo.cloud/
REACT_APP_CDN_MATOMO_TRACKER_URL=//cdn.matomo.cloud/excalidraw.matomo.cloud/matomo.js
REACT_APP_MATOMO_SITE_ID=1
REACT_APP_PLUS_APP=https://app.excalidraw.com
REACT_APP_DISABLE_TRACKING=
+30
View File
@@ -0,0 +1,30 @@
name: "Bundle Size check @excalidraw/excalidraw"
on:
pull_request:
branches:
- master
jobs:
size:
runs-on: ubuntu-latest
env:
CI_JOB_NUMBER: 1
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Node.js 18.x
uses: actions/setup-node@v3
with:
node-version: 18.x
- name: Install
run: yarn --frozen-lockfile
- name: Install in src/packages/excalidraw
run: yarn --frozen-lockfile
working-directory: src/packages/excalidraw
env:
CI: true
- uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:umd
skip_step: install
directory: src/packages/excalidraw
@@ -165,3 +165,35 @@ function App() {
| Prop | Type | Required | Default | Description |
| --- | --- | :-: | :-: | --- |
| `children ` | `React.ReactNode` | Yes | - | The content of the `Menu Group` |
### MainMenu.Sub
The MainMenu component now supports submenus. To render a submenu, you can use `MainMenu.Sub`, `MainMenu.Sub.Trigger`, `MainMenu.Sub.Content` and `MainMenu.Sub.Item`. Note that `MainMenu.Sub.Trigger` and `MainMenu.Sub.Content` must be direct children of `MainMenu.Sub`.
```jsx live
function App() {
return (
<div style={{ height: "500px" }}>
<Excalidraw>
<MainMenu>
<MainMenu.Sub>
<MainMenu.Sub.Trigger>Submenu</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content>
<MainMenu.Sub.Item
onSelect={() => window.alert("Submenu item 1")}
>
Submenu item 1
</MainMenu.Sub.Item>
<MainMenu.Sub.Item
onSelect={() => window.alert("Submenu item 2")}
>
Submenu item 2
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
</MainMenu>
</Excalidraw>
</div>
);
}
```
@@ -306,30 +306,32 @@ This is the history API. history.clear() will clear the history.
## scrollToContent
<pre>
(<br />
{" "}
target?:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>{" "}
&#124;{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>
[],
<br />
{" "}opts?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number
&#125;
<br />) => void
</pre>
```tsx
(
target?: ExcalidrawElement | ExcalidrawElement[],
opts?:
| {
fitToContent?: boolean;
animate?: boolean;
duration?: number;
}
| {
fitToViewport?: boolean;
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
}
) => void
```
Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene.
| Attribute | type | default | Description |
| --- | --- | --- | --- |
| target | <code>ExcalidrawElement &#124; ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. |
| target | [ExcalidrawElement](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) &#124; [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. Note that the zoom range is between 10%-100%. |
| opts.fitToViewport | boolean | false | Similar to fitToContent but the zoom range is not limited. If elements are smaller than the viewport, zoom will go above 100%. |
| opts.viewportZoomFactor | number | 0.7 | when fitToViewport=true, how much screen should the content cover, between 0.1 (10%) and 1 (100%) |
| opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. |
| opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |
+1
View File
@@ -19,6 +19,7 @@
]
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/random-username": "1.0.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2",
+33 -38
View File
@@ -148,33 +148,6 @@
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
<!-- Fathom - privacy-friendly analytics -->
<script
src="https://cdn.usefathom.com/script.js"
data-site="VMSBUEYA"
defer
></script>
<!-- / Fathom -->
<!-- LEGACY GOOGLE ANALYTICS -->
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
</script>
<% } %>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
<style>
@@ -227,17 +200,39 @@
<h1 class="visually-hidden">Excalidraw</h1>
</header>
<div id="root"></div>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
<!-- 100% privacy friendly analytics -->
<script
async
defer
src="https://scripts.simpleanalyticscdn.com/latest.js"
></script>
<noscript
><img
src="https://queue.simpleanalyticscdn.com/noscript.gif"
alt=""
referrerpolicy="no-referrer-when-downgrade"
/></noscript>
<script>
// need to load this script dynamically bcs. of iframe embed tracking
var scriptEle = document.createElement("script");
scriptEle.setAttribute(
"src",
"https://scripts.simpleanalyticscdn.com/latest.js",
);
scriptEle.setAttribute("type", "text/javascript");
scriptEle.setAttribute("defer", true);
scriptEle.setAttribute("async", true);
// if iframe
if (window.self !== window.top) {
scriptEle.setAttribute("data-auto-collect", true);
}
document.body.appendChild(scriptEle);
// if iframe
if (window.self !== window.top) {
scriptEle.addEventListener("load", () => {
if (window.sa_pageview) {
window.window.sa_event(action, {
category: "iframe",
label: "embed",
value: window.location.pathname,
});
}
});
}
</script>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body>
</html>
+5 -10
View File
@@ -1,6 +1,4 @@
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";
@@ -9,14 +7,11 @@ export const actionAddToLibrary = register({
name: "addToLibrary",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
if (selectedElements.some((element) => element.type === "image")) {
return {
commitToHistory: false,
+32 -34
View File
@@ -13,19 +13,18 @@ import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
const alignActionsPredicate = (
elements: readonly ExcalidrawElement[],
appState: AppState,
_: unknown,
app: AppClassProperties,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const selectedElements = app.scene.getSelectedElements(appState);
return (
selectedElements.length > 1 &&
// TODO enable aligning frames when implemented properly
@@ -36,12 +35,10 @@ const alignActionsPredicate = (
const alignSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
alignment: Alignment,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const selectedElements = app.scene.getSelectedElements(appState);
const updatedElements = alignElements(selectedElements, alignment);
@@ -50,6 +47,7 @@ const alignSelectedElements = (
return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
app,
);
};
@@ -57,10 +55,10 @@ export const actionAlignTop = register({
name: "alignTop",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, {
elements: alignSelectedElements(elements, appState, app, {
position: "start",
axis: "y",
}),
@@ -69,9 +67,9 @@ export const actionAlignTop = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState)}
hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button"
icon={AlignTopIcon}
onClick={() => updateData(null)}
@@ -88,10 +86,10 @@ export const actionAlignBottom = register({
name: "alignBottom",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, {
elements: alignSelectedElements(elements, appState, app, {
position: "end",
axis: "y",
}),
@@ -100,9 +98,9 @@ export const actionAlignBottom = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState)}
hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button"
icon={AlignBottomIcon}
onClick={() => updateData(null)}
@@ -119,10 +117,10 @@ export const actionAlignLeft = register({
name: "alignLeft",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, {
elements: alignSelectedElements(elements, appState, app, {
position: "start",
axis: "x",
}),
@@ -131,9 +129,9 @@ export const actionAlignLeft = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState)}
hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button"
icon={AlignLeftIcon}
onClick={() => updateData(null)}
@@ -150,10 +148,10 @@ export const actionAlignRight = register({
name: "alignRight",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, {
elements: alignSelectedElements(elements, appState, app, {
position: "end",
axis: "x",
}),
@@ -162,9 +160,9 @@ export const actionAlignRight = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState)}
hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button"
icon={AlignRightIcon}
onClick={() => updateData(null)}
@@ -181,19 +179,19 @@ export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, {
elements: alignSelectedElements(elements, appState, app, {
position: "center",
axis: "y",
}),
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState)}
hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button"
icon={CenterVerticallyIcon}
onClick={() => updateData(null)}
@@ -208,19 +206,19 @@ export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, {
elements: alignSelectedElements(elements, appState, app, {
position: "center",
axis: "x",
}),
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState)}
hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button"
icon={CenterHorizontallyIcon}
onClick={() => updateData(null)}
+15 -24
View File
@@ -4,7 +4,7 @@ import {
VERTICAL_ALIGN,
TEXT_ALIGN,
} from "../constants";
import { getNonDeletedElements, isTextElement, newElement } from "../element";
import { 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,16 +38,13 @@ export const actionUnbindText = register({
name: "unbindText",
contextItemLabel: "labels.unbindText",
trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return selectedElements.some((element) => hasBoundTextElement(element));
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
@@ -92,8 +89,8 @@ export const actionBindText = register({
name: "bindText",
contextItemLabel: "labels.bindText",
trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.length === 2) {
const textElement =
@@ -116,11 +113,8 @@ export const actionBindText = register({
}
return false;
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
let textElement: ExcalidrawTextElement;
let container: ExcalidrawTextContainer;
@@ -200,18 +194,15 @@ export const actionWrapTextInContainer = register({
name: "wrapTextInContainer",
contextItemLabel: "labels.createContainerFromText",
trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
const areTextElements = selectedElements.every((el) => isTextElement(el));
return selectedElements.length > 0 && areTextElements;
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
const containerIds: AppState["selectedElementIds"] = {};
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
for (const textElement of selectedElements) {
if (isTextElement(textElement)) {
+96 -36
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, getSelectedElements } from "../scene";
import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
@@ -20,7 +20,6 @@ import {
isHandToolActive,
} from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { Bounds } from "../element/bounds";
export const actionChangeViewBackgroundColor = register({
@@ -226,52 +225,93 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue;
};
export const zoomToFitElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
zoomToSelection: boolean,
) => {
const nonDeletedElements = getNonDeletedElements(elements);
const selectedElements = getSelectedElements(nonDeletedElements, appState);
const commonBounds =
zoomToSelection && selectedElements.length > 0
? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements))
: getCommonBounds(
excludeElementsInFramesFromSelection(nonDeletedElements),
);
const newZoom = {
value: zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
}),
};
export const zoomToFit = ({
targetElements,
appState,
fitToViewport = false,
viewportZoomFactor = 0.7,
}: {
targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number;
}) => {
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
let newZoomValue;
let scrollX;
let scrollY;
if (fitToViewport) {
const commonBoundsWidth = x2 - x1;
const commonBoundsHeight = y2 - y1;
newZoomValue =
Math.min(
appState.width / commonBoundsWidth,
appState.height / commonBoundsHeight,
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
// Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min(
Math.max(newZoomValue, 0.1),
30.0,
) as NormalizedZoomValue;
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else {
newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
});
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: { value: newZoomValue },
});
scrollX = centerScroll.scrollX;
scrollY = centerScroll.scrollY;
}
return {
appState: {
...appState,
...centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: newZoom,
}),
zoom: newZoom,
scrollX,
scrollY,
zoom: { value: newZoomValue },
},
commitToHistory: false,
};
};
export const actionZoomToSelected = register({
name: "zoomToSelection",
// Note, this action differs from actionZoomToFitSelection in that it doesn't
// zoom beyond 100%. In other words, if the content is smaller than viewport
// size, it won't be zoomed in.
export const actionZoomToFitSelectionInViewport = register({
name: "zoomToFitSelectionInViewport",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
perform: (elements, appState, _, 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
keyTest: (event) =>
event.code === CODES.TWO &&
event.shiftKey &&
@@ -279,11 +319,31 @@ export const actionZoomToSelected = 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) => zoomToFitElements(elements, appState, false),
perform: (elements, appState) =>
zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
keyTest: (event) =>
event.code === CODES.ONE &&
event.shiftKey &&
+24 -30
View File
@@ -7,7 +7,6 @@ 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";
@@ -16,7 +15,8 @@ export const actionCopy = register({
name: "copy",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const elementsToCopy = getSelectedElements(elements, appState, {
const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
@@ -75,14 +75,11 @@ export const actionCopyAsSvg = register({
commitToHistory: false,
};
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
try {
await exportCanvas(
"clipboard-svg",
@@ -122,14 +119,11 @@ export const actionCopyAsPng = register({
commitToHistory: false,
};
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
try {
await exportCanvas(
"clipboard",
@@ -177,14 +171,11 @@ export const actionCopyAsPng = register({
export const copyText = register({
name: "copyText",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
);
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
const text = selectedElements
.reduce((acc: string[], element) => {
@@ -199,12 +190,15 @@ export const copyText = register({
commitToHistory: false,
};
},
predicate: (elements, appState) => {
predicate: (elements, appState, _, app) => {
return (
probablySupportsClipboardWriteText &&
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).some(isTextElement)
app.scene
.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})
.some(isTextElement)
);
},
contextItemLabel: "labels.copyText",
+15 -22
View File
@@ -9,19 +9,13 @@ import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
selectedElements.length > 1 &&
// TODO enable distributing frames when implemented properly
@@ -32,12 +26,10 @@ const enableActionGroup = (
const distributeSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
distribution: Distribution,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const selectedElements = app.scene.getSelectedElements(appState);
const updatedElements = distributeElements(selectedElements, distribution);
@@ -46,16 +38,17 @@ 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) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: distributeSelectedElements(elements, appState, {
elements: distributeSelectedElements(elements, appState, app, {
space: "between",
axis: "x",
}),
@@ -64,9 +57,9 @@ export const distributeHorizontally = register({
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
hidden={!enableActionGroup(appState, app)}
type="button"
icon={DistributeHorizontallyIcon}
onClick={() => updateData(null)}
@@ -82,10 +75,10 @@ export const distributeHorizontally = register({
export const distributeVertically = register({
name: "distributeVertically",
trackEvent: { category: "element" },
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: distributeSelectedElements(elements, appState, {
elements: distributeSelectedElements(elements, appState, app, {
space: "between",
axis: "y",
}),
@@ -94,9 +87,9 @@ export const distributeVertically = register({
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
hidden={!enableActionGroup(appState, app)}
type="button"
icon={DistributeVerticallyIcon}
onClick={() => updateData(null)}
+2
View File
@@ -274,6 +274,8 @@ const duplicateElements = (
),
},
getNonDeletedElements(finalElements),
appState,
null,
),
};
};
+11 -9
View File
@@ -1,7 +1,6 @@
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";
@@ -11,14 +10,15 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
export const actionToggleElementLock = register({
name: "toggleElementLock",
trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return !selectedElements.some(
(element) => element.locked && element.frameId,
);
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, {
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
@@ -46,8 +46,9 @@ export const actionToggleElementLock = register({
commitToHistory: true,
};
},
contextItemLabel: (elements, appState) => {
const selected = getSelectedElements(elements, appState, {
contextItemLabel: (elements, appState, app) => {
const selected = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
if (selected.length === 1 && selected[0].type !== "frame") {
@@ -60,12 +61,13 @@ export const actionToggleElementLock = register({
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
},
keyTest: (event, appState, elements) => {
keyTest: (event, appState, elements, app) => {
return (
event.key.toLocaleLowerCase() === KEYS.L &&
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
getSelectedElements(elements, appState, {
app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
}).length > 0
);
+5 -5
View File
@@ -65,7 +65,7 @@ export const actionChangeExportScale = register({
);
const scaleButtonTitle = `${t(
"buttons.scale",
"imageExportDialog.label.scale",
)} ${s}x (${width}x${height})`;
return (
@@ -102,7 +102,7 @@ export const actionChangeExportBackground = register({
checked={appState.exportBackground}
onChange={(checked) => updateData(checked)}
>
{t("labels.withBackground")}
{t("imageExportDialog.label.withBackground")}
</CheckboxItem>
),
});
@@ -121,8 +121,8 @@ export const actionChangeExportEmbedScene = register({
checked={appState.exportEmbedScene}
onChange={(checked) => updateData(checked)}
>
{t("labels.exportEmbedScene")}
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
{t("imageExportDialog.label.embedScene")}
<Tooltip label={t("imageExportDialog.tooltip.embedScene")} long={true}>
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
</Tooltip>
</CheckboxItem>
@@ -277,7 +277,7 @@ export const actionExportWithDarkMode = register({
onChange={(theme: Theme) => {
updateData(theme === THEME.DARK);
}}
title={t("labels.toggleExportColorScheme")}
title={t("imageExportDialog.label.darkMode")}
/>
</div>
),
-7
View File
@@ -125,13 +125,6 @@ export const actionFinalize = register({
{ x, y },
);
}
if (
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
) {
appState.selectedElementIds[multiPointElement.id] = true;
}
}
if (
+4 -2
View File
@@ -17,11 +17,12 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
trackEvent: { category: "element" },
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "horizontal"),
appState,
app,
),
appState,
commitToHistory: true,
@@ -34,11 +35,12 @@ export const actionFlipHorizontal = register({
export const actionFlipVertical = register({
name: "flipVertical",
trackEvent: { category: "element" },
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "vertical"),
appState,
app,
),
appState,
commitToHistory: true,
+19 -27
View File
@@ -3,19 +3,12 @@ import { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { AppState } from "../types";
import { AppClassProperties, AppState } from "../types";
import { setCursorForShape, updateActiveTool } from "../utils";
import { register } from "./register";
const isSingleFrameSelected = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
return selectedElements.length === 1 && selectedElements[0].type === "frame";
};
@@ -23,11 +16,8 @@ const isSingleFrameSelected = (
export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame",
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
const selectedFrame = getSelectedElements(
getNonDeletedElements(elements),
appState,
)[0];
perform: (elements, appState, _, app) => {
const selectedFrame = app.scene.getSelectedElements(appState)[0];
if (selectedFrame && selectedFrame.type === "frame") {
const elementsInFrame = getFrameElements(
@@ -55,17 +45,15 @@ export const actionSelectAllElementsInFrame = register({
};
},
contextItemLabel: "labels.selectAllElementsInFrame",
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
});
export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame",
trackEvent: { category: "history" },
perform: (elements, appState) => {
const selectedFrame = getSelectedElements(
getNonDeletedElements(elements),
appState,
)[0];
perform: (elements, appState, _, app) => {
const selectedFrame = app.scene.getSelectedElements(appState)[0];
if (selectedFrame && selectedFrame.type === "frame") {
return {
@@ -87,11 +75,12 @@ export const actionRemoveAllElementsFromFrame = register({
};
},
contextItemLabel: "labels.removeAllElementsFromFrame",
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
});
export const actionToggleFrameRendering = register({
name: "toggleFrameRendering",
export const actionupdateFrameRendering = register({
name: "updateFrameRendering",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
@@ -99,13 +88,16 @@ export const actionToggleFrameRendering = register({
elements,
appState: {
...appState,
shouldRenderFrames: !appState.shouldRenderFrames,
frameRendering: {
...appState.frameRendering,
enabled: !appState.frameRendering.enabled,
},
},
commitToHistory: false,
};
},
contextItemLabel: "labels.toggleFrameRendering",
checked: (appState: AppState) => appState.shouldRenderFrames,
contextItemLabel: "labels.updateFrameRendering",
checked: (appState: AppState) => appState.frameRendering.enabled,
});
export const actionSetFrameAsActiveTool = register({
+29 -22
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 { getSelectedElements, isSomeElementSelected } from "../scene";
import { isSomeElementSelected } from "../scene";
import {
getSelectedGroupIds,
selectGroup,
@@ -22,7 +22,7 @@ import {
ExcalidrawFrameElement,
ExcalidrawTextElement,
} from "../element/types";
import { AppState } from "../types";
import { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,
@@ -51,14 +51,12 @@ const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
);
@@ -68,13 +66,10 @@ export const actionGroup = register({
name: "group",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
if (selectedElements.length < 2) {
// nothing to group
return { appState, elements, commitToHistory: false };
@@ -164,12 +159,13 @@ export const actionGroup = register({
};
},
contextItemLabel: "labels.group",
predicate: (elements, appState) => enableActionGroup(elements, appState),
predicate: (elements, appState, _, app) =>
enableActionGroup(elements, appState, app),
keyTest: (event) =>
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
hidden={!enableActionGroup(elements, appState, app)}
type="button"
icon={<GroupIcon theme={appState.theme} />}
onClick={() => updateData(null)}
@@ -191,7 +187,7 @@ export const actionUngroup = register({
let nextElements = [...elements];
const selectedElements = getSelectedElements(nextElements, appState);
const selectedElements = app.scene.getSelectedElements(appState);
const frames = selectedElements
.filter((element) => element.frameId)
.map((element) =>
@@ -218,6 +214,8 @@ export const actionUngroup = register({
const updateAppState = selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
appState,
null,
);
frames.forEach((frame) => {
@@ -232,9 +230,18 @@ export const actionUngroup = register({
});
// remove binded text elements from selection
boundTextElementIds.forEach(
(id) => (updateAppState.selectedElementIds[id] = false),
updateAppState.selectedElementIds = Object.entries(
updateAppState.selectedElementIds,
).reduce(
(acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => {
if (selected && !boundTextElementIds.includes(id)) {
acc[id] = true;
}
return acc;
},
{},
);
return {
appState: updateAppState,
elements: nextElements,
+11 -19
View File
@@ -1,8 +1,6 @@
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({
@@ -10,21 +8,18 @@ export const actionToggleLinearEditor = register({
trackEvent: {
category: "element",
},
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
return true;
}
return false;
},
perform(elements, appState, _, app) {
const selectedElement = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement;
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id
@@ -38,14 +33,11 @@ export const actionToggleLinearEditor = register({
commitToHistory: false,
};
},
contextItemLabel: (elements, appState) => {
const selectedElement = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement;
contextItemLabel: (elements, appState, app) => {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
return appState.editingLinearElement?.elementId === selectedElement.id
? "labels.lineEditor.exit"
: "labels.lineEditor.edit";
+2
View File
@@ -41,6 +41,8 @@ export const actionSelectAll = register({
selectedElementIds,
},
getNonDeletedElements(elements),
appState,
app,
),
commitToHistory: true,
};
+2
View File
@@ -90,6 +90,7 @@ export class ActionManager {
event,
this.getAppState(),
this.getElementsIncludingDeleted(),
this.app,
),
);
@@ -168,6 +169,7 @@ export class ActionManager {
appState={this.getAppState()}
updateData={updateData}
appProps={this.app.props}
app={this.app}
data={data}
/>
);
+6 -2
View File
@@ -82,7 +82,8 @@ export type ActionName =
| "zoomOut"
| "resetZoom"
| "zoomToFit"
| "zoomToSelection"
| "zoomToFitSelection"
| "zoomToFitSelectionInViewport"
| "changeFontFamily"
| "changeTextAlign"
| "changeVerticalAlign"
@@ -118,7 +119,7 @@ export type ActionName =
| "toggleHandTool"
| "selectAllElementsInFrame"
| "removeAllElementsFromFrame"
| "toggleFrameRendering"
| "updateFrameRendering"
| "setFrameAsActiveTool"
| "createContainerFromText"
| "wrapTextInContainer";
@@ -129,6 +130,7 @@ export type PanelComponentProps = {
updateData: (formData?: any) => void;
appProps: ExcalidrawProps;
data?: Record<string, any>;
app: AppClassProperties;
};
export interface Action {
@@ -140,12 +142,14 @@ 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[],
+5 -14
View File
@@ -5,6 +5,9 @@ export const trackEvent = (
value?: number,
) => {
try {
// place here categories that you want to track as events
// KEEP IN MIND THE PRICING
const ALLOWED_CATEGORIES_TO_TRACK = [] as string[];
// Uncomment the next line to track locally
// console.log("Track Event", { category, action, label, value });
@@ -12,12 +15,8 @@ export const trackEvent = (
return;
}
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) {
window.gtag("event", action, {
event_category: category,
event_label: label,
value,
});
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
return;
}
if (window.sa_event) {
@@ -27,14 +26,6 @@ export const trackEvent = (
value,
});
}
if (window.fathom) {
window.fathom.trackEvent(action, {
category,
label,
value,
});
}
} catch (error) {
console.error("error during analytics", error);
}
+2 -2
View File
@@ -84,7 +84,7 @@ export const getDefaultAppState = (): Omit<
showStats: false,
startBoundElement: null,
suggestedBindings: [],
shouldRenderFrames: true,
frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null,
editingFrame: null,
elementsToHighlight: null,
@@ -191,7 +191,7 @@ const APP_STATE_STORAGE_CONF = (<
showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
shouldRenderFrames: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false },
editingFrame: { browser: false, export: false, server: false },
elementsToHighlight: { browser: false, export: false, server: false },
+3 -3
View File
@@ -180,7 +180,7 @@ const commonProps = {
locked: false,
} as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => {
const getChartDimensions = (spreadsheet: Spreadsheet) => {
const chartWidth =
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
@@ -250,7 +250,7 @@ const chartLines = (
groupId: string,
backgroundColor: string,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const xLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
@@ -313,7 +313,7 @@ const chartBaseElements = (
backgroundColor: string,
debug?: boolean,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const title = spreadsheet.title
? newTextElement({
+360 -247
View File
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@ import {
} from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
import { ColorPaletteCustom } from "../../colors";
import { t } from "../../i18n";
import { TranslationKeys, t } from "../../i18n";
interface PickerColorListProps {
palette: ColorPaletteCustom;
@@ -48,7 +48,11 @@ const PickerColorList = ({
(Array.isArray(value) ? value[activeShade] : value) || "transparent";
const keybinding = colorPickerHotkeyBindings[index];
const label = t(`colors.${key.replace(/\d+/, "")}`, null, "");
const label = t(
`colors.${key.replace(/\d+/, "")}` as unknown as TranslationKeys,
null,
"",
);
return (
<button
+9 -3
View File
@@ -1,6 +1,6 @@
import clsx from "clsx";
import { Popover } from "./Popover";
import { t } from "../i18n";
import { t, TranslationKeys } from "../i18n";
import "./ContextMenu.scss";
import {
@@ -82,9 +82,15 @@ export const ContextMenu = React.memo(
let label = "";
if (item.contextItemLabel) {
if (typeof item.contextItemLabel === "function") {
label = t(item.contextItemLabel(elements, appState));
label = t(
item.contextItemLabel(
elements,
appState,
actionManager.app,
) as unknown as TranslationKeys,
);
} else {
label = t(item.contextItemLabel);
label = t(item.contextItemLabel as unknown as TranslationKeys);
}
}
+20 -4
View File
@@ -17,16 +17,34 @@ import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { jotaiScope } from "../jotai";
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
export interface DialogProps {
children: React.ReactNode;
className?: string;
size?: "small" | "regular" | "wide";
size?: DialogSize;
onCloseRequest(): void;
title: React.ReactNode | false;
autofocus?: boolean;
closeOnClickOutside?: boolean;
}
function getDialogSize(size: DialogSize): number {
if (size && typeof size === "number") {
return size;
}
switch (size) {
case "small":
return 550;
case "wide":
return 1024;
case "regular":
default:
return 800;
}
}
export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
@@ -85,9 +103,7 @@ export const Dialog = (props: DialogProps) => {
<Modal
className={clsx("Dialog", props.className)}
labelledBy="dialog-title"
maxWidth={
props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
}
maxWidth={getDialogSize(props.size)}
onCloseRequest={onClose}
closeOnClickOutside={props.closeOnClickOutside}
>
+138 -42
View File
@@ -2,20 +2,140 @@
.excalidraw {
.ExcButton {
&--color-primary {
color: var(--input-bg-color);
--text-color: transparent;
--border-color: transparent;
--back-color: transparent;
--accent-color: var(--color-primary);
--accent-color-hover: var(--color-primary-darker);
--accent-color-active: var(--color-primary-darkest);
color: var(--text-color);
background-color: var(--back-color);
border-color: var(--border-color);
&--color-primary {
&.ExcButton--variant-filled {
--text-color: var(--input-bg-color);
--back-color: var(--color-primary);
&:hover {
--back-color: var(--color-primary-darker);
}
&:active {
--back-color: var(--color-primary-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-primary);
--border-color: var(--color-primary);
--back-color: var(--input-bg-color);
&:hover {
--text-color: var(--color-primary-darker);
--border-color: var(--color-primary-darker);
}
&:active {
--text-color: var(--color-primary-darkest);
--border-color: var(--color-primary-darkest);
}
}
}
&--color-danger {
color: var(--input-bg-color);
&.ExcButton--variant-filled {
--text-color: var(--color-danger-text);
--back-color: var(--color-danger-dark);
--accent-color: var(--color-danger);
--accent-color-hover: #d65550;
--accent-color-active: #d1413c;
&:hover {
--back-color: var(--color-danger-darker);
}
&:active {
--back-color: var(--color-danger-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-danger);
--border-color: var(--color-danger);
--back-color: transparent;
&:hover {
--text-color: var(--color-danger-darkest);
--border-color: var(--color-danger-darkest);
}
&:active {
--text-color: var(--color-danger-darker);
--border-color: var(--color-danger-darker);
}
}
}
&--color-muted {
&.ExcButton--variant-filled {
--text-color: var(--island-bg-color);
--back-color: var(--color-gray-50);
&:hover {
--back-color: var(--color-gray-60);
}
&:active {
--back-color: var(--color-gray-80);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-muted-background);
--border-color: var(--color-muted);
--back-color: var(--island-bg-color);
&:hover {
--text-color: var(--color-muted-background-darker);
--border-color: var(--color-muted-darker);
}
&:active {
--text-color: var(--color-muted-background-darker);
--border-color: var(--color-muted-darkest);
}
}
}
&--color-warning {
&.ExcButton--variant-filled {
--text-color: black;
--back-color: var(--color-warning-dark);
&:hover {
--back-color: var(--color-warning-darker);
}
&:active {
--back-color: var(--color-warning-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-warning-dark);
--border-color: var(--color-warning-dark);
--back-color: var(--input-bg-color);
&:hover {
--text-color: var(--color-warning-darker);
--border-color: var(--color-warning-darker);
}
&:active {
--text-color: var(--color-warning-darkest);
--border-color: var(--color-warning-darkest);
}
}
}
display: flex;
@@ -25,6 +145,8 @@
flex-wrap: nowrap;
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: "Assistant";
@@ -33,9 +155,9 @@
transition: all 150ms ease-out;
&--size-large {
font-weight: 400;
font-weight: 600;
font-size: 0.875rem;
height: 3rem;
min-height: 3rem;
padding: 0.5rem 1.5rem;
gap: 0.75rem;
@@ -45,48 +167,22 @@
&--size-medium {
font-weight: 600;
font-size: 0.75rem;
height: 2.5rem;
min-height: 2.5rem;
padding: 0.5rem 1rem;
gap: 0.5rem;
letter-spacing: normal;
}
&--variant-filled {
background: var(--accent-color);
border: 1px solid transparent;
&:hover {
background: var(--accent-color-hover);
}
&:active {
background: var(--accent-color-active);
}
}
&--variant-outlined,
&--variant-icon {
border: 1px solid var(--accent-color);
color: var(--accent-color);
background: transparent;
&:hover {
border: 1px solid var(--accent-color-hover);
color: var(--accent-color-hover);
}
&:active {
border: 1px solid var(--accent-color-active);
color: var(--accent-color-active);
}
}
&--variant-icon {
padding: 0.5rem 0.75rem;
width: 3rem;
}
&--fullWidth {
width: 100%;
}
&__icon {
width: 1.25rem;
height: 1.25rem;
+4 -1
View File
@@ -4,7 +4,7 @@ import clsx from "clsx";
import "./FilledButton.scss";
export type ButtonVariant = "filled" | "outlined" | "icon";
export type ButtonColor = "primary" | "danger";
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
export type ButtonSize = "medium" | "large";
export type FilledButtonProps = {
@@ -17,6 +17,7 @@ export type FilledButtonProps = {
color?: ButtonColor;
size?: ButtonSize;
className?: string;
fullWidth?: boolean;
startIcon?: React.ReactNode;
};
@@ -31,6 +32,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
variant = "filled",
color = "primary",
size = "medium",
fullWidth,
className,
},
ref,
@@ -42,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
`ExcButton--color-${color}`,
`ExcButton--variant-${variant}`,
`ExcButton--size-${size}`,
{ "ExcButton--fullWidth": fullWidth },
className,
)}
onClick={onClick}
+6 -13
View File
@@ -1,7 +1,5 @@
import { t } from "../i18n";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene";
import { Device, UIAppState } from "../types";
import { AppClassProperties, Device, UIAppState } from "../types";
import {
isImageElement,
isLinearElement,
@@ -15,17 +13,12 @@ import "./HintViewer.scss";
interface HintViewerProps {
appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[];
isMobile: boolean;
device: Device;
app: AppClassProperties;
}
const getHints = ({
appState,
elements,
isMobile,
device,
}: HintViewerProps) => {
const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
@@ -55,7 +48,7 @@ const getHints = ({
return t("hints.placeImage");
}
const selectedElements = getSelectedElements(elements, appState);
const selectedElements = app.scene.getSelectedElements(appState);
if (
isResizing &&
@@ -115,15 +108,15 @@ const getHints = ({
export const HintViewer = ({
appState,
elements,
isMobile,
device,
app,
}: HintViewerProps) => {
let hint = getHints({
appState,
elements,
isMobile,
device,
app,
});
if (!hint) {
return null;
+17 -2
View File
@@ -41,6 +41,7 @@ import { jotaiScope } from "../jotai";
import { Provider, useAtom, useAtomValue } from "jotai";
import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState";
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
@@ -71,6 +72,7 @@ interface LayerUIProps {
onExportImage: AppClassProperties["onExportImage"];
renderWelcomeScreen: boolean;
children?: React.ReactNode;
app: AppClassProperties;
}
const DefaultMainMenu: React.FC<{
@@ -99,6 +101,15 @@ const DefaultMainMenu: React.FC<{
);
};
const DefaultOverwriteConfirmDialog = () => {
return (
<OverwriteConfirmDialog __fallback>
<OverwriteConfirmDialog.Actions.SaveToDisk />
<OverwriteConfirmDialog.Actions.ExportToImage />
</OverwriteConfirmDialog>
);
};
const LayerUI = ({
actionManager,
appState,
@@ -117,6 +128,7 @@ const LayerUI = ({
onExportImage,
renderWelcomeScreen,
children,
app,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@@ -230,9 +242,9 @@ const LayerUI = ({
>
<HintViewer
appState={appState}
elements={elements}
isMobile={device.isMobile}
device={device}
app={app}
/>
{heading}
<Stack.Row gap={1}>
@@ -343,6 +355,7 @@ const LayerUI = ({
>
{t("toolBar.library")}
</DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog />
{/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />}
@@ -374,6 +387,7 @@ const LayerUI = ({
/>
)}
<ActiveConfirmDialog />
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}
{renderJSONExportDialog()}
{appState.pasteDialog.shown && (
@@ -387,8 +401,9 @@ const LayerUI = ({
}
/>
)}
{device.isMobile && !eyeDropperState && (
{device.isMobile && (
<MobileMenu
app={app}
appState={appState}
elements={elements}
actionManager={actionManager}
+10 -2
View File
@@ -1,5 +1,11 @@
import React from "react";
import { AppState, Device, ExcalidrawProps, UIAppState } from "../types";
import {
AppClassProperties,
AppState,
Device,
ExcalidrawProps,
UIAppState,
} from "../types";
import { ActionManager } from "../actions/manager";
import { t } from "../i18n";
import Stack from "./Stack";
@@ -41,6 +47,7 @@ type MobileMenuProps = {
renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen: boolean;
app: AppClassProperties;
};
export const MobileMenu = ({
@@ -58,6 +65,7 @@ export const MobileMenu = ({
renderSidebars,
device,
renderWelcomeScreen,
app,
}: MobileMenuProps) => {
const {
WelcomeScreenCenterTunnel,
@@ -119,9 +127,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: 10;
z-index: var(--zIndex-modal);
}
.Modal {
@@ -0,0 +1,126 @@
@import "../../css/variables.module";
.excalidraw {
.OverwriteConfirm {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
isolation: isolate;
h3 {
margin: 0;
font-weight: 700;
font-size: 1.3125rem;
line-height: 130%;
align-self: flex-start;
color: var(--text-primary-color);
}
&__Description {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
gap: 1rem;
@include isMobile {
flex-direction: column;
text-align: center;
}
padding: 2.5rem;
background: var(--color-danger-background);
border-radius: 0.5rem;
font-family: "Assistant";
font-style: normal;
font-weight: 400;
font-size: 1rem;
line-height: 150%;
color: var(--color-danger-color);
&__spacer {
flex-grow: 1;
}
&__icon {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2.5rem;
background: var(--color-danger-icon-background);
width: 3.5rem;
height: 3.5rem;
padding: 0.75rem;
svg {
color: var(--color-danger-icon-color);
width: 1.5rem;
height: 1.5rem;
}
}
&.OverwriteConfirm__Description--color-warning {
background: var(--color-warning-background);
color: var(--color-warning-color);
.OverwriteConfirm__Description__icon {
background: var(--color-warning-icon-background);
flex: 0 0 auto;
svg {
color: var(--color-warning-icon-color);
}
}
}
}
&__Actions {
display: flex;
flex-direction: row;
align-items: stretch;
justify-items: stretch;
justify-content: center;
gap: 1.5rem;
@include isMobile {
flex-direction: column;
}
&__Action {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem;
gap: 0.75rem;
flex-basis: 50%;
flex-grow: 0;
&__content {
height: 100%;
font-size: 0.875rem;
text-align: center;
}
h4 {
font-weight: 700;
font-size: 1.125rem;
line-height: 130%;
margin: 0;
color: var(--text-primary-color);
}
}
}
}
}
@@ -0,0 +1,76 @@
import React from "react";
import { useAtom } from "jotai";
import { useTunnels } from "../../context/tunnels";
import { jotaiScope } from "../../jotai";
import { Dialog } from "../Dialog";
import { withInternalFallback } from "../hoc/withInternalFallback";
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
import { FilledButton } from "../FilledButton";
import { alertTriangleIcon } from "../icons";
import { Actions, Action } from "./OverwriteConfirmActions";
import "./OverwriteConfirm.scss";
export type OverwriteConfirmDialogProps = {
children: React.ReactNode;
};
const OverwriteConfirmDialog = Object.assign(
withInternalFallback(
"OverwriteConfirmDialog",
({ children }: OverwriteConfirmDialogProps) => {
const { OverwriteConfirmDialogTunnel } = useTunnels();
const [overwriteConfirmState, setState] = useAtom(
overwriteConfirmStateAtom,
jotaiScope,
);
if (!overwriteConfirmState.active) {
return null;
}
const handleClose = () => {
overwriteConfirmState.onClose();
setState((state) => ({ ...state, active: false }));
};
const handleConfirm = () => {
overwriteConfirmState.onConfirm();
setState((state) => ({ ...state, active: false }));
};
return (
<OverwriteConfirmDialogTunnel.In>
<Dialog onCloseRequest={handleClose} title={false} size={916}>
<div className="OverwriteConfirm">
<h3>{overwriteConfirmState.title}</h3>
<div
className={`OverwriteConfirm__Description OverwriteConfirm__Description--color-${overwriteConfirmState.color}`}
>
<div className="OverwriteConfirm__Description__icon">
{alertTriangleIcon}
</div>
<div>{overwriteConfirmState.description}</div>
<div className="OverwriteConfirm__Description__spacer"></div>
<FilledButton
color={overwriteConfirmState.color}
size="large"
label={overwriteConfirmState.actionLabel}
onClick={handleConfirm}
/>
</div>
<Actions>{children}</Actions>
</div>
</Dialog>
</OverwriteConfirmDialogTunnel.In>
);
},
),
{
Actions,
Action,
},
);
export { OverwriteConfirmDialog };
@@ -0,0 +1,85 @@
import React from "react";
import { FilledButton } from "../FilledButton";
import { useExcalidrawActionManager, useExcalidrawSetAppState } from "../App";
import { actionSaveFileToDisk } from "../../actions";
import { useI18n } from "../../i18n";
import { actionChangeExportEmbedScene } from "../../actions/actionExport";
export type ActionProps = {
title: string;
children: React.ReactNode;
actionLabel: string;
onClick: () => void;
};
export const Action = ({
title,
children,
actionLabel,
onClick,
}: ActionProps) => {
return (
<div className="OverwriteConfirm__Actions__Action">
<h4>{title}</h4>
<div className="OverwriteConfirm__Actions__Action__content">
{children}
</div>
<FilledButton
variant="outlined"
color="muted"
label={actionLabel}
size="large"
fullWidth
onClick={onClick}
/>
</div>
);
};
export const ExportToImage = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const setAppState = useExcalidrawSetAppState();
return (
<Action
title={t("overwriteConfirm.action.exportToImage.title")}
actionLabel={t("overwriteConfirm.action.exportToImage.button")}
onClick={() => {
actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
setAppState({ openDialog: "imageExport" });
}}
>
{t("overwriteConfirm.action.exportToImage.description")}
</Action>
);
};
export const SaveToDisk = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
return (
<Action
title={t("overwriteConfirm.action.saveToDisk.title")}
actionLabel={t("overwriteConfirm.action.saveToDisk.button")}
onClick={() => {
actionManager.executeAction(actionSaveFileToDisk, "ui");
}}
>
{t("overwriteConfirm.action.saveToDisk.description")}
</Action>
);
};
const Actions = Object.assign(
({ children }: { children: React.ReactNode }) => {
return <div className="OverwriteConfirm__Actions">{children}</div>;
},
{
ExportToImage,
SaveToDisk,
},
);
export { Actions };
@@ -0,0 +1,46 @@
import { atom } from "jotai";
import { jotaiStore } from "../../jotai";
import React from "react";
export type OverwriteConfirmState =
| {
active: true;
title: string;
description: React.ReactNode;
actionLabel: string;
color: "danger" | "warning";
onClose: () => void;
onConfirm: () => void;
onReject: () => void;
}
| { active: false };
export const overwriteConfirmStateAtom = atom<OverwriteConfirmState>({
active: false,
});
export async function openConfirmModal({
title,
description,
actionLabel,
color,
}: {
title: string;
description: React.ReactNode;
actionLabel: string;
color: "danger" | "warning";
}) {
return new Promise<boolean>((resolve) => {
jotaiStore.set(overwriteConfirmStateAtom, {
active: true,
onConfirm: () => resolve(true),
onClose: () => resolve(false),
onReject: () => resolve(false),
title,
description,
actionLabel,
color,
});
});
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { t } from "../i18n";
import { useExcalidrawContainer } from "./App";
export const Section: React.FC<{
heading: string;
heading: "canvasActions" | "selectedShapeActions" | "shapes";
children?: React.ReactNode | ((heading: React.ReactNode) => React.ReactNode);
className?: string;
}> = ({ heading, children, ...props }) => {
+91
View File
@@ -0,0 +1,91 @@
@import "../css/variables.module";
.excalidraw {
.ShareableLinkDialog {
display: flex;
flex-direction: column;
gap: 1.5rem;
color: var(--text-primary-color);
::selection {
background: var(--color-primary-light-darker);
}
h3 {
font-family: "Assistant";
font-weight: 700;
font-size: 1.313rem;
line-height: 130%;
margin: 0;
}
&__popover {
@keyframes RoomDialog__popover__scaleIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
box-sizing: border-box;
z-index: 100;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
padding: 0.125rem 0.5rem;
gap: 0.125rem;
height: 1.125rem;
border: none;
border-radius: 0.6875rem;
font-family: "Assistant";
font-style: normal;
font-weight: 600;
font-size: 0.75rem;
line-height: 110%;
background: var(--color-success-lighter);
color: var(--color-success);
& > svg {
width: 0.875rem;
height: 0.875rem;
}
transform-origin: var(--radix-popover-content-transform-origin);
animation: RoomDialog__popover__scaleIn 150ms ease-out;
}
&__linkRow {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 0.75rem;
}
&__description {
border-top: 1px solid var(--color-gray-20);
padding: 0.5rem 0.5rem 0;
font-weight: 400;
font-size: 0.75rem;
line-height: 150%;
& p {
margin: 0;
}
& p + p {
margin-top: 1em;
}
}
}
}
+91
View File
@@ -0,0 +1,91 @@
import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../clipboard";
import { useI18n } from "../i18n";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { FilledButton } from "./FilledButton";
import { copyIcon, tablerCheckIcon } from "./icons";
import "./ShareableLinkDialog.scss";
export type ShareableLinkDialogProps = {
link: string;
onCloseRequest: () => void;
setErrorMessage: (error: string) => void;
};
export const ShareableLinkDialog = ({
link,
onCloseRequest,
setErrorMessage,
}: ShareableLinkDialogProps) => {
const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false);
const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null);
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(link);
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
} catch (error: any) {
setErrorMessage(error.message);
}
ref.current?.select();
};
return (
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
<div className="ShareableLinkDialog">
<h3>Shareable link</h3>
<div className="ShareableLinkDialog__linkRow">
<TextField
ref={ref}
label="Link"
readonly
fullWidth
value={link}
selectOnRender
/>
<Popover.Root open={justCopied}>
<Popover.Trigger asChild>
<FilledButton
size="large"
label="Copy link"
startIcon={copyIcon}
onClick={copyRoomLink}
/>
</Popover.Trigger>
<Popover.Content
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="ShareableLinkDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div>
<div className="ShareableLinkDialog__description">
🔒 {t("alerts.uploadedSecurly")}
</div>
</div>
</Dialog>
);
};
+24 -2
View File
@@ -1,4 +1,10 @@
import { forwardRef, useRef, useImperativeHandle, KeyboardEvent } from "react";
import {
forwardRef,
useRef,
useImperativeHandle,
KeyboardEvent,
useLayoutEffect,
} from "react";
import clsx from "clsx";
import "./TextField.scss";
@@ -12,6 +18,7 @@ export type TextFieldProps = {
readonly?: boolean;
fullWidth?: boolean;
selectOnRender?: boolean;
label?: string;
placeholder?: string;
@@ -19,13 +26,28 @@ export type TextFieldProps = {
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
(
{ value, onChange, label, fullWidth, placeholder, readonly, onKeyDown },
{
value,
onChange,
label,
fullWidth,
placeholder,
readonly,
selectOnRender,
onKeyDown,
},
ref,
) => {
const innerRef = useRef<HTMLInputElement | null>(null);
useImperativeHandle(ref, () => innerRef.current!);
useLayoutEffect(() => {
if (selectOnRender) {
innerRef.current?.select();
}
}, [selectOnRender]);
return (
<div
className={clsx("ExcTextField", {
+1 -1
View File
@@ -6,7 +6,7 @@
Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
position: fixed;
z-index: 1000;
z-index: var(--zIndex-popup);
padding: 8px;
border-radius: 6px;
+9 -5
View File
@@ -3,6 +3,7 @@ import { render } from "@testing-library/react";
import fallbackLangData from "../locales/en.json";
import Trans from "./Trans";
import { TranslationKeys } from "../i18n";
describe("Test <Trans/>", () => {
it("should translate the the strings correctly", () => {
@@ -18,24 +19,27 @@ describe("Test <Trans/>", () => {
const { getByTestId } = render(
<>
<div data-testid="test1">
<Trans i18nKey="transTest.key1" audience="world" />
<Trans
i18nKey={"transTest.key1" as unknown as TranslationKeys}
audience="world"
/>
</div>
<div data-testid="test2">
<Trans
i18nKey="transTest.key2"
i18nKey={"transTest.key2" as unknown as TranslationKeys}
link={(el) => <a href="https://example.com">{el}</a>}
/>
</div>
<div data-testid="test3">
<Trans
i18nKey="transTest.key3"
i18nKey={"transTest.key3" as unknown as TranslationKeys}
link={(el) => <a href="https://example.com">{el}</a>}
location="the button"
/>
</div>
<div data-testid="test4">
<Trans
i18nKey="transTest.key4"
i18nKey={"transTest.key4" as unknown as TranslationKeys}
link={(el) => <a href="https://example.com">{el}</a>}
location="the button"
bold={(el) => <strong>{el}</strong>}
@@ -43,7 +47,7 @@ describe("Test <Trans/>", () => {
</div>
<div data-testid="test5">
<Trans
i18nKey="transTest.key5"
i18nKey={"transTest.key5" as unknown as TranslationKeys}
connect-link={(el) => <a href="https://example.com">{el}</a>}
/>
</div>
+2 -2
View File
@@ -1,6 +1,6 @@
import React from "react";
import { useI18n } from "../i18n";
import { TranslationKeys, useI18n } from "../i18n";
// Used for splitting i18nKey into tokens in Trans component
// Example:
@@ -153,7 +153,7 @@ const Trans = ({
children,
...props
}: {
i18nKey: string;
i18nKey: TranslationKeys;
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
}) => {
const { t } = useI18n();
+10
View File
@@ -1608,6 +1608,16 @@ export const tablerCheckIcon = createIcon(
tablerIconProps,
);
export const alertTriangleIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.24 3.957l-8.422 14.06a1.989 1.989 0 0 0 1.7 2.983h16.845a1.989 1.989 0 0 0 1.7 -2.983l-8.423 -14.06a1.989 1.989 0 0 0 -3.4 0z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</>,
tablerIconProps,
);
export const eyeDropperIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
+29 -2
View File
@@ -1,6 +1,10 @@
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { useI18n } from "../../i18n";
import { useExcalidrawSetAppState, useExcalidrawActionManager } from "../App";
import {
useExcalidrawSetAppState,
useExcalidrawActionManager,
useExcalidrawElements,
} from "../App";
import {
ExportIcon,
ExportImageIcon,
@@ -29,19 +33,42 @@ import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai";
import { useUIAppState } from "../../context/ui-appState";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans";
export const LoadScene = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const elements = useExcalidrawElements();
if (!actionManager.isActionEnabled(actionLoadScene)) {
return null;
}
const handleSelect = async () => {
if (
!elements.length ||
(await openConfirmModal({
title: t("overwriteConfirm.modal.loadFromFile.title"),
actionLabel: t("overwriteConfirm.modal.loadFromFile.button"),
color: "warning",
description: (
<Trans
i18nKey="overwriteConfirm.modal.loadFromFile.description"
bold={(text) => <strong>{text}</strong>}
br={() => <br />}
/>
),
}))
) {
actionManager.executeAction(actionLoadScene);
}
};
return (
<DropdownMenuItem
icon={LoadIcon}
onSelect={() => actionManager.executeAction(actionLoadScene)}
onSelect={handleSelect}
data-testid="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
aria-label={t("buttons.load")}
+2
View File
@@ -12,6 +12,7 @@ type TunnelsContextValue = {
FooterCenterTunnel: Tunnel;
DefaultSidebarTriggerTunnel: Tunnel;
DefaultSidebarTabTriggersTunnel: Tunnel;
OverwriteConfirmDialogTunnel: Tunnel;
jotaiScope: symbol;
};
@@ -30,6 +31,7 @@ export const useInitializeTunnels = () => {
FooterCenterTunnel: tunnel(),
DefaultSidebarTriggerTunnel: tunnel(),
DefaultSidebarTabTriggersTunnel: tunnel(),
OverwriteConfirmDialogTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);
+9
View File
@@ -5,6 +5,15 @@
--zIndex-canvas: 1;
--zIndex-wysiwyg: 2;
--zIndex-layerUI: 3;
--zIndex-modal: 1000;
--zIndex-popup: 1001;
--zIndex-toast: 999999;
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
--sar: env(safe-area-inset-right);
--sat: env(safe-area-inset-top);
}
.excalidraw {
+45 -4
View File
@@ -27,10 +27,6 @@
--popup-secondary-bg-color: #{$oc-gray-1};
--popup-text-color: #{$oc-black};
--popup-text-inverted-color: #{$oc-white};
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
--sar: env(safe-area-inset-right);
--sat: env(safe-area-inset-top);
--select-highlight-color: #{$oc-blue-5};
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
@@ -99,9 +95,33 @@
--color-gray-100: #121212;
--color-warning: #fceeca;
--color-warning-dark: #f5c354;
--color-warning-darker: #f3ab2c;
--color-warning-darkest: #ec8b14;
--color-text-warning: var(--text-primary-color);
--color-danger: #db6965;
--color-danger-dark: #db6965;
--color-danger-darker: #d65550;
--color-danger-darkest: #d1413c;
--color-danger-text: black;
--color-danger-background: #fff0f0;
--color-danger-icon-background: #ffdad6;
--color-danger-color: #700000;
--color-danger-icon-color: #700000;
--color-warning-background: var(--color-warning);
--color-warning-icon-background: var(--color-warning-dark);
--color-warning-color: var(--text-primary-color);
--color-warning-icon-color: var(--text-primary-color);
--color-muted: var(--color-gray-30);
--color-muted-darker: var(--color-gray-60);
--color-muted-darkest: var(--color-gray-100);
--color-muted-background: var(--color-gray-80);
--color-muted-background-darker: var(--color-gray-100);
--color-promo: #e70078;
--color-success: #268029;
--color-success-lighter: #cafccc;
@@ -177,6 +197,27 @@
--color-text-warning: var(--color-gray-80);
--color-danger: #ffa8a5;
--color-danger-dark: #672120;
--color-danger-darker: #8f2625;
--color-danger-darkest: #ac2b29;
--color-danger-text: #fbcbcc;
--color-danger-background: #fbcbcc;
--color-danger-icon-background: #672120;
--color-danger-color: #261919;
--color-danger-icon-color: #fbcbcc;
--color-warning-background: var(--color-warning);
--color-warning-icon-background: var(--color-warning-dark);
--color-warning-color: var(--color-gray-80);
--color-warning-icon-color: var(--color-gray-80);
--color-muted: var(--color-gray-80);
--color-muted-darker: var(--color-gray-60);
--color-muted-darkest: var(--color-gray-20);
--color-muted-background: var(--color-gray-40);
--color-muted-background-darker: var(--color-gray-20);
--color-promo: #d297ff;
}
}
+24 -1
View File
@@ -41,6 +41,7 @@ import {
measureBaseline,
} from "../element/textElement";
import { COLOR_PALETTE } from "../colors";
import { normalizeLink } from "./url";
type RestoredAppState = Omit<
AppState,
@@ -142,7 +143,7 @@ const restoreElementWithProperties = <
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
: element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ?? null,
link: element.link ? normalizeLink(element.link) : null,
locked: element.locked ?? false,
};
@@ -370,6 +371,24 @@ const repairBoundElement = (
}
};
/**
* Remove an element's frameId if its containing frame is non-existent
*
* NOTE mutates elements.
*/
const repairFrameMembership = (
element: Mutable<ExcalidrawElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
if (element.frameId) {
const containingFrame = elementsMap.get(element.frameId);
if (!containingFrame) {
element.frameId = null;
}
}
};
export const restoreElements = (
elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
@@ -410,6 +429,10 @@ export const restoreElements = (
// repair binding. Mutates elements.
const restoredElementsMap = arrayToMap(restoredElements);
for (const element of restoredElements) {
if (element.frameId) {
repairFrameMembership(element, restoredElementsMap);
}
if (isTextElement(element) && element.containerId) {
repairBoundElement(element, restoredElementsMap);
} else if (element.boundElements) {
+30
View File
@@ -0,0 +1,30 @@
import { normalizeLink } from "./url";
describe("normalizeLink", () => {
// NOTE not an extensive XSS test suite, just to check if we're not
// regressing in sanitization
it("should sanitize links", () => {
expect(
// eslint-disable-next-line no-script-url
normalizeLink(`javascript://%0aalert(document.domain)`).startsWith(
// eslint-disable-next-line no-script-url
`javascript:`,
),
).toBe(false);
expect(normalizeLink("ola")).toBe("ola");
expect(normalizeLink(" ola")).toBe("ola");
expect(normalizeLink("https://www.excalidraw.com")).toBe(
"https://www.excalidraw.com",
);
expect(normalizeLink("www.excalidraw.com")).toBe("www.excalidraw.com");
expect(normalizeLink("/ola")).toBe("/ola");
expect(normalizeLink("http://test")).toBe("http://test");
expect(normalizeLink("ftp://test")).toBe("ftp://test");
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
expect(normalizeLink("[[test]]")).toBe("[[test]]");
expect(normalizeLink("<test>")).toBe("<test>");
});
});
+9
View File
@@ -0,0 +1,9 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
export const normalizeLink = (link: string) => {
return sanitizeUrl(link);
};
export const isLocalLink = (link: string | null) => {
return !!(link?.includes(location.origin) || link?.startsWith("/"));
};
+9 -17
View File
@@ -29,6 +29,7 @@ import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip";
import { getSelectedElements } from "../scene";
import { isPointHittingElementBoundingBox } from "./collision";
import { getElementAbsoluteCoords } from "./";
import { isLocalLink, normalizeLink } from "../data/url";
import "./Hyperlink.scss";
import { trackEvent } from "../analytics";
@@ -166,7 +167,7 @@ export const Hyperlink = ({
/>
) : (
<a
href={element.link || ""}
href={normalizeLink(element.link || "")}
className={clsx("excalidraw-hyperlinkContainer-link", {
"d-none": isEditing,
})}
@@ -177,7 +178,13 @@ export const Hyperlink = ({
EVENT.EXCALIDRAW_LINK,
event.nativeEvent,
);
onLinkOpen(element, customEvent);
onLinkOpen(
{
...element,
link: normalizeLink(element.link),
},
customEvent,
);
if (customEvent.defaultPrevented) {
event.preventDefault();
}
@@ -231,21 +238,6 @@ const getCoordsForPopover = (
return { x, y };
};
export const normalizeLink = (link: string) => {
link = link.trim();
if (link) {
// prefix with protocol if not fully-qualified
if (!link.includes("://") && !/^[[\\/]/.test(link)) {
link = `https://${link}`;
}
}
return link;
};
export const isLocalLink = (link: string | null) => {
return !!(link?.includes(location.origin) || link?.startsWith("/"));
};
export const actionLink = register({
name: "hyperlink",
perform: (elements, appState) => {
-1
View File
@@ -862,7 +862,6 @@ const VALID_CONTAINER_TYPES = new Set([
"rectangle",
"ellipse",
"diamond",
"image",
"arrow",
]);
+49
View File
@@ -1459,5 +1459,54 @@ describe("textWysiwyg", () => {
}),
);
});
it("shouldn't bind to container if container has bound text not centered and text tool is used", async () => {
expect(h.elements.length).toBe(1);
Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(2);
// Bind first text
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello!");
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
).toBe(VERTICAL_ALIGN.MIDDLE);
fireEvent.click(screen.getByTitle("Align bottom"));
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
).toBe(VERTICAL_ALIGN.BOTTOM);
// Attempt to Bind 2nd text using text tool
UI.clickTool("text");
mouse.clickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Excalidraw");
editor.blur();
expect(h.elements.length).toBe(3);
expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" },
]);
text = h.elements[2] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(null);
expect(text.text).toBe("Excalidraw");
});
});
});
+9 -9
View File
@@ -30,15 +30,6 @@ describe("Test TypeChecks", () => {
}),
),
).toBeTruthy();
expect(
hasBoundTextElement(
API.createElement({
type: "image",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeTruthy();
});
it("should return false for text bindable containers without bound text", () => {
@@ -62,5 +53,14 @@ describe("Test TypeChecks", () => {
),
).toBeFalsy();
});
expect(
hasBoundTextElement(
API.createElement({
type: "image",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeFalsy();
});
});
-1
View File
@@ -126,7 +126,6 @@ export const isTextBindableContainer = (
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "image" ||
isArrowElement(element))
);
};
-1
View File
@@ -162,7 +162,6 @@ export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawImageElement
| ExcalidrawArrowElement;
export type ExcalidrawTextElementWithContainer = {
@@ -16,7 +16,7 @@ import { MIME_TYPES } from "../../constants";
import { trackEvent } from "../../analytics";
import { getFrame } from "../../utils";
const exportToExcalidrawPlus = async (
export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
+14 -6
View File
@@ -282,11 +282,15 @@ export const loadScene = async (
};
};
type ExportToBackendResult =
| { url: null; errorMessage: string }
| { url: string; errorMessage: null };
export const exportToBackend = async (
elements: readonly ExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
) => {
): Promise<ExportToBackendResult> => {
const encryptionKey = await generateEncryptionKey("string");
const payload = await compressData(
@@ -327,14 +331,18 @@ export const exportToBackend = async (
files: filesToUpload,
});
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
return { url: urlString, errorMessage: null };
} else if (json.error_class === "RequestTooLargeError") {
window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
} else {
window.alert(t("alerts.couldNotCreateShareableLink"));
return {
url: null,
errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
};
}
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
} catch (error: any) {
console.error(error);
window.alert(t("alerts.couldNotCreateShareableLink"));
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
}
};
+135
View File
@@ -0,0 +1,135 @@
declare global {
interface Window {
debug: typeof Debug;
}
}
const lessPrecise = (num: number, precision = 5) =>
parseFloat(num.toPrecision(precision));
const getAvgFrameTime = (times: number[]) =>
lessPrecise(times.reduce((a, b) => a + b) / times.length);
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
export class Debug {
public static DEBUG_LOG_TIMES = true;
private static TIMES_AGGR: Record<string, { t: number; times: number[] }> =
{};
private static TIMES_AVG: Record<
string,
{ t: number; times: number[]; avg: number | null }
> = {};
private static LAST_DEBUG_LOG_CALL = 0;
private static DEBUG_LOG_INTERVAL_ID: null | number = null;
private static setupInterval = () => {
if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
console.info("%c(starting perf recording)", "color: lime");
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
}
Debug.LAST_DEBUG_LOG_CALL = Date.now();
};
private static debugLogger = () => {
if (
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
Debug.DEBUG_LOG_INTERVAL_ID !== null
) {
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
Debug.DEBUG_LOG_INTERVAL_ID = null;
for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
if (avg != null) {
console.info(
`%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
"color: blue",
);
}
}
console.info("%c(stopping perf recording)", "color: red");
Debug.TIMES_AGGR = {};
Debug.TIMES_AVG = {};
return;
}
if (Debug.DEBUG_LOG_TIMES) {
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
if (times.length) {
console.info(
name,
lessPrecise(times.reduce((a, b) => a + b)),
times.sort((a, b) => a - b).map((x) => lessPrecise(x)),
);
Debug.TIMES_AGGR[name] = { t, times: [] };
}
}
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
if (times.length) {
const avgFrameTime = getAvgFrameTime(times);
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
Debug.TIMES_AVG[name] = {
t,
times: [],
avg:
avg != null ? getAvgFrameTime([avg, avgFrameTime]) : avgFrameTime,
};
}
}
}
};
public static logTime = (time?: number, name = "default") => {
Debug.setupInterval();
const now = performance.now();
const { t, times } = (Debug.TIMES_AGGR[name] = Debug.TIMES_AGGR[name] || {
t: 0,
times: [],
});
if (t) {
times.push(time != null ? time : now - t);
}
Debug.TIMES_AGGR[name].t = now;
};
public static logTimeAverage = (time?: number, name = "default") => {
Debug.setupInterval();
const now = performance.now();
const { t, times } = (Debug.TIMES_AVG[name] = Debug.TIMES_AVG[name] || {
t: 0,
times: [],
});
if (t) {
times.push(time != null ? time : now - t);
}
Debug.TIMES_AVG[name].t = now;
};
private static logWrapper =
(type: "logTime" | "logTimeAverage") =>
<T extends any[], R>(fn: (...args: T) => R, name = "default") => {
return (...args: T) => {
const t0 = performance.now();
const ret = fn(...args);
Debug.logTime(performance.now() - t0, name);
return ret;
};
};
public static logTimeWrap = Debug.logWrapper("logTime");
public static logTimeAverageWrap = Debug.logWrapper("logTimeAverage");
public static perfWrap = <T extends any[], R>(
fn: (...args: T) => R,
name = "default",
) => {
return (...args: T) => {
// eslint-disable-next-line no-console
console.time(name);
const ret = fn(...args);
// eslint-disable-next-line no-console
console.timeEnd(name);
return ret;
};
};
}
window.debug = Debug;
+67 -9
View File
@@ -69,7 +69,10 @@ import {
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks";
@@ -88,6 +91,10 @@ import { appJotaiStore } from "./app-jotai";
import "./index.scss";
import { ResolutionType } from "../utility-types";
import { ShareableLinkDialog } from "../components/ShareableLinkDialog";
import { openConfirmModal } from "../components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "../components/OverwriteConfirm/OverwriteConfirm";
import Trans from "../components/Trans";
polyfill();
@@ -98,6 +105,19 @@ languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"),
description: (
<Trans
i18nKey="overwriteConfirm.modal.shareableLink.description"
bold={(text) => <strong>{text}</strong>}
br={() => <br />}
/>
),
actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
color: "danger",
} as const;
const initializeScene = async (opts: {
collabAPI: CollabAPI | null;
excalidrawAPI: ExcalidrawImperativeAPI;
@@ -129,7 +149,7 @@ const initializeScene = async (opts: {
// don't prompt for collab scenes because we don't override local storage
roomLinkData ||
// otherwise, prompt whether user wants to override current scene
window.confirm(t("alerts.loadSceneOverridePrompt"))
(await openConfirmModal(shareableLinkConfirmDialog))
) {
if (jsonBackendMatch) {
scene = await loadScene(
@@ -168,7 +188,7 @@ const initializeScene = async (opts: {
const data = await loadFromBlob(await request.blob(), null, null);
if (
!scene.elements.length ||
window.confirm(t("alerts.loadSceneOverridePrompt"))
(await openConfirmModal(shareableLinkConfirmDialog))
) {
return { scene: data, isExternalScene };
}
@@ -554,6 +574,10 @@ const ExcalidrawWrapper = () => {
}
};
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
null,
);
const onExportToBackend = async (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>,
@@ -565,7 +589,7 @@ const ExcalidrawWrapper = () => {
}
if (canvas) {
try {
await exportToBackend(
const { url, errorMessage } = await exportToBackend(
exportedElements,
{
...appState,
@@ -575,6 +599,14 @@ const ExcalidrawWrapper = () => {
},
files,
);
if (errorMessage) {
setErrorMessage(errorMessage);
}
if (url) {
setLatestShareableLink(url);
}
} catch (error: any) {
if (error.name !== "AbortError") {
const { width, height } = canvas;
@@ -674,21 +706,47 @@ const ExcalidrawWrapper = () => {
setCollabDialogShown={setCollabDialogShown}
isCollabEnabled={!isCollabDisabled}
/>
<OverwriteConfirmDialog>
<OverwriteConfirmDialog.Actions.ExportToImage />
<OverwriteConfirmDialog.Actions.SaveToDisk />
{excalidrawAPI && (
<OverwriteConfirmDialog.Action
title={t("overwriteConfirm.action.excalidrawPlus.title")}
actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
onClick={() => {
exportToExcalidrawPlus(
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(),
);
}}
>
{t("overwriteConfirm.action.excalidrawPlus.description")}
</OverwriteConfirmDialog.Action>
)}
</OverwriteConfirmDialog>
<AppFooter />
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")}
</div>
)}
{latestShareableLink && (
<ShareableLinkDialog
link={latestShareableLink}
onCloseRequest={() => setLatestShareableLink(null)}
setErrorMessage={setErrorMessage}
/>
)}
{excalidrawAPI && !isCollabDisabled && (
<Collab excalidrawAPI={excalidrawAPI} />
)}
{errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}
</ErrorDialog>
)}
</Excalidraw>
{errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}
</ErrorDialog>
)}
</div>
);
};
+12 -4
View File
@@ -16,7 +16,7 @@ import {
} from "./element/textElement";
import { arrayToMap, findIndex } from "./utils";
import { mutateElement } from "./element/mutateElement";
import { AppState } from "./types";
import { AppClassProperties, AppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { isFrameElement } from "./element";
import { moveOneRight } from "./zindex";
@@ -304,7 +304,7 @@ export const groupsAreCompletelyOutOfFrame = (
/**
* Returns a map of frameId to frame elements. Includes empty frames.
*/
export const groupByFrames = (elements: ExcalidrawElementsIncludingDeleted) => {
export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
const frameElementsMap = new Map<
ExcalidrawElement["id"],
ExcalidrawElement[]
@@ -571,8 +571,13 @@ export const replaceAllElementsInFrame = (
export const updateFrameMembershipOfSelectedElements = (
allElements: ExcalidrawElementsIncludingDeleted,
appState: AppState,
app: AppClassProperties,
) => {
const selectedElements = getSelectedElements(allElements, appState);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
// supplying elements explicitly in case we're passed non-state elements
elements: allElements,
});
const elementsToFilter = new Set<ExcalidrawElement>(selectedElements);
if (appState.editingGroupId) {
@@ -591,6 +596,7 @@ export const updateFrameMembershipOfSelectedElements = (
elementsToFilter.forEach((element) => {
if (
element.frameId &&
!isFrameElement(element) &&
!isElementInFrame(element, allElements, appState)
) {
@@ -598,7 +604,9 @@ export const updateFrameMembershipOfSelectedElements = (
}
});
return removeElementsFromFrame(allElements, [...elementsToRemove], appState);
return elementsToRemove.size > 0
? removeElementsFromFrame(allElements, [...elementsToRemove], appState)
: allElements;
};
/**
+35 -5
View File
@@ -1,7 +1,13 @@
import { GroupId, ExcalidrawElement, NonDeleted } from "./element/types";
import { AppState } from "./types";
import {
GroupId,
ExcalidrawElement,
NonDeleted,
NonDeletedExcalidrawElement,
} from "./element/types";
import { AppClassProperties, AppState } from "./types";
import { getSelectedElements } from "./scene";
import { getBoundTextElement } from "./element/textElement";
import { makeNextSelectedElementIds } from "./scene/selection";
export const selectGroup = (
groupId: GroupId,
@@ -66,14 +72,33 @@ export const getSelectedGroupIds = (appState: AppState): GroupId[] =>
*/
export const selectGroupsForSelectedElements = (
appState: AppState,
elements: readonly NonDeleted<ExcalidrawElement>[],
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,
): AppState => {
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
const selectedElements = getSelectedElements(elements, appState);
const selectedElements = app
? app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
// supplying elements explicitly in case we're passed non-state elements
elements,
})
: getSelectedElements(elements, appState);
if (!selectedElements.length) {
return { ...nextAppState, editingGroupId: null };
return {
...nextAppState,
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds(
nextAppState.selectedElementIds,
prevAppState,
),
};
}
for (const selectedElement of selectedElements) {
@@ -91,6 +116,11 @@ export const selectGroupsForSelectedElements = (
}
}
nextAppState.selectedElementIds = makeNextSelectedElementIds(
nextAppState.selectedElementIds,
prevAppState,
);
return nextAppState;
};
+4 -1
View File
@@ -3,6 +3,7 @@ import percentages from "./locales/percentages.json";
import { ENV } from "./constants";
import { jotaiScope, jotaiStore } from "./jotai";
import { atom, useAtomValue } from "jotai";
import { NestedKeyOf } from "./utility-types";
const COMPLETION_THRESHOLD = 85;
@@ -12,6 +13,8 @@ export interface Language {
rtl?: boolean;
}
export type TranslationKeys = NestedKeyOf<typeof fallbackLangData>;
export const defaultLang = { code: "en", label: "English" };
export const languages: Language[] = [
@@ -123,7 +126,7 @@ const findPartsForData = (data: any, parts: string[]) => {
};
export const t = (
path: string,
path: NestedKeyOf<typeof fallbackLangData>,
replacement?: { [key: string]: string | number } | null,
fallback?: string,
) => {
+1
View File
@@ -10,6 +10,7 @@ export const CODES = {
BRACKET_LEFT: "BracketLeft",
ONE: "Digit1",
TWO: "Digit2",
THREE: "Digit3",
NINE: "Digit9",
QUOTE: "Quote",
ZERO: "Digit0",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "نُشر",
"sidebarLock": "إبقاء الشريط الجانبي مفتوح",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "وضع القلم - امنع اللمس",
"link": "إضافة/تحديث الرابط للشكل المحدد",
"eraser": "ممحاة",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "إجراءات اللوحة",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Действия по платното",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "প্রকাশিত",
"sidebarLock": "লক",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "একটি নির্বাচিত আকৃতির জন্য লিঙ্ক যোগ বা আপডেট করুন",
"eraser": "ঝাড়ন",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "ক্যানভাস কার্যকলাপ",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicat",
"sidebarLock": "Manté la barra lateral oberta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Mode de llapis - evita tocar",
"link": "Afegeix / actualitza l'enllaç per a la forma seleccionada",
"eraser": "Esborrador",
"hand": "Mà (eina de desplaçament)"
"frame": "",
"hand": "Mà (eina de desplaçament)",
"extraTools": ""
},
"headings": {
"canvasActions": "Accions del llenç",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Zveřejněno",
"sidebarLock": "Ponechat postranní panel otevřený",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "Vyberte barvu z plátna"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Režim Pera - zabránit dotyku",
"link": "Přidat/aktualizovat odkaz pro vybraný tvar",
"eraser": "Guma",
"hand": "Ruka (nástroj pro posouvání)"
"frame": "",
"hand": "Ruka (nástroj pro posouvání)",
"extraTools": ""
},
"headings": {
"canvasActions": "Akce plátna",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Veröffentlicht",
"sidebarLock": "Seitenleiste offen lassen",
"selectAllElementsInFrame": "Alle Elemente im Rahmen auswählen",
"removeAllElementsFromFrame": "Alle Elemente aus dem Rahmen entfernen",
"eyeDropper": "Farbe von der Zeichenfläche auswählen"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Stift-Modus - Berührung verhindern",
"link": "Link für ausgewählte Form hinzufügen / aktualisieren",
"eraser": "Radierer",
"hand": "Hand (Schwenkwerkzeug)"
"frame": "Rahmenwerkzeug",
"hand": "Hand (Schwenkwerkzeug)",
"extraTools": "Weitere Werkzeuge"
},
"headings": {
"canvasActions": "Aktionen für Zeichenfläche",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Δημοσιευμένο",
"sidebarLock": "Κρατήστε την πλαϊνή μπάρα ανοιχτή",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Λειτουργία μολυβιού - αποτροπή αφής",
"link": "Προσθήκη/ Ενημέρωση συνδέσμου για ένα επιλεγμένο σχήμα",
"eraser": "Γόμα",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Ενέργειες καμβά",
+31
View File
@@ -449,5 +449,36 @@
"shades": "Shades",
"hexCode": "Hex code",
"noShades": "No shades available for this color"
},
"overwriteConfirm": {
"action": {
"exportToImage": {
"title": "Export as image",
"button": "Export as image",
"description": "Export the scene data as an image from which you can import later."
},
"saveToDisk": {
"title": "Save to disk",
"button": "Save to disk",
"description": "Export the scene data to a file from which you can import later."
},
"excalidrawPlus": {
"title": "Excalidraw+",
"button": "Export to Excalidraw+",
"description": "Save the scene to your Excalidraw+ workspace."
}
},
"modal": {
"loadFromFile": {
"title": "Load from file",
"button": "Load from file",
"description": "Loading from a file will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first using one of the options below."
},
"shareableLink": {
"title": "Load from link",
"button": "Replace my content",
"description": "Loading external drawing will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first by using one of the options below."
}
}
}
}
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicado",
"sidebarLock": "Mantener barra lateral abierta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Modo Lápiz - previene toque",
"link": "Añadir/Actualizar enlace para una forma seleccionada",
"eraser": "Borrar",
"hand": "Mano (herramienta de panoramización)"
"frame": "",
"hand": "Mano (herramienta de panoramización)",
"extraTools": ""
},
"headings": {
"canvasActions": "Acciones del lienzo",
+39 -35
View File
@@ -124,7 +124,9 @@
},
"statusPublished": "Argitaratua",
"sidebarLock": "Mantendu alboko barra irekita",
"eyeDropper": ""
"selectAllElementsInFrame": "Hautatu markoko elementu guztiak",
"removeAllElementsFromFrame": "Kendu markoko elementu guztiak",
"eyeDropper": "Aukeratu kolorea oihaletik"
},
"library": {
"noItems": "Oraindik ez da elementurik gehitu...",
@@ -221,7 +223,9 @@
"penMode": "Luma modua - ukipena saihestu",
"link": "Gehitu / Eguneratu esteka hautatutako forma baterako",
"eraser": "Borragoma",
"hand": "Eskua (panoratze tresna)"
"frame": "Marko tresna",
"hand": "Eskua (panoratze tresna)",
"extraTools": "Tresna gehiago"
},
"headings": {
"canvasActions": "Canvas ekintzak",
@@ -356,27 +360,27 @@
"removeItemsFromLib": "Kendu hautatutako elementuak liburutegitik"
},
"imageExportDialog": {
"header": "",
"header": "Esportatu irudia",
"label": {
"withBackground": "",
"onlySelected": "",
"darkMode": "",
"embedScene": "",
"scale": "",
"padding": ""
"withBackground": "Atzeko planoa",
"onlySelected": "Hautapena soilik",
"darkMode": "Modu iluna",
"embedScene": "Txertatu eszena",
"scale": "Eskala",
"padding": "Betegarria"
},
"tooltip": {
"embedScene": ""
"embedScene": "Eszenaren datuak esportatutako PNG/SVG fitxategian gordeko dira, eszena bertatik berrezartzeko.\nEsportatutako fitxategien tamaina handituko da."
},
"title": {
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
"exportToPng": "Esportatu PNG gisa",
"exportToSvg": "Esportatu SVG gisa",
"copyPngToClipboard": "Kopiatu PNG arbelera"
},
"button": {
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
"exportToPng": "PNG",
"exportToSvg": "SVG",
"copyPngToClipboard": "Kopiatu arbelean"
}
},
"encrypted": {
@@ -411,20 +415,20 @@
},
"colors": {
"transparent": "Gardena",
"black": "",
"white": "",
"red": "",
"pink": "",
"grape": "",
"violet": "",
"gray": "",
"blue": "",
"cyan": "",
"teal": "",
"green": "",
"yellow": "",
"orange": "",
"bronze": ""
"black": "Beltza",
"white": "Zuria",
"red": "Gorria",
"pink": "Arrosa",
"grape": "Mahats kolorea",
"violet": "Bioleta",
"gray": "Grisa",
"blue": "Urdina",
"cyan": "Ziana",
"teal": "Berde urdinxka",
"green": "Berdea",
"yellow": "Horia",
"orange": "Laranja",
"bronze": "Brontzea"
},
"welcomeScreen": {
"app": {
@@ -440,10 +444,10 @@
}
},
"colorPicker": {
"mostUsedCustomColors": "",
"colors": "",
"shades": "",
"hexCode": "",
"noShades": ""
"mostUsedCustomColors": "Gehien erabilitako kolore pertsonalizatuak",
"colors": "Koloreak",
"shades": "Ñabardurak",
"hexCode": "Hez kodea",
"noShades": "Kolore honetarako ez dago ñabardurarik eskuragarri"
}
}
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "منتشر شده",
"sidebarLock": "باز نگه داشتن سایدبار",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "حالت قلم - جلوگیری از تماس",
"link": "افزودن/به‌روزرسانی پیوند برای شکل انتخابی",
"eraser": "پاک کن",
"hand": "دست (ابزار پانینگ)"
"frame": "",
"hand": "دست (ابزار پانینگ)",
"extraTools": ""
},
"headings": {
"canvasActions": "عملیات روی بوم",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Julkaistu",
"sidebarLock": "Pidä sivupalkki avoinna",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Kynätila - estä kosketus",
"link": "Lisää/päivitä linkki valitulle muodolle",
"eraser": "Poistotyökalu",
"hand": "Käsi (panning-työkalu)"
"frame": "",
"hand": "Käsi (panning-työkalu)",
"extraTools": ""
},
"headings": {
"canvasActions": "Piirtoalueen toiminnot",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publié",
"sidebarLock": "Maintenir la barre latérale ouverte",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Mode stylo - évite le toucher",
"link": "Ajouter/mettre à jour le lien pour une forme sélectionnée",
"eraser": "Gomme",
"hand": "Mains (outil de déplacement de la vue)"
"frame": "",
"hand": "Mains (outil de déplacement de la vue)",
"extraTools": ""
},
"headings": {
"canvasActions": "Actions du canevas",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicado",
"sidebarLock": "Manter a barra lateral aberta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Modo lapis - evitar o contacto",
"link": "Engadir/ Actualizar ligazón para a forma seleccionada",
"eraser": "Goma de borrar",
"hand": "Man (ferramenta de desprazamento)"
"frame": "",
"hand": "Man (ferramenta de desprazamento)",
"extraTools": ""
},
"headings": {
"canvasActions": "Accións do lenzo",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "פורסם",
"sidebarLock": "שמור את סרגל הצד פתוח",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "מצב עט - מנע נגיעה",
"link": "הוספה/עדכון קישור של הצורה שנבחרה",
"eraser": "מחק",
"hand": "יד (כלי הזזה)"
"frame": "",
"hand": "יד (כלי הזזה)",
"extraTools": ""
},
"headings": {
"canvasActions": "פעולות קנבאס",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "प्रकाशित",
"sidebarLock": "साइडबार खुला रखे.",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "चित्रफलक से रंग चुने"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "पेन का मोड - स्पर्श टाले",
"link": "",
"eraser": "रबड़",
"hand": "हाथ ( खिसकाने का औज़ार)"
"frame": "",
"hand": "हाथ ( खिसकाने का औज़ार)",
"extraTools": ""
},
"headings": {
"canvasActions": "कैनवास क्रिया",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "Hivatkozás hozzáadása/frissítése a kiválasztott alakzathoz",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Vászon műveletek",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Telah terbit",
"sidebarLock": "Biarkan sidebar tetap terbuka",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Mode pena - mencegah sentuhan",
"link": "Tambah/Perbarui tautan untuk bentuk yang dipilih",
"eraser": "Penghapus",
"hand": "Tangan (alat panning)"
"frame": "",
"hand": "Tangan (alat panning)",
"extraTools": ""
},
"headings": {
"canvasActions": "Opsi Kanvas",
+7 -3
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Pubblicato",
"sidebarLock": "Mantieni aperta la barra laterale",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Modalità penna - previene il tocco",
"link": "Aggiungi/ aggiorna il link per una forma selezionata",
"eraser": "Gomma",
"hand": "Mano (strumento di panoramica)"
"frame": "",
"hand": "Mano (strumento di panoramica)",
"extraTools": "Altri strumenti"
},
"headings": {
"canvasActions": "Azioni sulla Tela",
@@ -361,12 +365,12 @@
"withBackground": "Sfondo",
"onlySelected": "Solo selezionato",
"darkMode": "Tema scuro",
"embedScene": "",
"embedScene": "Includi scena",
"scale": "Scala",
"padding": ""
},
"tooltip": {
"embedScene": ""
"embedScene": "I dati della scena saranno salvati nel file PNG/SVG esportato in modo che la scena possa essere ripristinata da esso.\nQuesto aumenterà la dimensione del file esportato."
},
"title": {
"exportToPng": "Esporta come PNG",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "公開済み",
"sidebarLock": "サイドバーを開いたままにする",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "ペンモード - タッチ防止",
"link": "選択した図形のリンクを追加/更新",
"eraser": "消しゴム",
"hand": "手 (パンニングツール)"
"frame": "",
"hand": "手 (パンニングツール)",
"extraTools": ""
},
"headings": {
"canvasActions": "キャンバス操作",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "Óshirgish",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Yeffeɣ-d",
"sidebarLock": "Eǧǧ afeggag n yidis yeldi",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Askar n yimru - gdel tanalit",
"link": "Rnu/leqqem aseɣwen i talɣa yettwafernen",
"eraser": "Sfeḍ",
"hand": "Afus (afecku n usmutti n tmuɣli)"
"frame": "",
"hand": "Afus (afecku n usmutti n tmuɣli)",
"extraTools": ""
},
"headings": {
"canvasActions": "Tigawin n teɣzut n usuneɣ",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "ត្រូវបានបោះពុម្ពផ្សាយ",
"sidebarLock": "ទុករបារចំហៀងបើក",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "របៀបប៊ិច - ជៀសវាងការប៉ះ",
"link": "បន្ថែម/ធ្វើបច្ចុប្បន្នភាពតំណភ្ជាប់សម្រាប់រូបរាងដែលបានជ្រើសរើស",
"eraser": "ជ័រលុប",
"hand": "ដៃ (panning tool)"
"frame": "",
"hand": "ដៃ (panning tool)",
"extraTools": ""
},
"headings": {
"canvasActions": "សកម្មភាពបាវ",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "게시됨",
"sidebarLock": "사이드바 유지",
"selectAllElementsInFrame": "프레임의 모든 요소 선택",
"removeAllElementsFromFrame": "프레임의 모든 요소 삭제",
"eyeDropper": "캔버스에서 색상 고르기"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "펜 모드 - 터치 방지",
"link": "선택한 도형에 대해서 링크를 추가/업데이트",
"eraser": "지우개",
"hand": "손 (패닝 도구)"
"frame": "프레임 도구",
"hand": "손 (패닝 도구)",
"extraTools": "다른 도구"
},
"headings": {
"canvasActions": "캔버스 동작",
+40 -36
View File
@@ -71,7 +71,7 @@
"language": "زمان",
"liveCollaboration": "هاوکاریکردنی زیندو...",
"duplicateSelection": "لەبەرگرتنەوە",
"untitled": "بێ-ناو",
"untitled": "بێ ناونیشان",
"name": "ناو",
"yourName": "ناوەکەت",
"madeWithExcalidraw": "دروستکراوە بە Excalidraw",
@@ -124,7 +124,9 @@
},
"statusPublished": "بڵاوکراوەتەوە",
"sidebarLock": "هێشتنەوەی شریتی لا بە کراوەیی",
"eyeDropper": ""
"selectAllElementsInFrame": "هەموو توخمەکانی ناو چوارچێوەکە دیاری بکە",
"removeAllElementsFromFrame": "هەموو توخمەکانی ناو چوارچێوەکە لابەرە",
"eyeDropper": "ڕەنگێک لەسەر تابلۆکە هەڵبژێرە"
},
"library": {
"noItems": "هێشتا هیچ بڕگەیەک زیاد نەکراوە...",
@@ -221,7 +223,9 @@
"penMode": "شێوازی قەڵەم - دەست لێدان ڕابگرە",
"link": "زیادکردن/ نوێکردنەوەی لینک بۆ شێوەی دیاریکراو",
"eraser": "سڕەر",
"hand": "دەست (ئامرازی پانکردن)"
"frame": "ئامرازی چوارچێوە",
"hand": "دەست (ئامرازی پانکردن)",
"extraTools": "ئامرازی زیاتر"
},
"headings": {
"canvasActions": "کردارەکانی تابلۆ",
@@ -356,27 +360,27 @@
"removeItemsFromLib": "لابردنی ئایتمە دیاریکراوەکان لە کتێبخانە"
},
"imageExportDialog": {
"header": "",
"header": "وێنە هەناردە بکە",
"label": {
"withBackground": "",
"onlySelected": "",
"darkMode": "",
"embedScene": "",
"scale": "",
"padding": ""
"withBackground": "پاشبنەما",
"onlySelected": "تەنها دیاریکراوەکان",
"darkMode": "دۆخی تاریک",
"embedScene": "دیمەنەکە بەکاربهێنەرەوە",
"scale": "قەبارە",
"padding": "بۆشایی"
},
"tooltip": {
"embedScene": ""
"embedScene": "داتاکانی دیمەنەکە لە فایلە هەناردەکراوەکەی PNG/SVG هەڵدەگیرێن بۆ ئەوەی دیمەنەکە لێیەوە بگەڕێتەوە.\nقەبارەی پەڕگەی هەناردەکراو زیاد دەکات."
},
"title": {
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
"exportToPng": "هەناردە بکە وەک PNG",
"exportToSvg": "هەناردە بکە وەک SVG",
"copyPngToClipboard": "لەبەربگرەوە بۆ سەر تەختەنوس"
},
"button": {
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
"exportToPng": "PNG",
"exportToSvg": "SVG",
"copyPngToClipboard": "له‌به‌ری بگره‌وه‌ بۆ ته‌خته‌نووس"
}
},
"encrypted": {
@@ -411,20 +415,20 @@
},
"colors": {
"transparent": "ڕوون",
"black": "",
"white": "",
"red": "",
"pink": "",
"grape": "",
"violet": "",
"gray": "",
"blue": "",
"cyan": "",
"teal": "",
"green": "",
"yellow": "",
"orange": "",
"bronze": ""
"black": "ڕەش",
"white": "سپی",
"red": "سور",
"pink": "پەمەیی",
"grape": "مێوژی",
"violet": "مۆری کاڵ",
"gray": "خۆڵەمێشی",
"blue": "شین",
"cyan": "شینی ئاسمانی",
"teal": "شەدری",
"green": "سه‌وز",
"yellow": "زەرد",
"orange": "پرتەقاڵی",
"bronze": "برۆنزی"
},
"welcomeScreen": {
"app": {
@@ -440,10 +444,10 @@
}
},
"colorPicker": {
"mostUsedCustomColors": "",
"colors": "",
"shades": "",
"hexCode": "",
"noShades": ""
"mostUsedCustomColors": "زۆرترین ڕەنگە باوە بەکارهاتووەکان",
"colors": "ڕەنگەکان",
"shades": "سێبەرەکان",
"hexCode": "کۆدی هێکس",
"noShades": "هیچ سێبەرێک بۆ ئەم ڕەنگە بەردەست نییە"
}
}
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Rašyklio režimas - neleisti prisilietimų",
"link": "Pridėti / Atnaujinti pasirinktos figūros nuorodą",
"eraser": "Trintukas",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Veiksmai su drobe",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicēts",
"sidebarLock": "Paturēt atvērtu sānjoslu",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Pildspalvas režīms novērst pieskaršanos",
"link": "Pievienot/rediģēt atlasītās figūras saiti",
"eraser": "Dzēšgumija",
"hand": "Roka (panoramēšanas rīks)"
"frame": "",
"hand": "Roka (panoramēšanas rīks)",
"extraTools": ""
},
"headings": {
"canvasActions": "Tāfeles darbības",

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