Compare commits

..

9 Commits

Author SHA1 Message Date
Aakansha Doshi
4ed3a2e7be lint 2023-06-07 15:30:57 +05:30
Aakansha Doshi
faec098e30 fix offset in editor 2023-06-07 15:27:39 +05:30
Aakansha Doshi
65e849804d Merge remote-tracking branch 'origin/master' into aakansha-no-trailing-space-render-text-container 2023-06-06 15:07:42 +05:30
Aakansha Doshi
7c0f783cbc Merge remote-tracking branch 'origin/master' into aakansha-no-trailing-space-render-text-container 2023-06-06 13:27:27 +05:30
Aakansha Doshi
fd379c2897 add comment 2023-04-21 13:20:18 +05:30
Aakansha Doshi
97929c07d6 fix tests 2023-04-21 12:20:01 +05:30
Aakansha Doshi
ba22646c22 fix 2023-04-20 21:48:51 +05:30
Aakansha Doshi
c21fecde40 fix element width 2023-04-20 21:45:59 +05:30
Aakansha Doshi
caf0a904db fix: preserve trailing spaces in word wrap 2023-04-20 20:13:01 +05:30
232 changed files with 5970 additions and 12382 deletions

View File

@@ -20,10 +20,14 @@ REACT_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
REACT_APP_DISABLE_TRACKING=true
FAST_REFRESH=false
# MATOMO
REACT_APP_MATOMO_URL=
REACT_APP_CDN_MATOMO_TRACKER_URL=
REACT_APP_MATOMO_SITE_ID=
#Debug flags
# To enable bounding box for text containers

View File

@@ -11,5 +11,14 @@ REACT_APP_WS_SERVER_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
# production-only vars
# GOOGLE ANALYTICS
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
# MATOMO
REACT_APP_MATOMO_URL=https://excalidraw.matomo.cloud/
REACT_APP_CDN_MATOMO_TRACKER_URL=//cdn.matomo.cloud/excalidraw.matomo.cloud/matomo.js
REACT_APP_MATOMO_SITE_ID=1
REACT_APP_PLUS_APP=https://app.excalidraw.com
REACT_APP_DISABLE_TRACKING=

View File

@@ -1,7 +1,7 @@
name: Semantic PR title
on:
pull_request:
pull_request_target:
types:
- opened
- edited

View File

@@ -1,30 +0,0 @@
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

View File

@@ -29,8 +29,6 @@ All `props` are *optional*.
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
| [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
### Storing custom data on Excalidraw elements
@@ -217,6 +215,7 @@ Indicates whether to bind keyboard events to `document`. Disabled by default, me
Enable this if you want Excalidraw to handle keyboard even if the component isn't focused (e.g. a user is interacting with the navbar, sidebar, or similar).
### autoFocus
This prop indicates whether to `focus` the Excalidraw component on page load. Defaults to false.
@@ -229,12 +228,3 @@ Allows you to override `id` generation for files added on canvas (images). By de
(file: File) => string | Promise<string>
```
### validateEmbeddable
```tsx
validateEmbeddable?: boolean | string[] | RegExp | RegExp[] | ((link: string) => boolean | undefined)
```
This is an optional property. By default we support a handful of well-known sites. You may allow additional sites or disallow the default ones by supplying a custom validator. If you pass `true`, all URLs will be allowed. You can also supply a list of hostnames, RegExp (or list of RegExp objects), or a function. If the function returns `undefined`, the built-in validator will be used.
Supplying a list of hostnames (with or without `www.`) is the preferred way to allow a specific list of domains.

View File

@@ -306,32 +306,30 @@ This is the history API. history.clear() will clear the history.
## scrollToContent
```tsx
(
target?: ExcalidrawElement | ExcalidrawElement[],
opts?:
| {
fitToContent?: boolean;
animate?: boolean;
duration?: number;
}
| {
fitToViewport?: boolean;
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
}
) => void
```
<pre>
(<br />
{" "}
target?:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>{" "}
&#124;{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>
[],
<br />
{" "}opts?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number
&#125;
<br />) => void
</pre>
Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene.
| Attribute | type | default | Description |
| --- | --- | --- | --- |
| target | [ExcalidrawElement](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) &#124; [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. Note that the zoom range is between 10%-100%. |
| opts.fitToViewport | boolean | false | Similar to fitToContent but the zoom range is not limited. If elements are smaller than the viewport, zoom will go above 100%. |
| opts.viewportZoomFactor | number | 0.7 | when fitToViewport=true, how much screen should the content cover, between 0.1 (10%) and 1 (100%) |
| target | <code>ExcalidrawElement &#124; ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. |
| opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. |
| opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |

View File

@@ -121,16 +121,3 @@ function App() {
);
}
```
## renderEmbeddable
<pre>
(element: NonDeleted&lt;ExcalidrawEmbeddableElement&gt;, appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>) => JSX.Element | null
</pre>
Allows you to replace the renderer for embeddable elements (which renders `<iframe>` elements).
| Parameter | Type | Description |
| --- | --- | --- |
| `element` | `NonDeleted<ExcalidrawEmbeddableElement>` | The embeddable element to be rendered. |
| `appState` | `AppState` | The current state of the UI. |

View File

@@ -2,11 +2,6 @@
Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change.
We have a [roadmap](https://github.com/orgs/excalidraw/projects/3) which we strongly recommend to go through and check if something interests you.
For new contributors we would recommend to start with *Easy* tasks.
In case you want to pick up something from the roadmap, comment on that issue and one of the project maintainers will assign it to you, post which you can discuss in the issue and start working on it.
## Setup
### Option 1 - Manual

View File

@@ -19,19 +19,14 @@
]
},
"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",
"@radix-ui/react-dropdown-menu": "2.0.4",
"@radix-ui/react-portal": "1.0.2",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.5",
"@tldraw/vec": "1.7.1",
"browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1",
"cross-env": "7.0.3",
"fake-indexeddb": "3.1.7",
@@ -111,7 +106,7 @@
"<rootDir>/src/packages/excalidraw/example"
],
"transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access|canvas-roundrect-polyfill)/)"
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
],
"resetMocks": false
},

View File

@@ -148,6 +148,33 @@
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
<!-- Fathom - privacy-friendly analytics -->
<script
src="https://cdn.usefathom.com/script.js"
data-site="VMSBUEYA"
defer
></script>
<!-- / Fathom -->
<!-- LEGACY GOOGLE ANALYTICS -->
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
</script>
<% } %>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
<style>
@@ -200,39 +227,17 @@
<h1 class="visually-hidden">Excalidraw</h1>
</header>
<div id="root"></div>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
<!-- 100% privacy friendly analytics -->
<script>
// need to load this script dynamically bcs. of iframe embed tracking
var scriptEle = document.createElement("script");
scriptEle.setAttribute(
"src",
"https://scripts.simpleanalyticscdn.com/latest.js",
);
scriptEle.setAttribute("type", "text/javascript");
scriptEle.setAttribute("defer", true);
scriptEle.setAttribute("async", true);
// if iframe
if (window.self !== window.top) {
scriptEle.setAttribute("data-auto-collect", true);
}
document.body.appendChild(scriptEle);
// if iframe
if (window.self !== window.top) {
scriptEle.addEventListener("load", () => {
if (window.sa_pageview) {
window.window.sa_event(action, {
category: "iframe",
label: "embed",
value: window.location.pathname,
});
}
});
}
</script>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
<script
async
defer
src="https://scripts.simpleanalyticscdn.com/latest.js"
></script>
<noscript
><img
src="https://queue.simpleanalyticscdn.com/noscript.gif"
alt=""
referrerpolicy="no-referrer-when-downgrade"
/></noscript>
</body>
</html>

View File

@@ -1,29 +1,27 @@
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";
import { LIBRARY_DISABLED_TYPES } from "../constants";
export const actionAddToLibrary = register({
name: "addToLibrary",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
for (const type of LIBRARY_DISABLED_TYPES) {
if (selectedElements.some((element) => element.type === type)) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t(`errors.libraryElementTypeError.${type}`),
},
};
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
if (selectedElements.some((element) => element.type === "image")) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: "Support for adding images to the library coming soon!",
},
};
}
return app.library

View File

@@ -10,55 +10,44 @@ import {
import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
const alignActionsPredicate = (
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
appState: AppState,
_: unknown,
app: AppClassProperties,
) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
selectedElements.length > 1 &&
// TODO enable aligning frames when implemented properly
!selectedElements.some((el) => el.type === "frame")
);
};
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
const alignSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
alignment: Alignment,
) => {
const selectedElements = app.scene.getSelectedElements(appState);
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const updatedElements = alignElements(selectedElements, alignment);
const updatedElementsMap = arrayToMap(updatedElements);
return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
app,
return elements.map(
(element) => updatedElementsMap.get(element.id) || element,
);
};
export const actionAlignTop = register({
name: "alignTop",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
elements: alignSelectedElements(elements, appState, {
position: "start",
axis: "y",
}),
@@ -67,9 +56,9 @@ export const actionAlignTop = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={AlignTopIcon}
onClick={() => updateData(null)}
@@ -85,11 +74,10 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({
name: "alignBottom",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
elements: alignSelectedElements(elements, appState, {
position: "end",
axis: "y",
}),
@@ -98,9 +86,9 @@ export const actionAlignBottom = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={AlignBottomIcon}
onClick={() => updateData(null)}
@@ -116,11 +104,10 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({
name: "alignLeft",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
elements: alignSelectedElements(elements, appState, {
position: "start",
axis: "x",
}),
@@ -129,9 +116,9 @@ export const actionAlignLeft = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={AlignLeftIcon}
onClick={() => updateData(null)}
@@ -147,11 +134,11 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({
name: "alignRight",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
elements: alignSelectedElements(elements, appState, {
position: "end",
axis: "x",
}),
@@ -160,9 +147,9 @@ export const actionAlignRight = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={AlignRightIcon}
onClick={() => updateData(null)}
@@ -178,20 +165,20 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
elements: alignSelectedElements(elements, appState, {
position: "center",
axis: "y",
}),
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={CenterVerticallyIcon}
onClick={() => updateData(null)}
@@ -205,20 +192,19 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
elements: alignSelectedElements(elements, appState, {
position: "center",
axis: "x",
}),
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={CenterHorizontallyIcon}
onClick={() => updateData(null)}

View File

@@ -4,7 +4,7 @@ import {
VERTICAL_ALIGN,
TEXT_ALIGN,
} from "../constants";
import { isTextElement, newElement } from "../element";
import { getNonDeletedElements, isTextElement, newElement } from "../element";
import { mutateElement } from "../element/mutateElement";
import {
computeBoundTextPosition,
@@ -29,8 +29,8 @@ import {
ExcalidrawTextContainer,
ExcalidrawTextElement,
} from "../element/types";
import { getSelectedElements } from "../scene";
import { AppState } from "../types";
import { Mutable } from "../utility-types";
import { getFontString } from "../utils";
import { register } from "./register";
@@ -38,13 +38,16 @@ export const actionUnbindText = register({
name: "unbindText",
contextItemLabel: "labels.unbindText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return selectedElements.some((element) => hasBoundTextElement(element));
},
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
@@ -89,8 +92,8 @@ export const actionBindText = register({
name: "bindText",
contextItemLabel: "labels.bindText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 2) {
const textElement =
@@ -113,8 +116,11 @@ export const actionBindText = register({
}
return false;
},
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
let textElement: ExcalidrawTextElement;
let container: ExcalidrawTextContainer;
@@ -194,15 +200,18 @@ export const actionWrapTextInContainer = register({
name: "wrapTextInContainer",
contextItemLabel: "labels.createContainerFromText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
const areTextElements = selectedElements.every((el) => isTextElement(el));
return selectedElements.length > 0 && areTextElements;
},
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
const containerIds: AppState["selectedElementIds"] = {};
for (const textElement of selectedElements) {
if (isTextElement(textElement)) {
@@ -240,7 +249,6 @@ export const actionWrapTextInContainer = register({
"rectangle",
),
groupIds: textElement.groupIds,
frameId: textElement.frameId,
});
// update bindings

View File

@@ -6,7 +6,7 @@ import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { getNormalizedZoom } from "../scene";
import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
@@ -20,7 +20,6 @@ import {
isHandToolActive,
} from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { Bounds } from "../element/bounds";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@@ -207,7 +206,7 @@ export const actionResetZoom = register({
});
const zoomValueToFitBoundsOnViewport = (
bounds: Bounds,
bounds: [number, number, number, number],
viewportDimensions: { width: number; height: number },
) => {
const [x1, y1, x2, y2] = bounds;
@@ -225,93 +224,50 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue;
};
export const zoomToFit = ({
targetElements,
appState,
fitToViewport = false,
viewportZoomFactor = 0.7,
}: {
targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number;
}) => {
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
export const zoomToFitElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
zoomToSelection: boolean,
) => {
const nonDeletedElements = getNonDeletedElements(elements);
const selectedElements = getSelectedElements(nonDeletedElements, appState);
const commonBounds =
zoomToSelection && selectedElements.length > 0
? getCommonBounds(selectedElements)
: getCommonBounds(nonDeletedElements);
const newZoom = {
value: zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
}),
};
const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
let newZoomValue;
let scrollX;
let scrollY;
if (fitToViewport) {
const commonBoundsWidth = x2 - x1;
const commonBoundsHeight = y2 - y1;
newZoomValue =
Math.min(
appState.width / commonBoundsWidth,
appState.height / commonBoundsHeight,
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
// Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min(
Math.max(newZoomValue, 0.1),
30.0,
) as NormalizedZoomValue;
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else {
newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
});
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: { value: newZoomValue },
});
scrollX = centerScroll.scrollX;
scrollY = centerScroll.scrollY;
}
return {
appState: {
...appState,
scrollX,
scrollY,
zoom: { value: newZoomValue },
...centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: newZoom,
}),
zoom: newZoom,
},
commitToHistory: false,
};
};
// Note, this action differs from actionZoomToFitSelection in that it doesn't
// zoom beyond 100%. In other words, if the content is smaller than viewport
// size, it won't be zoomed in.
export const actionZoomToFitSelectionInViewport = register({
name: "zoomToFitSelectionInViewport",
export const actionZoomToSelected = register({
name: "zoomToSelection",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return zoomToFit({
targetElements: selectedElements.length ? selectedElements : elements,
appState,
fitToViewport: false,
});
},
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
// TBD on how proceed
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
keyTest: (event) =>
event.code === CODES.TWO &&
event.shiftKey &&
@@ -319,31 +275,11 @@ export const actionZoomToFitSelectionInViewport = register({
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFitSelection = register({
name: "zoomToFitSelection",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return zoomToFit({
targetElements: selectedElements.length ? selectedElements : elements,
appState,
fitToViewport: true,
});
},
// NOTE this action should use shift-2 per figma, alas
keyTest: (event) =>
event.code === CODES.THREE &&
event.shiftKey &&
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFit = register({
name: "zoomToFit",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) =>
zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
keyTest: (event) =>
event.code === CODES.ONE &&
event.shiftKey &&
@@ -396,7 +332,6 @@ export const actionToggleEraserTool = register({
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeEmbeddable: null,
activeTool,
},
commitToHistory: true,
@@ -431,7 +366,6 @@ export const actionToggleHandTool = register({
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeEmbeddable: null,
activeTool,
},
commitToHistory: true,

View File

@@ -7,6 +7,7 @@ import {
probablySupportsClipboardWriteText,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { getSelectedElements } from "../scene/selection";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements, isTextElement } from "../element";
import { t } from "../i18n";
@@ -15,13 +16,9 @@ export const actionCopy = register({
name: "copy",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const selectedElements = getSelectedElements(elements, appState, true);
copyToClipboard(elementsToCopy, app.files);
copyToClipboard(selectedElements, app.files);
return {
commitToHistory: false,
@@ -75,11 +72,11 @@ export const actionCopyAsSvg = register({
commitToHistory: false,
};
}
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
try {
await exportCanvas(
"clipboard-svg",
@@ -119,11 +116,11 @@ export const actionCopyAsPng = register({
commitToHistory: false,
};
}
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
try {
await exportCanvas(
"clipboard",
@@ -171,11 +168,12 @@ export const actionCopyAsPng = register({
export const copyText = register({
name: "copyText",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
const text = selectedElements
.reduce((acc: string[], element) => {
@@ -190,15 +188,10 @@ export const copyText = register({
commitToHistory: false,
};
},
predicate: (elements, appState, _, app) => {
predicate: (elements, appState) => {
return (
probablySupportsClipboardWriteText &&
app.scene
.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})
.some(isTextElement)
getSelectedElements(elements, appState, true).some(isTextElement)
);
},
contextItemLabel: "labels.copyText",

View File

@@ -1,4 +1,4 @@
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { isSomeElementSelected } from "../scene";
import { KEYS } from "../keys";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
@@ -18,23 +18,11 @@ const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const framesToBeDeleted = new Set(
getSelectedElements(
elements.filter((el) => el.type === "frame"),
appState,
).map((el) => el.id),
);
return {
elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
return newElementWith(el, { isDeleted: true });
}
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
return newElementWith(el, { isDeleted: true });
}
if (
isBoundToContainer(el) &&
appState.selectedElementIds[el.containerId]
@@ -158,7 +146,6 @@ export const actionDeleteSelected = register({
...nextAppState,
activeTool: updateActiveTool(appState, { type: "selection" }),
multiElement: null,
activeEmbeddable: null,
},
commitToHistory: isSomeElementSelected(
getNonDeletedElements(elements),

View File

@@ -6,49 +6,44 @@ import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../distribute";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
selectedElements.length > 1 &&
// TODO enable distributing frames when implemented properly
!selectedElements.some((el) => el.type === "frame")
);
};
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
const distributeSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
distribution: Distribution,
) => {
const selectedElements = app.scene.getSelectedElements(appState);
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const updatedElements = distributeElements(selectedElements, distribution);
const updatedElementsMap = arrayToMap(updatedElements);
return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
app,
return elements.map(
(element) => updatedElementsMap.get(element.id) || element,
);
};
export const distributeHorizontally = register({
name: "distributeHorizontally",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: distributeSelectedElements(elements, appState, app, {
elements: distributeSelectedElements(elements, appState, {
space: "between",
axis: "x",
}),
@@ -57,9 +52,9 @@ export const distributeHorizontally = register({
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(appState, app)}
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={DistributeHorizontallyIcon}
onClick={() => updateData(null)}
@@ -75,10 +70,10 @@ export const distributeHorizontally = register({
export const distributeVertically = register({
name: "distributeVertically",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
appState,
elements: distributeSelectedElements(elements, appState, app, {
elements: distributeSelectedElements(elements, appState, {
space: "between",
axis: "y",
}),
@@ -87,9 +82,9 @@ export const distributeVertically = register({
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(appState, app)}
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={DistributeVerticallyIcon}
onClick={() => updateData(null)}

View File

@@ -2,7 +2,7 @@ import { KEYS } from "../keys";
import { register } from "./register";
import { ExcalidrawElement } from "../element/types";
import { duplicateElement, getNonDeletedElements } from "../element";
import { isSomeElementSelected } from "../scene";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils";
@@ -20,17 +20,9 @@ import {
bindTextToShapeAfterDuplication,
getBoundTextElement,
} from "../element/textElement";
import { isBoundToContainer, isFrameElement } from "../element/typeChecks";
import { isBoundToContainer } from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons";
import {
bindElementsToFramesAfterDuplication,
getFrameElements,
} from "../frame";
import {
excludeElementsInFramesFromSelection,
getSelectedElements,
} from "../scene/selection";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
@@ -102,11 +94,8 @@ const duplicateElements = (
return newElement;
};
const idsOfElementsToDuplicate = arrayToMap(
getSelectedElements(sortedElements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
const selectedElementIds = arrayToMap(
getSelectedElements(sortedElements, appState, true),
);
// Ids of elements that have already been processed so we don't push them
@@ -140,25 +129,12 @@ const duplicateElements = (
}
const boundTextElement = getBoundTextElement(element);
const isElementAFrame = isFrameElement(element);
if (idsOfElementsToDuplicate.get(element.id)) {
// if a group or a container/bound-text or frame, duplicate atomically
if (element.groupIds.length || boundTextElement || isElementAFrame) {
if (selectedElementIds.get(element.id)) {
// if a group or a container/bound-text, duplicate atomically
if (element.groupIds.length || boundTextElement) {
const groupId = getSelectedGroupForElement(appState, element);
if (groupId) {
// TODO:
// remove `.flatMap...`
// if the elements in a frame are grouped when the frame is grouped
const groupElements = getElementsInGroup(
sortedElements,
groupId,
).flatMap((element) =>
isFrameElement(element)
? [...getFrameElements(elements, element.id), element]
: [element],
);
const groupElements = getElementsInGroup(sortedElements, groupId);
elementsWithClones.push(
...markAsProcessed([
...groupElements,
@@ -180,34 +156,10 @@ const duplicateElements = (
);
continue;
}
if (isElementAFrame) {
const elementsInFrame = getFrameElements(sortedElements, element.id);
elementsWithClones.push(
...markAsProcessed([
...elementsInFrame,
element,
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
duplicateAndOffsetElement(element),
]),
);
continue;
}
}
// since elements in frames have a lower z-index than the frame itself,
// they will be looped first and if their frames are selected as well,
// they will have been copied along with the frame atomically in the
// above branch, so we must skip those elements here
//
// now, for elements do not belong any frames or elements whose frames
// are selected (or elements that are left out from the above
// steps for whatever reason) we (should at least) duplicate them here
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) {
elementsWithClones.push(
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
);
}
elementsWithClones.push(
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
);
} else {
elementsWithClones.push(...markAsProcessed([element]));
}
@@ -248,14 +200,6 @@ const duplicateElements = (
oldElements,
oldIdToDuplicatedId,
);
bindElementsToFramesAfterDuplication(
finalElements,
oldElements,
oldIdToDuplicatedId,
);
const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements);
return {
elements: finalElements,
@@ -263,7 +207,7 @@ const duplicateElements = (
{
...appState,
selectedGroupIds: {},
selectedElementIds: nextElementsToSelect.reduce(
selectedElementIds: newElements.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
@@ -274,8 +218,6 @@ const duplicateElements = (
),
},
getNonDeletedElements(finalElements),
appState,
null,
),
};
};

View File

@@ -1,6 +1,7 @@
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { arrayToMap } from "../utils";
import { register } from "./register";
@@ -10,18 +11,8 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
export const actionToggleElementLock = register({
name: "toggleElementLock",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return !selectedElements.some(
(element) => element.locked && element.frameId,
);
},
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, true);
if (!selectedElements.length) {
return false;
@@ -46,12 +37,9 @@ export const actionToggleElementLock = register({
commitToHistory: true,
};
},
contextItemLabel: (elements, appState, app) => {
const selected = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
if (selected.length === 1 && selected[0].type !== "frame") {
contextItemLabel: (elements, appState) => {
const selected = getSelectedElements(elements, appState, false);
if (selected.length === 1) {
return selected[0].locked
? "labels.elementLock.unlock"
: "labels.elementLock.lock";
@@ -61,15 +49,12 @@ export const actionToggleElementLock = register({
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
},
keyTest: (event, appState, elements, app) => {
keyTest: (event, appState, elements) => {
return (
event.key.toLocaleLowerCase() === KEYS.L &&
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
}).length > 0
getSelectedElements(elements, appState, false).length > 0
);
},
});

View File

@@ -65,7 +65,7 @@ export const actionChangeExportScale = register({
);
const scaleButtonTitle = `${t(
"imageExportDialog.label.scale",
"buttons.scale",
)} ${s}x (${width}x${height})`;
return (
@@ -102,7 +102,7 @@ export const actionChangeExportBackground = register({
checked={appState.exportBackground}
onChange={(checked) => updateData(checked)}
>
{t("imageExportDialog.label.withBackground")}
{t("labels.withBackground")}
</CheckboxItem>
),
});
@@ -121,8 +121,8 @@ export const actionChangeExportEmbedScene = register({
checked={appState.exportEmbedScene}
onChange={(checked) => updateData(checked)}
>
{t("imageExportDialog.label.embedScene")}
<Tooltip label={t("imageExportDialog.tooltip.embedScene")} long={true}>
{t("labels.exportEmbedScene")}
<Tooltip label={t("labels.exportEmbedScene_details")} 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("imageExportDialog.label.darkMode")}
title={t("labels.toggleExportColorScheme")}
/>
</div>
),

View File

@@ -125,6 +125,13 @@ export const actionFinalize = register({
{ x, y },
);
}
if (
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
) {
appState.selectedElementIds[multiPointElement.id] = true;
}
}
if (
@@ -160,7 +167,6 @@ export const actionFinalize = register({
multiPointElement
? appState.activeTool
: activeTool,
activeEmbeddable: null,
draggingElement: null,
multiElement: null,
editingElement: null,

View File

@@ -12,18 +12,13 @@ import {
isBindingEnabled,
unbindLinearElements,
} from "../element/binding";
import { updateFrameMembershipOfSelectedElements } from "../frame";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "horizontal"),
appState,
app,
),
elements: flipSelectedElements(elements, appState, "horizontal"),
appState,
commitToHistory: true,
};
@@ -35,13 +30,9 @@ export const actionFlipHorizontal = register({
export const actionFlipVertical = register({
name: "flipVertical",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
return {
elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "vertical"),
appState,
app,
),
elements: flipSelectedElements(elements, appState, "vertical"),
appState,
commitToHistory: true,
};
@@ -59,9 +50,6 @@ const flipSelectedElements = (
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeElementsInFrames: true,
},
);
const updatedElements = flipElements(

View File

@@ -1,132 +0,0 @@
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame";
import { KEYS } from "../keys";
import { AppClassProperties, AppState } from "../types";
import { setCursorForShape, updateActiveTool } from "../utils";
import { register } from "./register";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
return selectedElements.length === 1 && selectedElements[0].type === "frame";
};
export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedFrame = app.scene.getSelectedElements(appState)[0];
if (selectedFrame && selectedFrame.type === "frame") {
const elementsInFrame = getFrameElements(
getNonDeletedElements(elements),
selectedFrame.id,
).filter((element) => !(element.type === "text" && element.containerId));
return {
elements,
appState: {
...appState,
selectedElementIds: elementsInFrame.reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
},
commitToHistory: false,
};
}
return {
elements,
appState,
commitToHistory: false,
};
},
contextItemLabel: "labels.selectAllElementsInFrame",
predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
});
export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame",
trackEvent: { category: "history" },
perform: (elements, appState, _, app) => {
const selectedFrame = app.scene.getSelectedElements(appState)[0];
if (selectedFrame && selectedFrame.type === "frame") {
return {
elements: removeAllElementsFromFrame(elements, selectedFrame, appState),
appState: {
...appState,
selectedElementIds: {
[selectedFrame.id]: true,
},
},
commitToHistory: true,
};
}
return {
elements,
appState,
commitToHistory: false,
};
},
contextItemLabel: "labels.removeAllElementsFromFrame",
predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
});
export const actionupdateFrameRendering = register({
name: "updateFrameRendering",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
return {
elements,
appState: {
...appState,
frameRendering: {
...appState.frameRendering,
enabled: !appState.frameRendering.enabled,
},
},
commitToHistory: false,
};
},
contextItemLabel: "labels.updateFrameRendering",
checked: (appState: AppState) => appState.frameRendering.enabled,
});
export const actionSetFrameAsActiveTool = register({
name: "setFrameAsActiveTool",
trackEvent: { category: "toolbar" },
perform: (elements, appState, _, app) => {
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setCursorForShape(app.canvas, {
...appState,
activeTool: nextActiveTool,
});
return {
elements,
appState: {
...appState,
activeTool: updateActiveTool(appState, {
type: "frame",
}),
},
commitToHistory: false,
};
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] &&
!event.shiftKey &&
!event.altKey &&
event.key.toLocaleLowerCase() === KEYS.F,
});

View File

@@ -4,7 +4,7 @@ import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
import { UngroupIcon, GroupIcon } from "../components/icons";
import { newElementWith } from "../element/mutateElement";
import { isSomeElementSelected } from "../scene";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import {
getSelectedGroupIds,
selectGroup,
@@ -17,19 +17,9 @@ import {
import { getNonDeletedElements } from "../element";
import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawTextElement,
} from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import { AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,
groupByFrames,
removeElementsFromFrame,
replaceAllElementsInFrame,
} from "../frame";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) {
@@ -51,12 +41,12 @@ const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
);
@@ -65,11 +55,12 @@ const enableActionGroup = (
export const actionGroup = register({
name: "group",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
if (selectedElements.length < 2) {
// nothing to group
return { appState, elements, commitToHistory: false };
@@ -95,31 +86,9 @@ export const actionGroup = register({
return { appState, elements, commitToHistory: false };
}
}
let nextElements = [...elements];
// this includes the case where we are grouping elements inside a frame
// and elements outside that frame
const groupingElementsFromDifferentFrames =
new Set(selectedElements.map((element) => element.frameId)).size > 1;
// when it happens, we want to remove elements that are in the frame
// and are going to be grouped from the frame (mouthful, I know)
if (groupingElementsFromDifferentFrames) {
const frameElementsMap = groupByFrames(selectedElements);
frameElementsMap.forEach((elementsInFrame, frameId) => {
nextElements = removeElementsFromFrame(
nextElements,
elementsInFrame,
appState,
);
});
}
const newGroupId = randomId();
const selectElementIds = arrayToMap(selectedElements);
nextElements = nextElements.map((element) => {
const updatedElements = elements.map((element) => {
if (!selectElementIds.get(element.id)) {
return element;
}
@@ -133,16 +102,17 @@ export const actionGroup = register({
});
// keep the z order within the group the same, but move them
// to the z order of the highest element in the layer stack
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = nextElements
const lastGroupElementIndex =
updatedElements.lastIndexOf(lastElementInGroup);
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = updatedElements
.slice(0, lastGroupElementIndex)
.filter(
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
);
nextElements = [
const updatedElementsInOrder = [
...elementsBeforeGroup,
...elementsInGroup,
...elementsAfterGroup,
@@ -152,20 +122,19 @@ export const actionGroup = register({
appState: selectGroup(
newGroupId,
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
getNonDeletedElements(updatedElementsInOrder),
),
elements: nextElements,
elements: updatedElementsInOrder,
commitToHistory: true,
};
},
contextItemLabel: "labels.group",
predicate: (elements, appState, _, app) =>
enableActionGroup(elements, appState, app),
predicate: (elements, appState) => enableActionGroup(elements, appState),
keyTest: (event) =>
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState, app)}
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={<GroupIcon theme={appState.theme} />}
onClick={() => updateData(null)}
@@ -179,23 +148,14 @@ export const actionGroup = register({
export const actionUngroup = register({
name: "ungroup",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false };
}
let nextElements = [...elements];
const selectedElements = app.scene.getSelectedElements(appState);
const frames = selectedElements
.filter((element) => element.frameId)
.map((element) =>
app.scene.getElement(element.frameId!),
) as ExcalidrawFrameElement[];
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
nextElements = nextElements.map((element) => {
const nextElements = elements.map((element) => {
if (isBoundToContainer(element)) {
boundTextElementIds.push(element.id);
}
@@ -214,36 +174,15 @@ export const actionUngroup = register({
const updateAppState = selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
appState,
null,
);
frames.forEach((frame) => {
if (frame) {
nextElements = replaceAllElementsInFrame(
nextElements,
getElementsInResizingFrame(nextElements, frame, appState),
frame,
appState,
);
}
});
// remove binded text elements from selection
updateAppState.selectedElementIds = Object.entries(
updateAppState.selectedElementIds,
).reduce(
(acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => {
if (selected && !boundTextElementIds.includes(id)) {
acc[id] = true;
}
return acc;
},
{},
boundTextElementIds.forEach(
(id) => (updateAppState.selectedElementIds[id] = false),
);
return {
appState: updateAppState,
elements: nextElements,
commitToHistory: true,
};

View File

@@ -1,6 +1,8 @@
import { getNonDeletedElements } from "../element";
import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks";
import { ExcalidrawLinearElement } from "../element/types";
import { getSelectedElements } from "../scene";
import { register } from "./register";
export const actionToggleLinearEditor = register({
@@ -8,18 +10,19 @@ export const actionToggleLinearEditor = register({
trackEvent: {
category: "element",
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
return true;
}
return false;
},
perform(elements, appState, _, app) {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
const selectedElement = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
)[0] as ExcalidrawLinearElement;
const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id
@@ -33,11 +36,12 @@ export const actionToggleLinearEditor = register({
commitToHistory: false,
};
},
contextItemLabel: (elements, appState, app) => {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
contextItemLabel: (elements, appState) => {
const selectedElement = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
)[0] as ExcalidrawLinearElement;
return appState.editingLinearElement?.elementId === selectedElement.id
? "labels.lineEditor.exit"
: "labels.lineEditor.edit";

View File

@@ -67,6 +67,7 @@ export const actionFullScreen = register({
commitToHistory: false,
};
},
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
});
export const actionShortcuts = register({

View File

@@ -1,4 +1,4 @@
import { getClientColor } from "../clients";
import { getClientColors } from "../clients";
import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll";
import { Collaborator } from "../types";
@@ -31,14 +31,15 @@ export const actionGoToCollaborator = register({
commitToHistory: false,
};
},
PanelComponent: ({ updateData, data }) => {
PanelComponent: ({ appState, updateData, data }) => {
const [clientId, collaborator] = data as [string, Collaborator];
const background = getClientColor(clientId);
const { background, stroke } = getClientColors(clientId, appState);
return (
<Avatar
color={background}
border={stroke}
onClick={() => updateData(collaborator.pointer)}
name={collaborator.username || ""}
src={collaborator.avatarUrl}

View File

@@ -102,11 +102,8 @@ const changeProperty = (
includeBoundText = false,
) => {
const selectedElementIds = arrayToMap(
getSelectedElements(elements, appState, {
includeBoundTextElement: includeBoundText,
}),
getSelectedElements(elements, appState, includeBoundText),
);
return elements.map((element) => {
if (
selectedElementIds.get(element.id) ||

View File

@@ -5,7 +5,6 @@ import { getNonDeletedElements, isTextElement } from "../element";
import { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
export const actionSelectAll = register({
name: "selectAll",
@@ -14,18 +13,19 @@ export const actionSelectAll = register({
if (appState.editingLinearElement) {
return false;
}
const selectedElementIds = excludeElementsInFramesFromSelection(
elements.filter(
(element) =>
const selectedElementIds = elements.reduce(
(map: Record<ExcalidrawElement["id"], true>, element) => {
if (
!element.isDeleted &&
!(isTextElement(element) && element.containerId) &&
!element.locked,
),
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
map[element.id] = true;
return map;
}, {});
!element.locked
) {
map[element.id] = true;
}
return map;
},
{},
);
return {
appState: selectGroupsForSelectedElements(
@@ -41,8 +41,6 @@ export const actionSelectAll = register({
selectedElementIds,
},
getNonDeletedElements(elements),
appState,
app,
),
commitToHistory: true,
};

View File

@@ -20,7 +20,6 @@ import {
hasBoundTextElement,
canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement,
isFrameElement,
} from "../element/typeChecks";
import { getSelectedElements } from "../scene";
@@ -65,9 +64,7 @@ export const actionPasteStyles = register({
return { elements, commitToHistory: false };
}
const selectedElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
});
const selectedElements = getSelectedElements(elements, appState, true);
const selectedElementIds = selectedElements.map((element) => element.id);
return {
elements: elements.map((element) => {
@@ -130,13 +127,6 @@ export const actionPasteStyles = register({
});
}
if (isFrameElement(element)) {
newElement = newElementWith(newElement, {
roundness: null,
backgroundColor: "transparent",
});
}
return newElement;
}
return element;

View File

@@ -90,7 +90,6 @@ export class ActionManager {
event,
this.getAppState(),
this.getElementsIncludingDeleted(),
this.app,
),
);
@@ -169,7 +168,6 @@ export class ActionManager {
appState={this.getAppState()}
updateData={updateData}
appProps={this.app.props}
app={this.app}
data={data}
/>
);

View File

@@ -82,8 +82,7 @@ export type ActionName =
| "zoomOut"
| "resetZoom"
| "zoomToFit"
| "zoomToFitSelection"
| "zoomToFitSelectionInViewport"
| "zoomToSelection"
| "changeFontFamily"
| "changeTextAlign"
| "changeVerticalAlign"
@@ -117,12 +116,6 @@ export type ActionName =
| "toggleLinearEditor"
| "toggleEraserTool"
| "toggleHandTool"
| "selectAllElementsInFrame"
| "removeAllElementsFromFrame"
| "updateFrameRendering"
| "setFrameAsActiveTool"
| "setEmbeddableAsActiveTool"
| "createContainerFromText"
| "wrapTextInContainer";
export type PanelComponentProps = {
@@ -131,7 +124,6 @@ export type PanelComponentProps = {
updateData: (formData?: any) => void;
appProps: ExcalidrawProps;
data?: Record<string, any>;
app: AppClassProperties;
};
export interface Action {
@@ -143,14 +135,12 @@ 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[],

View File

@@ -1,6 +1,6 @@
import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement";
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
import { Box, getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups";
export interface Alignment {
@@ -33,7 +33,7 @@ export const alignElements = (
const calculateTranslation = (
group: ExcalidrawElement[],
selectionBoundingBox: BoundingBox,
selectionBoundingBox: Box,
{ axis, position }: Alignment,
): { x: number; y: number } => {
const groupBoundingBox = getCommonBoundingBox(group);

View File

@@ -5,9 +5,6 @@ export const trackEvent = (
value?: number,
) => {
try {
// place here categories that you want to track as events
// KEEP IN MIND THE PRICING
const ALLOWED_CATEGORIES_TO_TRACK = [] as string[];
// Uncomment the next line to track locally
// console.log("Track Event", { category, action, label, value });
@@ -15,8 +12,12 @@ export const trackEvent = (
return;
}
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
return;
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) {
window.gtag("event", action, {
event_category: category,
event_label: label,
value,
});
}
if (window.sa_event) {
@@ -26,6 +27,14 @@ export const trackEvent = (
value,
});
}
if (window.fathom) {
window.fathom.trackEvent(action, {
category,
label,
value,
});
}
} catch (error) {
console.error("error during analytics", error);
}

View File

@@ -38,7 +38,6 @@ export const getDefaultAppState = (): Omit<
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
cursorButton: "up",
activeEmbeddable: null,
draggingElement: null,
editingElement: null,
editingGroupId: null,
@@ -79,16 +78,11 @@ export const getDefaultAppState = (): Omit<
scrollY: 0,
selectedElementIds: {},
selectedGroupIds: {},
selectedElementsAreBeingDragged: false,
selectionElement: null,
shouldCacheIgnoreZoom: false,
showStats: false,
startBoundElement: null,
suggestedBindings: [],
frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null,
editingFrame: null,
elementsToHighlight: null,
toast: null,
viewBackgroundColor: COLOR_PALETTE.white,
zenModeEnabled: false,
@@ -140,7 +134,6 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false },
draggingElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
@@ -183,20 +176,11 @@ const APP_STATE_STORAGE_CONF = (<
scrollY: { browser: true, export: false, server: false },
selectedElementIds: { browser: true, export: false, server: false },
selectedGroupIds: { browser: true, export: false, server: false },
selectedElementsAreBeingDragged: {
browser: false,
export: false,
server: false,
},
selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false },
editingFrame: { browser: false, export: false, server: false },
elementsToHighlight: { browser: false, export: false, server: false },
toast: { browser: false, export: false, server: false },
viewBackgroundColor: { browser: true, export: true, server: true },
width: { browser: false, export: false, server: false },

View File

@@ -180,7 +180,7 @@ const commonProps = {
locked: false,
} as const;
const getChartDimensions = (spreadsheet: Spreadsheet) => {
const getChartDimentions = (spreadsheet: Spreadsheet) => {
const chartWidth =
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
@@ -250,7 +250,7 @@ const chartLines = (
groupId: string,
backgroundColor: string,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const xLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
@@ -313,7 +313,7 @@ const chartBaseElements = (
backgroundColor: string,
debug?: boolean,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const title = spreadsheet.title
? newTextElement({

View File

@@ -1,31 +1,31 @@
function hashToInteger(id: string) {
let hash = 0;
if (id.length === 0) {
return hash;
}
for (let i = 0; i < id.length; i++) {
const char = id.charCodeAt(i);
hash = (hash << 5) - hash + char;
}
return hash;
}
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
getAllColorsSpecificShade,
} from "./colors";
import { AppState } from "./types";
export const getClientColor = (
/**
* any uniquely identifying key, such as user id or socket id
*/
id: string,
) => {
// to get more even distribution in case `id` is not uniformly distributed to
// begin with, we hash it
const hash = Math.abs(hashToInteger(id));
// we want to get a multiple of 10 number in the range of 0-360 (in other
// words a hue value of step size 10). There are 37 such values including 0.
const hue = (hash % 37) * 10;
const saturation = 100;
const lightness = 83;
const BG_COLORS = getAllColorsSpecificShade(
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
);
const STROKE_COLORS = getAllColorsSpecificShade(
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
);
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
export const getClientColors = (clientId: string, appState: AppState) => {
if (appState?.collaborators) {
const currentUser = appState.collaborators.get(clientId);
if (currentUser?.color) {
return currentUser.color;
}
}
// Naive way of getting an integer out of the clientId
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
return {
background: BG_COLORS[sum % BG_COLORS.length],
stroke: STROKE_COLORS[sum % STROKE_COLORS.length],
};
};
/**

View File

@@ -7,9 +7,6 @@ import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
import { isPromiseLike, isTestEnv } from "./utils";
type ElementsClipboard = {
@@ -60,9 +57,6 @@ export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
files: BinaryFiles | null,
) => {
const framesToCopy = new Set(
elements.filter((element) => element.type === "frame"),
);
let foundFile = false;
const _files = elements.reduce((acc, element) => {
@@ -84,20 +78,7 @@ export const copyToClipboard = async (
// select binded text elements when copying
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: elements.map((element) => {
if (
getContainingFrame(element) &&
!framesToCopy.has(getContainingFrame(element)!)
) {
const copiedElement = deepCopyElement(element);
mutateElement(copiedElement, {
frameId: null,
});
return copiedElement;
}
return element;
}),
elements,
files: files ? _files : undefined,
};
const json = JSON.stringify(contents);

View File

@@ -21,7 +21,7 @@ export type ColorPickerColor =
export type ColorTuple = readonly [string, string, string, string, string];
export type ColorPalette = Merge<
Record<ColorPickerColor, ColorTuple>,
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
{ black: string; white: string; transparent: string }
>;
// used general type instead of specific type (ColorPalette) to support custom colors

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React from "react";
import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types";
@@ -35,9 +35,6 @@ import {
} from "../element/textElement";
import "./Actions.scss";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
import { KEYS } from "../keys";
export const SelectedShapeActions = ({
appState,
@@ -92,8 +89,7 @@ export const SelectedShapeActions = ({
<div>
{((hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" &&
commonSelectedType !== "image" &&
commonSelectedType !== "frame") ||
commonSelectedType !== "image") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")}
</div>
@@ -224,186 +220,61 @@ export const ShapesSwitcher = ({
setAppState: React.Component<any, UIAppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: UIAppState;
}) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
const device = useDevice();
return (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
const label = t(`toolBar.${value}`);
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
return (
<ToolButton
className={clsx("Shape", { fillable })}
key={value}
type="radio"
icon={icon}
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = updateActiveTool(appState, {
type: value,
});
}) => (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
const label = t(`toolBar.${value}`);
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
return (
<ToolButton
className={clsx("Shape", { fillable })}
key={value}
type="radio"
icon={icon}
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
activeTool: nextActiveTool,
activeEmbeddable: null,
multiElement: null,
selectedElementIds: {},
penDetected: true,
penMode: true,
});
setCursorForShape(canvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") {
onImageAction({ pointerType });
}
}}
/>
);
})}
<div className="App-toolbar__divider" />
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
{device.isMobile ? (
<>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
icon={frameToolIcon}
checked={activeTool.type === "frame"}
name="editor-current-shape"
title={`${capitalizeString(
t("toolBar.frame"),
)}${KEYS.F.toLocaleUpperCase()}`}
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
aria-label={capitalizeString(t("toolBar.frame"))}
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "frame", "ui");
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
activeEmbeddable: null,
});
}}
/>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
icon={EmbedIcon}
checked={activeTool.type === "embeddable"}
name="editor-current-shape"
title={capitalizeString(t("toolBar.embeddable"))}
aria-label={capitalizeString(t("toolBar.embeddable"))}
data-testid={`toolbar-embeddable`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "embeddable", "ui");
const nextActiveTool = updateActiveTool(appState, {
type: "embeddable",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
activeEmbeddable: null,
});
}}
/>
</>
) : (
<DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger
className="App-toolbar__extra-tools-trigger"
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
{extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
align="end"
>
<DropdownMenu.Item
onSelect={() => {
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}}
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
>
{t("toolBar.frame")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
const nextActiveTool = updateActiveTool(appState, {
type: "embeddable",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}}
icon={EmbedIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}
</>
);
};
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = updateActiveTool(appState, {
type: value,
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(canvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") {
onImageAction({ pointerType });
}
}}
/>
);
})}
</>
);
export const ZoomActions = ({
renderAction,

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,10 @@
display: flex;
justify-content: center;
align-items: center;
color: $oc-white;
cursor: pointer;
font-size: 0.75rem;
font-weight: 800;
font-size: 0.625rem;
font-weight: 500;
line-height: 1;
&-img {

View File

@@ -6,6 +6,7 @@ import { getNameInitial } from "../clients";
type AvatarProps = {
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
color: string;
border: string;
name: string;
src?: string;
};

View File

@@ -29,18 +29,6 @@
justify-content: space-between;
}
#canvas-bg-color-picker-container {
.color-picker__top-picks {
gap: 0.5rem;
}
.color-picker-container {
@include isMobile {
max-width: none;
}
}
}
.color-picker__button {
--radius: 0.25rem;

View File

@@ -1,4 +1,4 @@
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
import { isTransparent, isWritableElement } from "../../utils";
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import { TopPicks } from "./TopPicks";
@@ -121,14 +121,11 @@ const ColorPickerPopupContent = ({
}
}}
onCloseAutoFocus={(e) => {
e.stopPropagation();
// prevents focusing the trigger
e.preventDefault();
e.stopPropagation();
// return focus to excalidraw container unless
// user focuses an interactive element, such as a button, or
// enters the text editor by clicking on canvas with the text tool
if (container && !isInteractive(document.activeElement)) {
// return focus to excalidraw container
if (container) {
container.focus();
}

View File

@@ -8,7 +8,7 @@ import {
} from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
import { ColorPaletteCustom } from "../../colors";
import { TranslationKeys, t } from "../../i18n";
import { t } from "../../i18n";
interface PickerColorListProps {
palette: ColorPaletteCustom;
@@ -48,11 +48,7 @@ const PickerColorList = ({
(Array.isArray(value) ? value[activeShade] : value) || "transparent";
const keybinding = colorPickerHotkeyBindings[index];
const label = t(
`colors.${key.replace(/\d+/, "")}` as unknown as TranslationKeys,
null,
"",
);
const label = t(`colors.${key.replace(/\d+/, "")}`, null, "");
return (
<button

View File

@@ -1,6 +1,6 @@
import clsx from "clsx";
import { Popover } from "./Popover";
import { t, TranslationKeys } from "../i18n";
import { t } from "../i18n";
import "./ContextMenu.scss";
import {
@@ -82,15 +82,9 @@ export const ContextMenu = React.memo(
let label = "";
if (item.contextItemLabel) {
if (typeof item.contextItemLabel === "function") {
label = t(
item.contextItemLabel(
elements,
appState,
actionManager.app,
) as unknown as TranslationKeys,
);
label = t(item.contextItemLabel(elements, appState));
} else {
label = t(item.contextItemLabel as unknown as TranslationKeys);
label = t(item.contextItemLabel);
}
}

View File

@@ -17,34 +17,16 @@ import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { jotaiScope } from "../jotai";
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
export interface DialogProps {
children: React.ReactNode;
className?: string;
size?: DialogSize;
size?: "small" | "regular" | "wide";
onCloseRequest(): void;
title: React.ReactNode | false;
autofocus?: boolean;
closeOnClickOutside?: boolean;
}
function getDialogSize(size: DialogSize): number {
if (size && typeof size === "number") {
return size;
}
switch (size) {
case "small":
return 550;
case "wide":
return 1024;
case "regular":
default:
return 800;
}
}
export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
@@ -103,7 +85,9 @@ export const Dialog = (props: DialogProps) => {
<Modal
className={clsx("Dialog", props.className)}
labelledBy="dialog-title"
maxWidth={getDialogSize(props.size)}
maxWidth={
props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
}
onCloseRequest={onClose}
closeOnClickOutside={props.closeOnClickOutside}
>

View File

@@ -58,7 +58,7 @@ export const EyeDropper: React.FC<{
return;
}
let currentColor: string = COLOR_PALETTE.black;
let currentColor = COLOR_PALETTE.black;
let isHoldingPointerDown = false;
const ctx = app.canvas.getContext("2d")!;

View File

@@ -2,140 +2,20 @@
.excalidraw {
.ExcButton {
--text-color: transparent;
--border-color: transparent;
--back-color: transparent;
color: var(--text-color);
background-color: var(--back-color);
border-color: var(--border-color);
&--color-primary {
&.ExcButton--variant-filled {
--text-color: var(--input-bg-color);
--back-color: var(--color-primary);
color: var(--input-bg-color);
&:hover {
--back-color: var(--color-primary-darker);
}
&:active {
--back-color: var(--color-primary-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-primary);
--border-color: var(--color-primary);
--back-color: var(--input-bg-color);
&:hover {
--text-color: var(--color-primary-darker);
--border-color: var(--color-primary-darker);
}
&:active {
--text-color: var(--color-primary-darkest);
--border-color: var(--color-primary-darkest);
}
}
--accent-color: var(--color-primary);
--accent-color-hover: var(--color-primary-darker);
--accent-color-active: var(--color-primary-darkest);
}
&--color-danger {
&.ExcButton--variant-filled {
--text-color: var(--color-danger-text);
--back-color: var(--color-danger-dark);
color: var(--input-bg-color);
&:hover {
--back-color: var(--color-danger-darker);
}
&:active {
--back-color: var(--color-danger-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-danger);
--border-color: var(--color-danger);
--back-color: transparent;
&:hover {
--text-color: var(--color-danger-darkest);
--border-color: var(--color-danger-darkest);
}
&:active {
--text-color: var(--color-danger-darker);
--border-color: var(--color-danger-darker);
}
}
}
&--color-muted {
&.ExcButton--variant-filled {
--text-color: var(--island-bg-color);
--back-color: var(--color-gray-50);
&:hover {
--back-color: var(--color-gray-60);
}
&:active {
--back-color: var(--color-gray-80);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-muted-background);
--border-color: var(--color-muted);
--back-color: var(--island-bg-color);
&:hover {
--text-color: var(--color-muted-background-darker);
--border-color: var(--color-muted-darker);
}
&:active {
--text-color: var(--color-muted-background-darker);
--border-color: var(--color-muted-darkest);
}
}
}
&--color-warning {
&.ExcButton--variant-filled {
--text-color: black;
--back-color: var(--color-warning-dark);
&:hover {
--back-color: var(--color-warning-darker);
}
&:active {
--back-color: var(--color-warning-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-warning-dark);
--border-color: var(--color-warning-dark);
--back-color: var(--input-bg-color);
&:hover {
--text-color: var(--color-warning-darker);
--border-color: var(--color-warning-darker);
}
&:active {
--text-color: var(--color-warning-darkest);
--border-color: var(--color-warning-darkest);
}
}
--accent-color: var(--color-danger);
--accent-color-hover: #d65550;
--accent-color-active: #d1413c;
}
display: flex;
@@ -145,8 +25,6 @@
flex-wrap: nowrap;
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: "Assistant";
@@ -155,9 +33,9 @@
transition: all 150ms ease-out;
&--size-large {
font-weight: 600;
font-weight: 400;
font-size: 0.875rem;
min-height: 3rem;
height: 3rem;
padding: 0.5rem 1.5rem;
gap: 0.75rem;
@@ -167,22 +45,48 @@
&--size-medium {
font-weight: 600;
font-size: 0.75rem;
min-height: 2.5rem;
height: 2.5rem;
padding: 0.5rem 1rem;
gap: 0.5rem;
letter-spacing: normal;
}
&--variant-filled {
background: var(--accent-color);
border: 1px solid transparent;
&:hover {
background: var(--accent-color-hover);
}
&:active {
background: var(--accent-color-active);
}
}
&--variant-outlined,
&--variant-icon {
border: 1px solid var(--accent-color);
color: var(--accent-color);
background: transparent;
&:hover {
border: 1px solid var(--accent-color-hover);
color: var(--accent-color-hover);
}
&:active {
border: 1px solid var(--accent-color-active);
color: var(--accent-color-active);
}
}
&--variant-icon {
padding: 0.5rem 0.75rem;
width: 3rem;
}
&--fullWidth {
width: 100%;
}
&__icon {
width: 1.25rem;
height: 1.25rem;

View File

@@ -4,7 +4,7 @@ import clsx from "clsx";
import "./FilledButton.scss";
export type ButtonVariant = "filled" | "outlined" | "icon";
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
export type ButtonColor = "primary" | "danger";
export type ButtonSize = "medium" | "large";
export type FilledButtonProps = {
@@ -17,7 +17,6 @@ export type FilledButtonProps = {
color?: ButtonColor;
size?: ButtonSize;
className?: string;
fullWidth?: boolean;
startIcon?: React.ReactNode;
};
@@ -32,7 +31,6 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
variant = "filled",
color = "primary",
size = "medium",
fullWidth,
className,
},
ref,
@@ -44,7 +42,6 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
`ExcButton--color-${color}`,
`ExcButton--variant-${variant}`,
`ExcButton--size-${size}`,
{ "ExcButton--fullWidth": fullWidth },
className,
)}
onClick={onClick}

View File

@@ -12,7 +12,7 @@ const Header = () => (
<div className="HelpDialog__header">
<a
className="HelpDialog__btn"
href="https://docs.excalidraw.com"
href="https://github.com/excalidraw/excalidraw#documentation"
target="_blank"
rel="noopener noreferrer"
>
@@ -164,7 +164,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("toolBar.eraser")}
shortcuts={[KEYS.E, KEYS["0"]]}
/>
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
<Shortcut
label={t("labels.eyeDropper")}
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}

View File

@@ -1,5 +1,7 @@
import { t } from "../i18n";
import { AppClassProperties, Device, UIAppState } from "../types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene";
import { Device, UIAppState } from "../types";
import {
isImageElement,
isLinearElement,
@@ -13,12 +15,17 @@ import "./HintViewer.scss";
interface HintViewerProps {
appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[];
isMobile: boolean;
device: Device;
app: AppClassProperties;
}
const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
const getHints = ({
appState,
elements,
isMobile,
device,
}: HintViewerProps) => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
@@ -44,15 +51,11 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.text");
}
if (activeTool.type === "embeddable") {
return t("hints.embeddable");
}
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
return t("hints.placeImage");
}
const selectedElements = app.scene.getSelectedElements(appState);
const selectedElements = getSelectedElements(elements, appState);
if (
isResizing &&
@@ -112,15 +115,15 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
export const HintViewer = ({
appState,
elements,
isMobile,
device,
app,
}: HintViewerProps) => {
let hint = getHints({
appState,
elements,
isMobile,
device,
app,
});
if (!hint) {
return null;

View File

@@ -84,10 +84,7 @@ const ImageExportModal = ({
const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected
? getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
})
? getSelectedElements(elements, appState, true)
: elements;
useEffect(() => {

View File

@@ -41,7 +41,6 @@ import { jotaiScope } from "../jotai";
import { Provider, useAtom, useAtomValue } from "jotai";
import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState";
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
@@ -72,7 +71,6 @@ interface LayerUIProps {
onExportImage: AppClassProperties["onExportImage"];
renderWelcomeScreen: boolean;
children?: React.ReactNode;
app: AppClassProperties;
}
const DefaultMainMenu: React.FC<{
@@ -101,15 +99,6 @@ const DefaultMainMenu: React.FC<{
);
};
const DefaultOverwriteConfirmDialog = () => {
return (
<OverwriteConfirmDialog __fallback>
<OverwriteConfirmDialog.Actions.SaveToDisk />
<OverwriteConfirmDialog.Actions.ExportToImage />
</OverwriteConfirmDialog>
);
};
const LayerUI = ({
actionManager,
appState,
@@ -128,7 +117,6 @@ const LayerUI = ({
onExportImage,
renderWelcomeScreen,
children,
app,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@@ -216,7 +204,12 @@ const LayerUI = ({
return (
<FixedSideContainer side="top">
<div className="App-menu App-menu_top">
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
<Stack.Col
gap={6}
className={clsx("App-menu_top__left", {
"disable-pointerEvents": appState.zenModeEnabled,
})}
>
{renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col>
@@ -242,9 +235,9 @@ const LayerUI = ({
>
<HintViewer
appState={appState}
elements={elements}
isMobile={device.isMobile}
device={device}
app={app}
/>
{heading}
<Stack.Row gap={1}>
@@ -261,7 +254,7 @@ const LayerUI = ({
title={t("toolBar.lock")}
/>
<div className="App-toolbar__divider" />
<div className="App-toolbar__divider"></div>
<HandButton
checked={isHandToolActive(appState)}
@@ -355,7 +348,6 @@ const LayerUI = ({
>
{t("toolBar.library")}
</DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog />
{/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />}
@@ -387,7 +379,6 @@ const LayerUI = ({
/>
)}
<ActiveConfirmDialog />
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}
{renderJSONExportDialog()}
{appState.pasteDialog.shown && (
@@ -401,9 +392,8 @@ const LayerUI = ({
}
/>
)}
{device.isMobile && (
{device.isMobile && !eyeDropperState && (
<MobileMenu
app={app}
appState={appState}
elements={elements}
actionManager={actionManager}

View File

@@ -29,7 +29,6 @@ import "./LibraryMenu.scss";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { isShallowEqual } from "../utils";
import { NonDeletedExcalidrawElement } from "../element/types";
import { LIBRARY_DISABLED_TYPES } from "../constants";
export const isLibraryMenuOpenAtom = atom(false);
@@ -69,12 +68,11 @@ export const LibraryMenuContent = ({
libraryItems: LibraryItems,
) => {
trackEvent("element", "addToLibrary", "ui");
for (const type of LIBRARY_DISABLED_TYPES) {
if (processedElements.some((element) => element.type === type)) {
return setAppState({
errorMessage: t(`errors.libraryElementTypeError.${type}`),
});
}
if (processedElements.some((element) => element.type === "image")) {
return setAppState({
errorMessage:
"Support for adding images to the library coming soon!",
});
}
const nextItems: LibraryItems = [
{
@@ -150,11 +148,7 @@ const usePendingElementsMemo = (
appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[],
) => {
const create = () =>
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const create = () => getSelectedElements(elements, appState, true);
const val = useRef(create());
const prevAppState = useRef<UIAppState>(appState);
const prevElements = useRef(elements);
@@ -199,7 +193,6 @@ export const LibraryMenu = () => {
setAppState({
selectedElementIds: {},
selectedGroupIds: {},
activeEmbeddable: null,
});
}, [setAppState]);

View File

@@ -191,7 +191,6 @@ export const LibraryDropdownMenuButton: React.FC<{
<DropdownMenu open={isLibraryMenuOpen}>
<DropdownMenu.Trigger
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
aria-label="Library menu"
>
{DotsIcon}
</DropdownMenu.Trigger>
@@ -199,7 +198,6 @@ export const LibraryDropdownMenuButton: React.FC<{
onClickOutside={() => setIsLibraryMenuOpen(false)}
onSelect={() => setIsLibraryMenuOpen(false)}
className="library-menu"
align="end"
>
{!itemsSelected && (
<DropdownMenu.Item

View File

@@ -12,11 +12,6 @@
box-sizing: border-box;
border-radius: var(--border-radius-lg);
svg {
// to prevent clicks on links and such
pointer-events: none;
}
&--hover {
border-color: var(--color-primary);
}

View File

@@ -1,11 +1,5 @@
import React from "react";
import {
AppClassProperties,
AppState,
Device,
ExcalidrawProps,
UIAppState,
} from "../types";
import { AppState, Device, ExcalidrawProps, UIAppState } from "../types";
import { ActionManager } from "../actions/manager";
import { t } from "../i18n";
import Stack from "./Stack";
@@ -47,7 +41,6 @@ type MobileMenuProps = {
renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen: boolean;
app: AppClassProperties;
};
export const MobileMenu = ({
@@ -65,7 +58,6 @@ export const MobileMenu = ({
renderSidebars,
device,
renderWelcomeScreen,
app,
}: MobileMenuProps) => {
const {
WelcomeScreenCenterTunnel,
@@ -127,9 +119,9 @@ export const MobileMenu = ({
</Section>
<HintViewer
appState={appState}
elements={elements}
isMobile={true}
device={device}
app={app}
/>
</FixedSideContainer>
);

View File

@@ -3,7 +3,7 @@
.excalidraw {
&.excalidraw-modal-container {
position: absolute;
z-index: var(--zIndex-modal);
z-index: 10;
}
.Modal {

View File

@@ -1,126 +0,0 @@
@import "../../css/variables.module";
.excalidraw {
.OverwriteConfirm {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
isolation: isolate;
h3 {
margin: 0;
font-weight: 700;
font-size: 1.3125rem;
line-height: 130%;
align-self: flex-start;
color: var(--text-primary-color);
}
&__Description {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
gap: 1rem;
@include isMobile {
flex-direction: column;
text-align: center;
}
padding: 2.5rem;
background: var(--color-danger-background);
border-radius: 0.5rem;
font-family: "Assistant";
font-style: normal;
font-weight: 400;
font-size: 1rem;
line-height: 150%;
color: var(--color-danger-color);
&__spacer {
flex-grow: 1;
}
&__icon {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2.5rem;
background: var(--color-danger-icon-background);
width: 3.5rem;
height: 3.5rem;
padding: 0.75rem;
svg {
color: var(--color-danger-icon-color);
width: 1.5rem;
height: 1.5rem;
}
}
&.OverwriteConfirm__Description--color-warning {
background: var(--color-warning-background);
color: var(--color-warning-color);
.OverwriteConfirm__Description__icon {
background: var(--color-warning-icon-background);
flex: 0 0 auto;
svg {
color: var(--color-warning-icon-color);
}
}
}
}
&__Actions {
display: flex;
flex-direction: row;
align-items: stretch;
justify-items: stretch;
justify-content: center;
gap: 1.5rem;
@include isMobile {
flex-direction: column;
}
&__Action {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem;
gap: 0.75rem;
flex-basis: 50%;
flex-grow: 0;
&__content {
height: 100%;
font-size: 0.875rem;
text-align: center;
}
h4 {
font-weight: 700;
font-size: 1.125rem;
line-height: 130%;
margin: 0;
color: var(--text-primary-color);
}
}
}
}
}

View File

@@ -1,76 +0,0 @@
import React from "react";
import { useAtom } from "jotai";
import { useTunnels } from "../../context/tunnels";
import { jotaiScope } from "../../jotai";
import { Dialog } from "../Dialog";
import { withInternalFallback } from "../hoc/withInternalFallback";
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
import { FilledButton } from "../FilledButton";
import { alertTriangleIcon } from "../icons";
import { Actions, Action } from "./OverwriteConfirmActions";
import "./OverwriteConfirm.scss";
export type OverwriteConfirmDialogProps = {
children: React.ReactNode;
};
const OverwriteConfirmDialog = Object.assign(
withInternalFallback(
"OverwriteConfirmDialog",
({ children }: OverwriteConfirmDialogProps) => {
const { OverwriteConfirmDialogTunnel } = useTunnels();
const [overwriteConfirmState, setState] = useAtom(
overwriteConfirmStateAtom,
jotaiScope,
);
if (!overwriteConfirmState.active) {
return null;
}
const handleClose = () => {
overwriteConfirmState.onClose();
setState((state) => ({ ...state, active: false }));
};
const handleConfirm = () => {
overwriteConfirmState.onConfirm();
setState((state) => ({ ...state, active: false }));
};
return (
<OverwriteConfirmDialogTunnel.In>
<Dialog onCloseRequest={handleClose} title={false} size={916}>
<div className="OverwriteConfirm">
<h3>{overwriteConfirmState.title}</h3>
<div
className={`OverwriteConfirm__Description OverwriteConfirm__Description--color-${overwriteConfirmState.color}`}
>
<div className="OverwriteConfirm__Description__icon">
{alertTriangleIcon}
</div>
<div>{overwriteConfirmState.description}</div>
<div className="OverwriteConfirm__Description__spacer"></div>
<FilledButton
color={overwriteConfirmState.color}
size="large"
label={overwriteConfirmState.actionLabel}
onClick={handleConfirm}
/>
</div>
<Actions>{children}</Actions>
</div>
</Dialog>
</OverwriteConfirmDialogTunnel.In>
);
},
),
{
Actions,
Action,
},
);
export { OverwriteConfirmDialog };

View File

@@ -1,85 +0,0 @@
import React from "react";
import { FilledButton } from "../FilledButton";
import { useExcalidrawActionManager, useExcalidrawSetAppState } from "../App";
import { actionSaveFileToDisk } from "../../actions";
import { useI18n } from "../../i18n";
import { actionChangeExportEmbedScene } from "../../actions/actionExport";
export type ActionProps = {
title: string;
children: React.ReactNode;
actionLabel: string;
onClick: () => void;
};
export const Action = ({
title,
children,
actionLabel,
onClick,
}: ActionProps) => {
return (
<div className="OverwriteConfirm__Actions__Action">
<h4>{title}</h4>
<div className="OverwriteConfirm__Actions__Action__content">
{children}
</div>
<FilledButton
variant="outlined"
color="muted"
label={actionLabel}
size="large"
fullWidth
onClick={onClick}
/>
</div>
);
};
export const ExportToImage = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const setAppState = useExcalidrawSetAppState();
return (
<Action
title={t("overwriteConfirm.action.exportToImage.title")}
actionLabel={t("overwriteConfirm.action.exportToImage.button")}
onClick={() => {
actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
setAppState({ openDialog: "imageExport" });
}}
>
{t("overwriteConfirm.action.exportToImage.description")}
</Action>
);
};
export const SaveToDisk = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
return (
<Action
title={t("overwriteConfirm.action.saveToDisk.title")}
actionLabel={t("overwriteConfirm.action.saveToDisk.button")}
onClick={() => {
actionManager.executeAction(actionSaveFileToDisk, "ui");
}}
>
{t("overwriteConfirm.action.saveToDisk.description")}
</Action>
);
};
const Actions = Object.assign(
({ children }: { children: React.ReactNode }) => {
return <div className="OverwriteConfirm__Actions">{children}</div>;
},
{
ExportToImage,
SaveToDisk,
},
);
export { Actions };

View File

@@ -1,46 +0,0 @@
import { atom } from "jotai";
import { jotaiStore } from "../../jotai";
import React from "react";
export type OverwriteConfirmState =
| {
active: true;
title: string;
description: React.ReactNode;
actionLabel: string;
color: "danger" | "warning";
onClose: () => void;
onConfirm: () => void;
onReject: () => void;
}
| { active: false };
export const overwriteConfirmStateAtom = atom<OverwriteConfirmState>({
active: false,
});
export async function openConfirmModal({
title,
description,
actionLabel,
color,
}: {
title: string;
description: React.ReactNode;
actionLabel: string;
color: "danger" | "warning";
}) {
return new Promise<boolean>((resolve) => {
jotaiStore.set(overwriteConfirmStateAtom, {
active: true,
onConfirm: () => resolve(true),
onClose: () => resolve(false),
onReject: () => resolve(false),
title,
description,
actionLabel,
color,
});
});
}

View File

@@ -3,7 +3,7 @@ import { t } from "../i18n";
import { useExcalidrawContainer } from "./App";
export const Section: React.FC<{
heading: "canvasActions" | "selectedShapeActions" | "shapes";
heading: string;
children?: React.ReactNode | ((heading: React.ReactNode) => React.ReactNode);
className?: string;
}> = ({ heading, children, ...props }) => {

View File

@@ -1,91 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.ShareableLinkDialog {
display: flex;
flex-direction: column;
gap: 1.5rem;
color: var(--text-primary-color);
::selection {
background: var(--color-primary-light-darker);
}
h3 {
font-family: "Assistant";
font-weight: 700;
font-size: 1.313rem;
line-height: 130%;
margin: 0;
}
&__popover {
@keyframes RoomDialog__popover__scaleIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
box-sizing: border-box;
z-index: 100;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
padding: 0.125rem 0.5rem;
gap: 0.125rem;
height: 1.125rem;
border: none;
border-radius: 0.6875rem;
font-family: "Assistant";
font-style: normal;
font-weight: 600;
font-size: 0.75rem;
line-height: 110%;
background: var(--color-success-lighter);
color: var(--color-success);
& > svg {
width: 0.875rem;
height: 0.875rem;
}
transform-origin: var(--radix-popover-content-transform-origin);
animation: RoomDialog__popover__scaleIn 150ms ease-out;
}
&__linkRow {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 0.75rem;
}
&__description {
border-top: 1px solid var(--color-gray-20);
padding: 0.5rem 0.5rem 0;
font-weight: 400;
font-size: 0.75rem;
line-height: 150%;
& p {
margin: 0;
}
& p + p {
margin-top: 1em;
}
}
}
}

View File

@@ -1,91 +0,0 @@
import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../clipboard";
import { useI18n } from "../i18n";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { FilledButton } from "./FilledButton";
import { copyIcon, tablerCheckIcon } from "./icons";
import "./ShareableLinkDialog.scss";
export type ShareableLinkDialogProps = {
link: string;
onCloseRequest: () => void;
setErrorMessage: (error: string) => void;
};
export const ShareableLinkDialog = ({
link,
onCloseRequest,
setErrorMessage,
}: ShareableLinkDialogProps) => {
const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false);
const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null);
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(link);
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
} catch (error: any) {
setErrorMessage(error.message);
}
ref.current?.select();
};
return (
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
<div className="ShareableLinkDialog">
<h3>Shareable link</h3>
<div className="ShareableLinkDialog__linkRow">
<TextField
ref={ref}
label="Link"
readonly
fullWidth
value={link}
selectOnRender
/>
<Popover.Root open={justCopied}>
<Popover.Trigger asChild>
<FilledButton
size="large"
label="Copy link"
startIcon={copyIcon}
onClick={copyRoomLink}
/>
</Popover.Trigger>
<Popover.Content
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="ShareableLinkDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div>
<div className="ShareableLinkDialog__description">
🔒 {t("alerts.uploadedSecurly")}
</div>
</div>
</Dialog>
);
};

View File

@@ -1,10 +1,4 @@
import {
forwardRef,
useRef,
useImperativeHandle,
KeyboardEvent,
useLayoutEffect,
} from "react";
import { forwardRef, useRef, useImperativeHandle, KeyboardEvent } from "react";
import clsx from "clsx";
import "./TextField.scss";
@@ -18,7 +12,6 @@ export type TextFieldProps = {
readonly?: boolean;
fullWidth?: boolean;
selectOnRender?: boolean;
label?: string;
placeholder?: string;
@@ -26,28 +19,13 @@ export type TextFieldProps = {
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
(
{
value,
onChange,
label,
fullWidth,
placeholder,
readonly,
selectOnRender,
onKeyDown,
},
{ value, onChange, label, fullWidth, placeholder, readonly, onKeyDown },
ref,
) => {
const innerRef = useRef<HTMLInputElement | null>(null);
useImperativeHandle(ref, () => innerRef.current!);
useLayoutEffect(() => {
if (selectOnRender) {
innerRef.current?.select();
}
}, [selectOnRender]);
return (
<div
className={clsx("ExcTextField", {

View File

@@ -1,6 +1,6 @@
import "./ToolIcon.scss";
import React, { CSSProperties, useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { useExcalidrawContainer } from "./App";
import { AbortError } from "../errors";
@@ -25,7 +25,6 @@ type ToolButtonBaseProps = {
visible?: boolean;
selected?: boolean;
className?: string;
style?: CSSProperties;
isLoading?: boolean;
};
@@ -115,7 +114,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
"ToolIcon--plain": props.type === "icon",
},
)}
style={props.style}
data-testid={props["data-testid"]}
hidden={props.hidden}
title={props.title}

View File

@@ -15,24 +15,7 @@
height: 1.5rem;
align-self: center;
background-color: var(--default-border-color);
margin: 0 0.25rem;
margin: 0 0.5rem;
}
}
.App-toolbar__extra-tools-trigger {
box-shadow: none;
border: 0;
&:active {
background-color: var(--button-hover-bg);
box-shadow: 0 0 0 1px
var(--button-active-border, var(--color-primary-darkest)) inset;
}
}
.App-toolbar__extra-tools-dropdown {
margin-top: 0.375rem;
right: 0;
min-width: 11.875rem;
}
}

View File

@@ -6,7 +6,7 @@
Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
position: fixed;
z-index: var(--zIndex-popup);
z-index: 1000;
padding: 8px;
border-radius: 6px;

View File

@@ -3,7 +3,6 @@ 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", () => {
@@ -19,27 +18,24 @@ describe("Test <Trans/>", () => {
const { getByTestId } = render(
<>
<div data-testid="test1">
<Trans
i18nKey={"transTest.key1" as unknown as TranslationKeys}
audience="world"
/>
<Trans i18nKey="transTest.key1" audience="world" />
</div>
<div data-testid="test2">
<Trans
i18nKey={"transTest.key2" as unknown as TranslationKeys}
i18nKey="transTest.key2"
link={(el) => <a href="https://example.com">{el}</a>}
/>
</div>
<div data-testid="test3">
<Trans
i18nKey={"transTest.key3" as unknown as TranslationKeys}
i18nKey="transTest.key3"
link={(el) => <a href="https://example.com">{el}</a>}
location="the button"
/>
</div>
<div data-testid="test4">
<Trans
i18nKey={"transTest.key4" as unknown as TranslationKeys}
i18nKey="transTest.key4"
link={(el) => <a href="https://example.com">{el}</a>}
location="the button"
bold={(el) => <strong>{el}</strong>}
@@ -47,7 +43,7 @@ describe("Test <Trans/>", () => {
</div>
<div data-testid="test5">
<Trans
i18nKey={"transTest.key5" as unknown as TranslationKeys}
i18nKey="transTest.key5"
connect-link={(el) => <a href="https://example.com">{el}</a>}
/>
</div>

View File

@@ -1,6 +1,6 @@
import React from "react";
import { TranslationKeys, useI18n } from "../i18n";
import { useI18n } from "../i18n";
// Used for splitting i18nKey into tokens in Trans component
// Example:
@@ -153,7 +153,7 @@ const Trans = ({
children,
...props
}: {
i18nKey: TranslationKeys;
i18nKey: string;
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
}) => {
const { t } = useI18n();

View File

@@ -1,35 +1,19 @@
@import "../../css/variables.module";
.excalidraw {
[data-dropdown-menu-trigger] + [data-radix-popper-content-wrapper] {
z-index: 2 !important;
}
.dropdown-menu {
max-width: 16rem;
&__submenu-trigger {
&[aria-expanded="true"] {
.dropdown-menu-item {
background-color: var(--button-hover-bg);
}
}
}
&__submenu-trigger-icon {
margin-left: auto;
opacity: 0.5;
}
.radix-menu-item {
&:focus-visible {
outline: none;
}
}
position: absolute;
top: 100%;
margin-top: 0.25rem;
&--mobile {
bottom: 55px;
top: auto;
left: 0;
width: 100%;
row-gap: 0.75rem;
.dropdown-menu-container {
grid-template-columns: minmax(0, 1fr);
padding: 8px 8px;
box-sizing: border-box;
background-color: var(--island-bg-color);
@@ -46,22 +30,21 @@
.dropdown-menu-container {
background-color: #fff !important;
max-height: var(--radix-popper-available-height);
max-height: calc(100vh - 150px);
overflow-y: auto;
--gap: 2;
}
.dropdown-menu-item-base {
display: flex;
padding: 0 0.75rem;
column-gap: 0.5rem;
padding: 0 0.625rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-gray-100);
width: 100%;
box-sizing: border-box;
font-weight: normal;
font-family: inherit;
justify-content: flex-start;
}
.dropdown-menu-item {
@@ -70,7 +53,7 @@
align-items: center;
height: 2rem;
cursor: pointer;
border-radius: var(--border-radius-sm);
border-radius: var(--border-radius-md);
@media screen and (min-width: 1921px) {
height: 2.25rem;

View File

@@ -13,8 +13,6 @@ import {
import "./DropdownMenu.scss";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
const DropdownMenu = ({
children,
open,
@@ -24,12 +22,11 @@ const DropdownMenu = ({
}) => {
const MenuTriggerComp = getMenuTriggerComponent(children);
const MenuContentComp = getMenuContentComponent(children);
return (
<DropdownMenuPrimitive.Root open={open} modal={false}>
<>
{MenuTriggerComp}
{MenuContentComp}
</DropdownMenuPrimitive.Root>
{open && MenuContentComp}
</>
);
};

View File

@@ -6,17 +6,12 @@ import React, { useRef } from "react";
import { DropdownMenuContentPropsContext } from "./common";
import { useOutsideClick } from "../../hooks/useOutsideClick";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
const MenuContent = ({
children,
onClickOutside,
className = "",
onSelect,
style,
sideOffset = 4,
align = "start",
collisionPadding,
}: {
children?: React.ReactNode;
onClickOutside?: () => void;
@@ -26,11 +21,6 @@ const MenuContent = ({
*/
onSelect?: (event: Event) => void;
style?: React.CSSProperties;
sideOffset?: number;
align?: "start" | "center" | "end";
collisionPadding?:
| number
| Partial<Record<"top" | "right" | "bottom" | "left", number>>;
}) => {
const device = useDevice();
const menuRef = useRef<HTMLDivElement>(null);
@@ -45,15 +35,11 @@ const MenuContent = ({
return (
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
<DropdownMenuPrimitive.Content
<div
ref={menuRef}
className={classNames}
style={style}
data-testid="dropdown-menu"
side="bottom"
sideOffset={sideOffset}
align={align}
collisionPadding={collisionPadding}
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
@@ -62,13 +48,13 @@ const MenuContent = ({
) : (
<Island
className="dropdown-menu-container"
padding={1}
padding={2}
style={{ zIndex: 2 }}
>
{children}
</Island>
)}
</DropdownMenuPrimitive.Content>
</div>
</DropdownMenuContentPropsContext.Provider>
);
};

View File

@@ -1,12 +1,10 @@
import { Button } from "../Button";
import React from "react";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
import MenuItemContent from "./DropdownMenuItemContent";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
const DropdownMenuItem = ({
icon,
onSelect,
@@ -24,19 +22,17 @@ const DropdownMenuItem = ({
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return (
<DropdownMenuPrimitive.Item className="radix-menu-item">
<Button
{...rest}
onClick={handleClick}
onSelect={() => {}}
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</Button>
</DropdownMenuPrimitive.Item>
<button
{...rest}
onClick={handleClick}
type="button"
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</button>
);
};

View File

@@ -1,26 +0,0 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import {
getSubMenuContentComponent,
getSubMenuTriggerComponent,
} from "./dropdownMenuUtils";
import DropdownMenuSubTrigger from "./DropdownMenuSubTrigger";
import DropdownMenuSubContent from "./DropdownMenuSubContent";
import DropdownMenuSubItem from "./DropdownMenuSubItem";
const DropdownMenuSub = ({ children }: { children?: React.ReactNode }) => {
const MenuTriggerComp = getSubMenuTriggerComponent(children);
const MenuContentComp = getSubMenuContentComponent(children);
return (
<DropdownMenuPrimitive.Sub>
{MenuTriggerComp}
{MenuContentComp}
</DropdownMenuPrimitive.Sub>
);
};
DropdownMenuSub.Trigger = DropdownMenuSubTrigger;
DropdownMenuSub.Content = DropdownMenuSubContent;
DropdownMenuSub.Item = DropdownMenuSubItem;
export default DropdownMenuSub;
DropdownMenuSub.displayName = "DropdownMenuSub";

View File

@@ -1,42 +0,0 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { useDevice } from "../App";
import Stack from "../Stack";
import { Island } from "../Island";
import clsx from "clsx";
const DropdownMenuSubContent = ({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) => {
const device = useDevice();
const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": device.isMobile,
}).trim();
return (
<DropdownMenuPrimitive.SubContent
className={classNames}
sideOffset={8}
alignOffset={-4}
>
{device.isMobile ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : (
<Island
className="dropdown-menu-container"
padding={1}
style={{ zIndex: 1 }}
>
{children}
</Island>
)}
</DropdownMenuPrimitive.SubContent>
);
};
export default DropdownMenuSubContent;
DropdownMenuSubContent.displayName = "DropdownMenuSubContent";

View File

@@ -1,45 +0,0 @@
import { Button } from "../Button";
import MenuItemContent from "./DropdownMenuItemContent";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
const DropdownMenuSubItem = ({
icon,
onSelect,
children,
shortcut,
className,
...rest
}: {
icon?: JSX.Element;
onSelect: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return (
<DropdownMenuPrimitive.Item className="radix-menu-item">
<Button
{...rest}
onClick={handleClick}
onSelect={() => {}}
type="button"
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</Button>
</DropdownMenuPrimitive.Item>
);
};
export default DropdownMenuSubItem;
DropdownMenuSubItem.displayName = "DropdownMenuSubItem";

View File

@@ -1,34 +0,0 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import React from "react";
import MenuItemContent from "./DropdownMenuItemContent";
import { getDropdownMenuItemClassName } from "./common";
import { ChevronRight } from "../icons";
const DropdownMenuSubTrigger = ({
children,
icon,
className,
...rest
}: {
children: React.ReactNode;
icon?: JSX.Element;
className?: string;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<DropdownMenuPrimitive.SubTrigger className="radix-menu-item dropdown-menu__submenu-trigger">
<div
{...rest}
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon}>{children}</MenuItemContent>
<div className="dropdown-menu__submenu-trigger-icon">
{ChevronRight}
</div>
</div>
</DropdownMenuPrimitive.SubTrigger>
);
};
export default DropdownMenuSubTrigger;
DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger";

View File

@@ -1,40 +1,36 @@
import clsx from "clsx";
import { useUIAppState } from "../../context/ui-appState";
import { useDevice } from "../App";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
const MenuTrigger = ({
className = "",
children,
onToggle,
title,
...rest
}: {
className?: string;
children: React.ReactNode;
onToggle: () => void;
title?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
}) => {
const appState = useUIAppState();
const device = useDevice();
const classNames = clsx(
`dropdown-menu-button ${className}`,
"zen-mode-transition",
{
"transition-left": appState.zenModeEnabled,
"dropdown-menu-button--mobile": device.isMobile,
},
).trim();
return (
<DropdownMenuPrimitive.Trigger
data-dropdown-menu-trigger
<button
data-prevent-outside-click
className={classNames}
onClick={onToggle}
type="button"
data-testid="dropdown-menu-button"
title={title}
{...rest}
>
{children}
</DropdownMenuPrimitive.Trigger>
</button>
);
};

View File

@@ -1,6 +1,6 @@
import React from "react";
const getMenuComponent = (component: string) => (children: React.ReactNode) => {
export const getMenuTriggerComponent = (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
@@ -8,7 +8,7 @@ const getMenuComponent = (component: string) => (children: React.ReactNode) => {
//@ts-ignore
child?.type.displayName &&
//@ts-ignore
child.type.displayName === component,
child.type.displayName === "DropdownMenuTrigger",
);
if (!comp) {
return null;
@@ -17,11 +17,19 @@ const getMenuComponent = (component: string) => (children: React.ReactNode) => {
return comp;
};
export const getMenuTriggerComponent = getMenuComponent("DropdownMenuTrigger");
export const getMenuContentComponent = getMenuComponent("DropdownMenuContent");
export const getSubMenuTriggerComponent = getMenuComponent(
"DropdownMenuSubTrigger",
);
export const getSubMenuContentComponent = getMenuComponent(
"DropdownMenuSubContent",
);
export const getMenuContentComponent = (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
typeof child.type !== "string" &&
//@ts-ignore
child?.type.displayName &&
//@ts-ignore
child.type.displayName === "DropdownMenuContent",
);
if (!comp) {
return null;
}
//@ts-ignore
return comp;
};

View File

@@ -12,17 +12,17 @@ describe("Test internal component fallback rendering", () => {
</div>,
);
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container",
);
expect(
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
).toBe(1);
expect(
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
).toBe(1);
});
@@ -36,17 +36,17 @@ describe("Test internal component fallback rendering", () => {
</div>,
);
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container",
);
expect(
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
).toBe(1);
expect(
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
).toBe(1);
});
@@ -62,17 +62,17 @@ describe("Test internal component fallback rendering", () => {
</div>,
);
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container",
);
expect(
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
).toBe(1);
expect(
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
).toBe(1);
});
@@ -84,17 +84,17 @@ describe("Test internal component fallback rendering", () => {
</div>,
);
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container",
);
expect(
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
).toBe(1);
expect(
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
).toBe(1);
});
});

View File

@@ -71,15 +71,6 @@ const modifiedTablerIconProps: Opts = {
// -----------------------------------------------------------------------------
//tabler-icons: chevron-right
export const ChevronRight = createIcon(
<g strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="9 6 15 12 9 18" />
</g>,
tablerIconProps,
);
// tabler-icons: present
export const PlusPromoIcon = createIcon(
<g strokeWidth="1.5">
@@ -405,14 +396,6 @@ export const TrashIcon = createIcon(
modifiedTablerIconProps,
);
export const EmbedIcon = createIcon(
<g strokeWidth="1.25">
<polyline points="12 16 18 10 12 4" />
<polyline points="8 4 2 10 8 16" />
</g>,
modifiedTablerIconProps,
);
export const DuplicateIcon = createIcon(
<g strokeWidth="1.25">
<path d="M14.375 6.458H8.958a2.5 2.5 0 0 0-2.5 2.5v5.417a2.5 2.5 0 0 0 2.5 2.5h5.417a2.5 2.5 0 0 0 2.5-2.5V8.958a2.5 2.5 0 0 0-2.5-2.5Z" />
@@ -1625,16 +1608,6 @@ export const tablerCheckIcon = createIcon(
tablerIconProps,
);
export const alertTriangleIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.24 3.957l-8.422 14.06a1.989 1.989 0 0 0 1.7 2.983h16.845a1.989 1.989 0 0 0 1.7 -2.983l-8.423 -14.06a1.989 1.989 0 0 0 -3.4 0z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</>,
tablerIconProps,
);
export const eyeDropperIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@@ -1643,24 +1616,3 @@ export const eyeDropperIcon = createIcon(
</g>,
tablerIconProps,
);
export const extraToolsIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 3l-4 7h8z"></path>
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"></path>
</g>,
tablerIconProps,
);
export const frameToolIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 7l16 0"></path>
<path d="M4 17l16 0"></path>
<path d="M7 4l0 16"></path>
<path d="M17 4l0 16"></path>
</g>,
tablerIconProps,
);

View File

@@ -1,11 +1,6 @@
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { useI18n } from "../../i18n";
import {
useExcalidrawSetAppState,
useExcalidrawActionManager,
useExcalidrawElements,
useAppProps,
} from "../App";
import { useExcalidrawSetAppState, useExcalidrawActionManager } from "../App";
import {
ExportIcon,
ExportImageIcon,
@@ -34,42 +29,19 @@ import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai";
import { useUIAppState } from "../../context/ui-appState";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans";
export const LoadScene = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const elements = useExcalidrawElements();
if (!actionManager.isActionEnabled(actionLoadScene)) {
return null;
}
const handleSelect = async () => {
if (
!elements.length ||
(await openConfirmModal({
title: t("overwriteConfirm.modal.loadFromFile.title"),
actionLabel: t("overwriteConfirm.modal.loadFromFile.button"),
color: "warning",
description: (
<Trans
i18nKey="overwriteConfirm.modal.loadFromFile.description"
bold={(text) => <strong>{text}</strong>}
br={() => <br />}
/>
),
}))
) {
actionManager.executeAction(actionLoadScene);
}
};
return (
<DropdownMenuItem
icon={LoadIcon}
onSelect={handleSelect}
onSelect={() => actionManager.executeAction(actionLoadScene)}
data-testid="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
aria-label={t("buttons.load")}
@@ -199,26 +171,16 @@ export const ChangeCanvasBackground = () => {
const { t } = useI18n();
const appState = useUIAppState();
const actionManager = useExcalidrawActionManager();
const appProps = useAppProps();
if (
appState.viewModeEnabled ||
!appProps.UIOptions.canvasActions.changeViewBackgroundColor
) {
if (appState.viewModeEnabled) {
return null;
}
return (
<div style={{ marginTop: "0.5rem" }}>
<div
data-testid="canvas-background-label"
style={{ fontSize: ".75rem", marginBottom: ".5rem" }}
>
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
{t("labels.canvasBackground")}
</div>
<div
style={{ padding: "0 0.625rem" }}
id="canvas-bg-color-picker-container"
>
<div style={{ padding: "0 0.625rem" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
</div>
</div>

View File

@@ -11,9 +11,6 @@ import { withInternalFallback } from "../hoc/withInternalFallback";
import { composeEventHandlers } from "../../utils";
import { useTunnels } from "../../context/tunnels";
import { useUIAppState } from "../../context/ui-appState";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import * as Portal from "@radix-ui/react-portal";
const MainMenu = Object.assign(
withInternalFallback(
@@ -38,17 +35,6 @@ const MainMenu = Object.assign(
return (
<MainMenuTunnel.In>
{appState.openMenu === "canvas" && device.isMobile && (
<Portal.Root
style={{
backgroundColor: "rgba(18, 18, 18, 0.2)",
position: "fixed",
inset: "0px",
zIndex: "var(--zIndex-layerUI)",
}}
onClick={() => setAppState({ openMenu: null })}
/>
)}
<DropdownMenu open={appState.openMenu === "canvas"}>
<DropdownMenu.Trigger
onToggle={() => {
@@ -56,27 +42,14 @@ const MainMenu = Object.assign(
openMenu: appState.openMenu === "canvas" ? null : "canvas",
});
}}
data-testid="main-menu-trigger"
aria-label="Main menu"
>
{HamburgerMenuIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
sideOffset={device.isMobile ? 20 : undefined}
className="main-menu-content"
onClickOutside={onClickOutside}
onSelect={composeEventHandlers(onSelect, () => {
setAppState({ openMenu: null });
})}
collisionPadding={
// accounting for
// - editor footer on desktop
// - toolbar on mobile
// we probably don't want the menu to overlay these elements
!device.isMobile
? { bottom: 90, top: 10 }
: { top: 90, bottom: 10 }
}
>
{children}
{device.isMobile && appState.collaborators.size > 0 && (
@@ -101,7 +74,6 @@ const MainMenu = Object.assign(
ItemCustom: DropdownMenu.ItemCustom,
Group: DropdownMenu.Group,
Separator: DropdownMenu.Separator,
Sub: DropdownMenuSub,
DefaultItems,
},
);

View File

@@ -71,18 +71,8 @@ export enum EVENT {
// custom events
EXCALIDRAW_LINK = "excalidraw-link",
MENU_ITEM_SELECT = "menu.itemSelect",
MESSAGE = "message",
}
export const YOUTUBE_STATES = {
UNSTARTED: -1,
ENDED: 0,
PLAYING: 1,
PAUSED: 2,
BUFFERING: 3,
CUED: 5,
} as const;
export const ENV = {
TEST: "test",
DEVELOPMENT: "development",
@@ -102,17 +92,6 @@ export const FONT_FAMILY = {
export const THEME = {
LIGHT: "light",
DARK: "dark",
} as const;
export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 1 as ExcalidrawElement["strokeWidth"],
strokeStyle: "solid" as ExcalidrawElement["strokeStyle"],
fillStyle: "solid" as ExcalidrawElement["fillStyle"],
roughness: 0 as ExcalidrawElement["roughness"],
roundness: null as ExcalidrawElement["roundness"],
backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"],
radius: 8,
};
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
@@ -310,5 +289,3 @@ export const DEFAULT_SIDEBAR = {
name: "default",
defaultTab: LIBRARY_SIDEBAR_TAB,
} as const;
export const LIBRARY_DISABLED_TYPES = new Set(["embeddable", "image"] as const);

View File

@@ -12,7 +12,6 @@ type TunnelsContextValue = {
FooterCenterTunnel: Tunnel;
DefaultSidebarTriggerTunnel: Tunnel;
DefaultSidebarTabTriggersTunnel: Tunnel;
OverwriteConfirmDialogTunnel: Tunnel;
jotaiScope: symbol;
};
@@ -31,7 +30,6 @@ export const useInitializeTunnels = () => {
FooterCenterTunnel: tunnel(),
DefaultSidebarTriggerTunnel: tunnel(),
DefaultSidebarTabTriggersTunnel: tunnel(),
OverwriteConfirmDialogTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);

View File

@@ -5,15 +5,6 @@
--zIndex-canvas: 1;
--zIndex-wysiwyg: 2;
--zIndex-layerUI: 3;
--zIndex-modal: 1000;
--zIndex-popup: 1001;
--zIndex-toast: 999999;
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
--sar: env(safe-area-inset-right);
--sat: env(safe-area-inset-top);
}
.excalidraw {
@@ -77,19 +68,6 @@
position: absolute;
}
&__embeddable {
width: 100%;
height: 100%;
border: 0;
}
&__embeddable-container {
position: absolute;
z-index: 2;
transform-origin: top left;
pointer-events: none;
}
&.theme--dark {
// The percentage is inspired by
// https://material.io/design/color/dark-theme.html#properties, which
@@ -674,33 +652,3 @@
}
}
}
.excalidraw__embeddable-container {
.excalidraw__embeddable-container__inner {
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--embeddable-radius);
}
.excalidraw__embeddable__outer {
width: 100%;
height: 100%;
& > * {
border-radius: var(--embeddable-radius);
}
}
.excalidraw__embeddable-hint {
position: absolute;
z-index: 1;
background: rgba(0, 0, 0, 0.5);
padding: 1rem 1.6rem;
border-radius: 12px;
color: #fff;
font-weight: bold;
letter-spacing: 0.6px;
font-family: "Assistant";
}
}

View File

@@ -27,6 +27,10 @@
--popup-secondary-bg-color: #{$oc-gray-1};
--popup-text-color: #{$oc-black};
--popup-text-inverted-color: #{$oc-white};
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
--sar: env(safe-area-inset-right);
--sat: env(safe-area-inset-top);
--select-highlight-color: #{$oc-blue-5};
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
@@ -95,38 +99,13 @@
--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;
--border-radius-sm: 0.25rem;
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
@@ -198,27 +177,6 @@
--color-text-warning: var(--color-gray-80);
--color-danger: #ffa8a5;
--color-danger-dark: #672120;
--color-danger-darker: #8f2625;
--color-danger-darkest: #ac2b29;
--color-danger-text: #fbcbcc;
--color-danger-background: #fbcbcc;
--color-danger-icon-background: #672120;
--color-danger-color: #261919;
--color-danger-icon-color: #fbcbcc;
--color-warning-background: var(--color-warning);
--color-warning-icon-background: var(--color-warning-dark);
--color-warning-color: var(--color-gray-80);
--color-warning-icon-color: var(--color-gray-80);
--color-muted: var(--color-gray-80);
--color-muted-darker: var(--color-gray-60);
--color-muted-darkest: var(--color-gray-20);
--color-muted-background: var(--color-gray-40);
--color-muted-background-darker: var(--color-gray-20);
--color-promo: #d297ff;
}
}

View File

@@ -3,7 +3,6 @@ import {
ExcalidrawSelectionElement,
ExcalidrawTextElement,
FontFamilyValues,
PointBinding,
StrokeRoundness,
} from "../element/types";
import {
@@ -42,7 +41,6 @@ import {
measureBaseline,
} from "../element/textElement";
import { COLOR_PALETTE } from "../colors";
import { normalizeLink } from "./url";
type RestoredAppState = Omit<
AppState,
@@ -64,8 +62,6 @@ export const AllowedExcalidrawActiveTools: Record<
freedraw: true,
eraser: false,
custom: true,
frame: true,
embeddable: true,
hand: true,
};
@@ -84,13 +80,6 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
return DEFAULT_FONT_FAMILY;
};
const repairBinding = (binding: PointBinding | null) => {
if (!binding) {
return null;
}
return { ...binding, focus: binding.focus || 0 };
};
const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "customData">> & {
customData?: ExcalidrawElement["customData"];
@@ -136,7 +125,6 @@ const restoreElementWithProperties = <
height: element.height || 0,
seed: element.seed ?? 1,
groupIds: element.groupIds ?? [],
frameId: element.frameId ?? null,
roundness: element.roundness
? element.roundness
: element.strokeSharpness === "round"
@@ -152,7 +140,7 @@ const restoreElementWithProperties = <
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
: element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ? normalizeLink(element.link) : null,
link: element.link ?? null,
locked: element.locked ?? false,
};
@@ -266,8 +254,8 @@ const restoreElement = (
(element.type as ExcalidrawElement["type"] | "draw") === "draw"
? "line"
: element.type,
startBinding: repairBinding(element.startBinding),
endBinding: repairBinding(element.endBinding),
startBinding: element.startBinding,
endBinding: element.endBinding,
lastCommittedPoint: null,
startArrowhead,
endArrowhead,
@@ -284,14 +272,6 @@ const restoreElement = (
return restoreElementWithProperties(element, {});
case "diamond":
return restoreElementWithProperties(element, {});
case "embeddable":
return restoreElementWithProperties(element, {
validated: undefined,
});
case "frame":
return restoreElementWithProperties(element, {
name: element.name ?? null,
});
// Don't use default case so as to catch a missing an element type case.
// We also don't want to throw, but instead return void so we filter
@@ -384,24 +364,6 @@ const repairBoundElement = (
}
};
/**
* Remove an element's frameId if its containing frame is non-existent
*
* NOTE mutates elements.
*/
const repairFrameMembership = (
element: Mutable<ExcalidrawElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
if (element.frameId) {
const containingFrame = elementsMap.get(element.frameId);
if (!containingFrame) {
element.frameId = null;
}
}
};
export const restoreElements = (
elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
@@ -442,10 +404,6 @@ export const restoreElements = (
// repair binding. Mutates elements.
const restoredElementsMap = arrayToMap(restoredElements);
for (const element of restoredElements) {
if (element.frameId) {
repairFrameMembership(element, restoredElementsMap);
}
if (isTextElement(element) && element.containerId) {
repairBoundElement(element, restoredElementsMap);
} else if (element.boundElements) {

View File

@@ -1,30 +0,0 @@
import { normalizeLink } from "./url";
describe("normalizeLink", () => {
// NOTE not an extensive XSS test suite, just to check if we're not
// regressing in sanitization
it("should sanitize links", () => {
expect(
// eslint-disable-next-line no-script-url
normalizeLink(`javascript://%0aalert(document.domain)`).startsWith(
// eslint-disable-next-line no-script-url
`javascript:`,
),
).toBe(false);
expect(normalizeLink("ola")).toBe("ola");
expect(normalizeLink(" ola")).toBe("ola");
expect(normalizeLink("https://www.excalidraw.com")).toBe(
"https://www.excalidraw.com",
);
expect(normalizeLink("www.excalidraw.com")).toBe("www.excalidraw.com");
expect(normalizeLink("/ola")).toBe("/ola");
expect(normalizeLink("http://test")).toBe("http://test");
expect(normalizeLink("ftp://test")).toBe("ftp://test");
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
expect(normalizeLink("[[test]]")).toBe("[[test]]");
expect(normalizeLink("<test>")).toBe("<test>");
});
});

View File

@@ -1,35 +0,0 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
export const normalizeLink = (link: string) => {
link = link.trim();
if (!link) {
return link;
}
return sanitizeUrl(link);
};
export const isLocalLink = (link: string | null) => {
return !!(link?.includes(location.origin) || link?.startsWith("/"));
};
/**
* Returns URL sanitized and safe for usage in places such as
* iframe's src attribute or <a> href attributes.
*/
export const toValidURL = (link: string) => {
link = normalizeLink(link);
// make relative links into fully-qualified urls
if (link.startsWith("/")) {
return `${location.origin}${link}`;
}
try {
new URL(link);
} catch {
// if link does not parse as URL, assume invalid and return blank page
return "about:blank";
}
return link;
};

View File

@@ -55,6 +55,10 @@
}
}
.d-none {
display: none;
}
&--remove .ToolIcon__icon svg {
color: $oc-red-6;
}

View File

@@ -5,12 +5,8 @@ import {
viewportCoordsToSceneCoords,
wrapEvent,
} from "../utils";
import { getEmbedLink, embeddableURLValidator } from "./embeddable";
import { mutateElement } from "./mutateElement";
import {
ExcalidrawEmbeddableElement,
NonDeletedExcalidrawElement,
} from "./types";
import { NonDeletedExcalidrawElement } from "./types";
import { register } from "../actions/register";
import { ToolButton } from "../components/ToolButton";
@@ -25,10 +21,7 @@ import {
} from "react";
import clsx from "clsx";
import { KEYS } from "../keys";
import {
DEFAULT_LINK_SIZE,
invalidateShapeForElement,
} from "../renderer/renderElement";
import { DEFAULT_LINK_SIZE } from "../renderer/renderElement";
import { rotate } from "../math";
import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants";
import { Bounds } from "./bounds";
@@ -36,12 +29,10 @@ 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";
import { useAppProps, useExcalidrawAppState } from "../components/App";
import { isEmbeddableElement } from "./typeChecks";
import { useExcalidrawAppState } from "../components/App";
const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85;
@@ -56,112 +47,37 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
const embeddableLinkCache = new Map<
ExcalidrawEmbeddableElement["id"],
string
>();
export const Hyperlink = ({
element,
setAppState,
onLinkOpen,
setToast,
}: {
element: NonDeletedExcalidrawElement;
setAppState: React.Component<any, AppState>["setState"];
onLinkOpen: ExcalidrawProps["onLinkOpen"];
setToast: (
toast: { message: string; closable?: boolean; duration?: number } | null,
) => void;
}) => {
const appState = useExcalidrawAppState();
const appProps = useAppProps();
const linkVal = element.link || "";
const [inputVal, setInputVal] = useState(linkVal);
const inputRef = useRef<HTMLInputElement>(null);
const isEditing = appState.showHyperlinkPopup === "editor";
const isEditing = appState.showHyperlinkPopup === "editor" || !linkVal;
const handleSubmit = useCallback(() => {
if (!inputRef.current) {
return;
}
const link = normalizeLink(inputRef.current.value) || null;
const link = normalizeLink(inputRef.current.value);
if (!element.link && link) {
trackEvent("hyperlink", "create");
}
if (isEmbeddableElement(element)) {
if (appState.activeEmbeddable?.element === element) {
setAppState({ activeEmbeddable: null });
}
if (!link) {
mutateElement(element, {
validated: false,
link: null,
});
return;
}
if (!embeddableURLValidator(link, appProps.validateEmbeddable)) {
if (link) {
setToast({ message: t("toast.unableToEmbed"), closable: true });
}
element.link && embeddableLinkCache.set(element.id, element.link);
mutateElement(element, {
validated: false,
link,
});
invalidateShapeForElement(element);
} else {
const { width, height } = element;
const embedLink = getEmbedLink(link);
if (embedLink?.warning) {
setToast({ message: embedLink.warning, closable: true });
}
const ar = embedLink
? embedLink.aspectRatio.w / embedLink.aspectRatio.h
: 1;
const hasLinkChanged =
embeddableLinkCache.get(element.id) !== element.link;
mutateElement(element, {
...(hasLinkChanged
? {
width:
embedLink?.type === "video"
? width > height
? width
: height * ar
: width,
height:
embedLink?.type === "video"
? width > height
? width / ar
: height
: height,
}
: {}),
validated: true,
link,
});
invalidateShapeForElement(element);
if (embeddableLinkCache.has(element.id)) {
embeddableLinkCache.delete(element.id);
}
}
} else {
mutateElement(element, { link });
}
}, [
element,
setToast,
appProps.validateEmbeddable,
appState.activeEmbeddable,
setAppState,
]);
mutateElement(element, { link });
setAppState({ showHyperlinkPopup: "info" });
}, [element, setAppState]);
useLayoutEffect(() => {
return () => {
@@ -215,12 +131,10 @@ export const Hyperlink = ({
appState.draggingElement ||
appState.resizingElement ||
appState.isRotating ||
appState.openMenu ||
appState.viewModeEnabled
appState.openMenu
) {
return null;
}
return (
<div
className="excalidraw-hyperlinkContainer"
@@ -230,11 +144,6 @@ export const Hyperlink = ({
width: CONTAINER_WIDTH,
padding: CONTAINER_PADDING,
}}
onClick={() => {
if (!element.link && !isEditing) {
setAppState({ showHyperlinkPopup: "editor" });
}
}}
>
{isEditing ? (
<input
@@ -252,14 +161,15 @@ export const Hyperlink = ({
}
if (event.key === KEYS.ENTER || event.key === KEYS.ESCAPE) {
handleSubmit();
setAppState({ showHyperlinkPopup: "info" });
}
}}
/>
) : element.link ? (
) : (
<a
href={normalizeLink(element.link || "")}
className="excalidraw-hyperlinkContainer-link"
href={element.link || ""}
className={clsx("excalidraw-hyperlinkContainer-link", {
"d-none": isEditing,
})}
target={isLocalLink(element.link) ? "_self" : "_blank"}
onClick={(event) => {
if (element.link && onLinkOpen) {
@@ -267,13 +177,7 @@ export const Hyperlink = ({
EVENT.EXCALIDRAW_LINK,
event.nativeEvent,
);
onLinkOpen(
{
...element,
link: normalizeLink(element.link),
},
customEvent,
);
onLinkOpen(element, customEvent);
if (customEvent.defaultPrevented) {
event.preventDefault();
}
@@ -283,10 +187,6 @@ export const Hyperlink = ({
>
{element.link}
</a>
) : (
<div className="excalidraw-hyperlinkContainer-link">
{t("labels.link.empty")}
</div>
)}
<div className="excalidraw-hyperlinkContainer__buttons">
{!isEditing && (
@@ -300,7 +200,8 @@ export const Hyperlink = ({
icon={FreedrawIcon}
/>
)}
{linkVal && !isEmbeddableElement(element) && (
{linkVal && (
<ToolButton
type="button"
title={t("buttons.remove")}
@@ -330,6 +231,21 @@ const getCoordsForPopover = (
return { x, y };
};
export const normalizeLink = (link: string) => {
link = link.trim();
if (link) {
// prefix with protocol if not fully-qualified
if (!link.includes("://") && !/^[[\\/]/.test(link)) {
link = `https://${link}`;
}
}
return link;
};
export const isLocalLink = (link: string | null) => {
return !!(link?.includes(location.origin) || link?.startsWith("/"));
};
export const actionLink = register({
name: "hyperlink",
perform: (elements, appState) => {
@@ -363,11 +279,7 @@ export const actionLink = register({
type="button"
icon={LinkIcon}
aria-label={t(getContextMenuLabel(elements, appState))}
title={`${
isEmbeddableElement(elements[0])
? t("labels.link.labelEmbed")
: t("labels.link.label")
} - ${getShortcutKey("CtrlOrCmd+K")}`}
title={`${t("labels.link.label")} - ${getShortcutKey("CtrlOrCmd+K")}`}
onClick={() => updateData(null)}
selected={selectedElements.length === 1 && !!selectedElements[0].link}
/>
@@ -381,11 +293,7 @@ export const getContextMenuLabel = (
) => {
const selectedElements = getSelectedElements(elements, appState);
const label = selectedElements[0]!.link
? isEmbeddableElement(selectedElements[0])
? "labels.link.editEmbed"
: "labels.link.edit"
: isEmbeddableElement(selectedElements[0])
? "labels.link.createEmbed"
? "labels.link.edit"
: "labels.link.create";
return label;
};
@@ -427,9 +335,21 @@ export const isPointHittingLinkIcon = (
element: NonDeletedExcalidrawElement,
appState: AppState,
[x, y]: Point,
isMobile: boolean,
) => {
if (!element.link || appState.selectedElementIds[element.id]) {
return false;
}
const threshold = 4 / appState.zoom.value;
if (
!isMobile &&
appState.viewModeEnabled &&
isPointHittingElementBoundingBox(element, [x, y], threshold)
) {
return true;
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
[x1, y1, x2, y2],
element.angle,
@@ -443,26 +363,6 @@ export const isPointHittingLinkIcon = (
return hitLink;
};
export const isPointHittingLink = (
element: NonDeletedExcalidrawElement,
appState: AppState,
[x, y]: Point,
isMobile: boolean,
) => {
if (!element.link || appState.selectedElementIds[element.id]) {
return false;
}
const threshold = 4 / appState.zoom.value;
if (
!isMobile &&
appState.viewModeEnabled &&
isPointHittingElementBoundingBox(element, [x, y], threshold, null)
) {
return true;
}
return isPointHittingLinkIcon(element, appState, [x, y]);
};
let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null;
export const showHyperlinkTooltip = (
element: NonDeletedExcalidrawElement,
@@ -540,9 +440,7 @@ export const shouldHideLinkPopup = (
const threshold = 15 / appState.zoom.value;
// hitbox to prevent hiding when hovered in element bounding box
if (
isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold, null)
) {
if (isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold)) {
return false;
}
const [x1, y1, x2] = getElementAbsoluteCoords(element);

View File

@@ -39,7 +39,7 @@ export type SuggestedPointBinding = [
];
export const shouldEnableBindingForPointerEvent = (
event: React.PointerEvent<HTMLElement>,
event: React.PointerEvent<HTMLCanvasElement>,
) => {
return !event[KEYS.CTRL_OR_CMD];
};

View File

@@ -6,7 +6,7 @@ import {
NonDeleted,
ExcalidrawTextElementWithContainer,
} from "./types";
import { distance2d, rotate, rotatePoint } from "../math";
import { distance2d, rotate } from "../math";
import rough from "roughjs/bin/rough";
import { Drawable, Op } from "roughjs/bin/core";
import { Point } from "../types";
@@ -25,101 +25,10 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { Mutable } from "../utility-types";
export type RectangleBox = {
x: number;
y: number;
width: number;
height: number;
angle: number;
};
// x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [number, number, number, number];
type MaybeQuadraticSolution = [number | null, number | null] | false;
// x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [x1: number, y1: number, x2: number, y2: number];
export class ElementBounds {
private static boundsCache = new WeakMap<
ExcalidrawElement,
{
bounds: Bounds;
version: ExcalidrawElement["version"];
}
>();
static getBounds(element: ExcalidrawElement) {
const cachedBounds = ElementBounds.boundsCache.get(element);
if (cachedBounds?.version && cachedBounds.version === element.version) {
return cachedBounds.bounds;
}
const bounds = ElementBounds.calculateBounds(element);
ElementBounds.boundsCache.set(element, {
version: element.version,
bounds,
});
return bounds;
}
private static calculateBounds(element: ExcalidrawElement): Bounds {
let bounds: [number, number, number, number];
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
rotate(x, y, cx - element.x, cy - element.y, element.angle),
),
);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy);
} else if (element.type === "diamond") {
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
} else if (element.type === "ellipse") {
const w = (x2 - x1) / 2;
const h = (y2 - y1) / 2;
const cos = Math.cos(element.angle);
const sin = Math.sin(element.angle);
const ww = Math.hypot(w * cos, h * sin);
const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
}
return bounds;
}
}
// Scene -> Scene coords, but in x1,x2,y1,y2 format.
//
// If the element is created from right to left, the width is going to be negative
// This set of functions retrieves the absolute position of the 4 points.
export const getElementAbsoluteCoords = (
@@ -160,111 +69,6 @@ export const getElementAbsoluteCoords = (
];
};
/**
* for a given element, `getElementLineSegments` returns line segments
* that can be used for visual collision detection (useful for frames)
* as opposed to bounding box collision detection
*/
export const getElementLineSegments = (
element: ExcalidrawElement,
): [Point, Point][] => {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
const center: Point = [cx, cy];
if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: [Point, Point][] = [];
let i = 0;
while (i < element.points.length - 1) {
segments.push([
rotatePoint(
[
element.points[i][0] + element.x,
element.points[i][1] + element.y,
] as Point,
center,
element.angle,
),
rotatePoint(
[
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
] as Point,
center,
element.angle,
),
]);
i++;
}
return segments;
}
const [nw, ne, sw, se, n, s, w, e] = (
[
[x1, y1],
[x2, y1],
[x1, y2],
[x2, y2],
[cx, y1],
[cx, y2],
[x1, cy],
[x2, cy],
] as Point[]
).map((point) => rotatePoint(point, center, element.angle));
if (element.type === "diamond") {
return [
[n, w],
[n, e],
[s, w],
[s, e],
];
}
if (element.type === "ellipse") {
return [
[n, w],
[n, e],
[s, w],
[s, e],
[n, w],
[n, e],
[s, w],
[s, e],
];
}
return [
[nw, ne],
[sw, se],
[nw, sw],
[ne, se],
[nw, e],
[sw, e],
[ne, w],
[se, w],
];
};
/**
* Scene -> Scene coords, but in x1,x2,y1,y2 format.
*
* Rectangle here means any rectangular frame, not an excalidraw element.
*/
export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
return [
boxSceneCoords.x,
boxSceneCoords.y,
boxSceneCoords.x + boxSceneCoords.width,
boxSceneCoords.y + boxSceneCoords.height,
boxSceneCoords.x + boxSceneCoords.width / 2,
boxSceneCoords.y + boxSceneCoords.height / 2,
];
};
export const pointRelativeTo = (
element: ExcalidrawElement,
absoluteCoords: Point,
@@ -650,12 +454,64 @@ const getLinearElementRotatedBounds = (
return coords;
};
export const getElementBounds = (element: ExcalidrawElement): Bounds => {
return ElementBounds.getBounds(element);
// We could cache this stuff
export const getElementBounds = (
element: ExcalidrawElement,
): [number, number, number, number] => {
let bounds: [number, number, number, number];
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
rotate(x, y, cx - element.x, cy - element.y, element.angle),
),
);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy);
} else if (element.type === "diamond") {
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
} else if (element.type === "ellipse") {
const w = (x2 - x1) / 2;
const h = (y2 - y1) / 2;
const cos = Math.cos(element.angle);
const sin = Math.sin(element.angle);
const ww = Math.hypot(w * cos, h * sin);
const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
}
return bounds;
};
export const getCommonBounds = (
elements: readonly ExcalidrawElement[],
): Bounds => {
): [number, number, number, number] => {
if (!elements.length) {
return [0, 0, 0, 0];
}
@@ -752,7 +608,7 @@ export const getElementPointsCoords = (
export const getClosestElementBounds = (
elements: readonly ExcalidrawElement[],
from: { x: number; y: number },
): Bounds => {
): [number, number, number, number] => {
if (!elements.length) {
return [0, 0, 0, 0];
}
@@ -773,7 +629,7 @@ export const getClosestElementBounds = (
return getElementBounds(closestElement);
};
export interface BoundingBox {
export interface Box {
minX: number;
minY: number;
maxX: number;
@@ -786,7 +642,7 @@ export interface BoundingBox {
export const getCommonBoundingBox = (
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
): BoundingBox => {
): Box => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return {
minX,

View File

@@ -18,7 +18,6 @@ import {
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawRectangleElement,
ExcalidrawEmbeddableElement,
ExcalidrawDiamondElement,
ExcalidrawTextElement,
ExcalidrawEllipseElement,
@@ -27,24 +26,14 @@ import {
ExcalidrawImageElement,
ExcalidrawLinearElement,
StrokeRoundness,
ExcalidrawFrameElement,
} from "./types";
import {
getElementAbsoluteCoords,
getCurvePathOps,
getRectangleBoxAbsoluteCoords,
RectangleBox,
} from "./bounds";
import { FrameNameBoundsCache, Point } from "../types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
import { Point } from "../types";
import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement";
import {
hasBoundTextElement,
isEmbeddableElement,
isImageElement,
} from "./typeChecks";
import { hasBoundTextElement, isImageElement } from "./typeChecks";
import { isTextElement } from ".";
import { isTransparent } from "../utils";
import { shouldShowBoundingBox } from "./transformHandles";
@@ -62,9 +51,7 @@ const isElementDraggableFromInside = (
return true;
}
const isDraggableFromInside =
!isTransparent(element.backgroundColor) ||
hasBoundTextElement(element) ||
isEmbeddableElement(element);
!isTransparent(element.backgroundColor) || hasBoundTextElement(element);
if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points);
}
@@ -74,7 +61,6 @@ const isElementDraggableFromInside = (
export const hitTest = (
element: NonDeletedExcalidrawElement,
appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache,
x: number,
y: number,
): boolean => {
@@ -86,39 +72,22 @@ export const hitTest = (
isElementSelected(appState, element) &&
shouldShowBoundingBox([element], appState)
) {
return isPointHittingElementBoundingBox(
element,
point,
threshold,
frameNameBoundsCache,
);
return isPointHittingElementBoundingBox(element, point, threshold);
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const isHittingBoundTextElement = hitTest(
boundTextElement,
appState,
frameNameBoundsCache,
x,
y,
);
const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
if (isHittingBoundTextElement) {
return true;
}
}
return isHittingElementNotConsideringBoundingBox(
element,
appState,
frameNameBoundsCache,
point,
);
return isHittingElementNotConsideringBoundingBox(element, appState, point);
};
export const isHittingElementBoundingBoxWithoutHittingElement = (
element: NonDeletedExcalidrawElement,
appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache,
x: number,
y: number,
): boolean => {
@@ -127,33 +96,19 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
// So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
// eg for linear elements text can be outside the element bounding box
const boundTextElement = getBoundTextElement(element);
if (
boundTextElement &&
hitTest(boundTextElement, appState, frameNameBoundsCache, x, y)
) {
if (boundTextElement && hitTest(boundTextElement, appState, x, y)) {
return false;
}
return (
!isHittingElementNotConsideringBoundingBox(
element,
appState,
frameNameBoundsCache,
[x, y],
) &&
isPointHittingElementBoundingBox(
element,
[x, y],
threshold,
frameNameBoundsCache,
)
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
isPointHittingElementBoundingBox(element, [x, y], threshold)
);
};
export const isHittingElementNotConsideringBoundingBox = (
element: NonDeletedExcalidrawElement,
appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache | null,
point: Point,
): boolean => {
const threshold = 10 / appState.zoom.value;
@@ -162,13 +117,7 @@ export const isHittingElementNotConsideringBoundingBox = (
: isElementDraggableFromInside(element)
? isInsideCheck
: isNearCheck;
return hitTestPointAgainstElement({
element,
point,
threshold,
check,
frameNameBoundsCache,
});
return hitTestPointAgainstElement({ element, point, threshold, check });
};
const isElementSelected = (
@@ -180,22 +129,7 @@ export const isPointHittingElementBoundingBox = (
element: NonDeleted<ExcalidrawElement>,
[x, y]: Point,
threshold: number,
frameNameBoundsCache: FrameNameBoundsCache | null,
) => {
// frames needs be checked differently so as to be able to drag it
// by its frame, whether it has been selected or not
// this logic here is not ideal
// TODO: refactor it later...
if (element.type === "frame") {
return hitTestPointAgainstElement({
element,
point: [x, y],
threshold,
check: isInsideCheck,
frameNameBoundsCache,
});
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const elementCenterX = (x1 + x2) / 2;
const elementCenterY = (y1 + y2) / 2;
@@ -223,13 +157,7 @@ export const bindingBorderTest = (
const threshold = maxBindingGap(element, element.width, element.height);
const check = isOutsideCheck;
const point: Point = [x, y];
return hitTestPointAgainstElement({
element,
point,
threshold,
check,
frameNameBoundsCache: null,
});
return hitTestPointAgainstElement({ element, point, threshold, check });
};
export const maxBindingGap = (
@@ -249,13 +177,11 @@ type HitTestArgs = {
point: Point;
threshold: number;
check: (distance: number, threshold: number) => boolean;
frameNameBoundsCache: FrameNameBoundsCache | null;
};
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
switch (args.element.type) {
case "rectangle":
case "embeddable":
case "image":
case "text":
case "diamond":
@@ -282,27 +208,6 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
"This should not happen, we need to investigate why it does.",
);
return false;
case "frame": {
// check distance to frame element first
if (
args.check(
distanceToBindableElement(args.element, args.point),
args.threshold,
)
) {
return true;
}
const frameNameBounds = args.frameNameBoundsCache?.get(args.element);
if (frameNameBounds) {
return args.check(
distanceToRectangleBox(frameNameBounds, args.point),
args.threshold,
);
}
return false;
}
}
};
@@ -314,8 +219,6 @@ export const distanceToBindableElement = (
case "rectangle":
case "image":
case "text":
case "embeddable":
case "frame":
return distanceToRectangle(element, point);
case "diamond":
return distanceToDiamond(element, point);
@@ -345,9 +248,7 @@ const distanceToRectangle = (
| ExcalidrawRectangleElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement
| ExcalidrawEmbeddableElement
| ExcalidrawFrameElement,
| ExcalidrawImageElement,
point: Point,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@@ -357,14 +258,6 @@ const distanceToRectangle = (
);
};
const distanceToRectangleBox = (box: RectangleBox, point: Point): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToDivElement(point, box);
return Math.max(
GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)),
GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)),
);
};
const distanceToDiamond = (
element: ExcalidrawDiamondElement,
point: Point,
@@ -564,7 +457,8 @@ const pointRelativeToElement = (
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter(x1, y1, x2, y2);
const elementCoords = getElementAbsoluteCoords(element);
const center = coordsCenter([x1, y1, x2, y2]);
// GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle);
const pointRotated = GATransform.apply(rotate, point);
@@ -572,26 +466,9 @@ const pointRelativeToElement = (
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
const elementPos = GA.offset(element.x, element.y);
const pointRelToPos = GA.sub(pointRotated, elementPos);
const halfWidth = (x2 - x1) / 2;
const halfHeight = (y2 - y1) / 2;
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
};
const pointRelativeToDivElement = (
pointTuple: Point,
rectangle: RectangleBox,
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
const [x1, y1, x2, y2] = getRectangleBoxAbsoluteCoords(rectangle);
const center = coordsCenter(x1, y1, x2, y2);
const rotate = GATransform.rotation(center, rectangle.angle);
const pointRotated = GATransform.apply(rotate, point);
const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
const elementPos = GA.offset(rectangle.x, rectangle.y);
const pointRelToPos = GA.sub(pointRotated, elementPos);
const halfWidth = (x2 - x1) / 2;
const halfHeight = (y2 - y1) / 2;
const [ax, ay, bx, by] = elementCoords;
const halfWidth = (bx - ax) / 2;
const halfHeight = (by - ay) / 2;
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
};
@@ -613,7 +490,7 @@ const relativizationToElementCenter = (
element: ExcalidrawElement,
): GA.Transform => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter(x1, y1, x2, y2);
const center = coordsCenter([x1, y1, x2, y2]);
// GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle);
const translate = GA.reverse(
@@ -622,13 +499,8 @@ const relativizationToElementCenter = (
return GATransform.compose(rotate, translate);
};
const coordsCenter = (
x1: number,
y1: number,
x2: number,
y2: number,
): GA.Point => {
return GA.point((x1 + x2) / 2, (y1 + y2) / 2);
const coordsCenter = ([ax, ay, bx, by]: Bounds): GA.Point => {
return GA.point((ax + bx) / 2, (ay + by) / 2);
};
// The focus distance is the oriented ratio between the size of
@@ -655,23 +527,16 @@ export const determineFocusDistance = (
const c = line[1];
const mabs = Math.abs(m);
const nabs = Math.abs(n);
let ret;
switch (element.type) {
case "rectangle":
case "image":
case "text":
case "embeddable":
case "frame":
ret = c / (hwidth * (nabs + q * mabs));
break;
return c / (hwidth * (nabs + q * mabs));
case "diamond":
ret = mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
break;
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
case "ellipse":
ret = c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
break;
return c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
}
return ret || 0;
};
export const determineFocusPoint = (
@@ -683,7 +548,7 @@ export const determineFocusPoint = (
): Point => {
if (focus === 0) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter(x1, y1, x2, y2);
const center = coordsCenter([x1, y1, x2, y2]);
return GAPoint.toTuple(center);
}
const relateToCenter = relativizationToElementCenter(element);
@@ -698,8 +563,6 @@ export const determineFocusPoint = (
case "image":
case "text":
case "diamond":
case "embeddable":
case "frame":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
break;
case "ellipse":
@@ -750,8 +613,6 @@ const getSortedElementLineIntersections = (
case "image":
case "text":
case "diamond":
case "embeddable":
case "frame":
const corners = getCorners(element);
intersections = corners
.flatMap((point, i) => {
@@ -785,9 +646,7 @@ const getCorners = (
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement
| ExcalidrawEmbeddableElement
| ExcalidrawFrameElement,
| ExcalidrawTextElement,
scale: number = 1,
): GA.Point[] => {
const hx = (scale * element.width) / 2;
@@ -796,8 +655,6 @@ const getCorners = (
case "rectangle":
case "image":
case "text":
case "embeddable":
case "frame":
return [
GA.point(hx, hy),
GA.point(hx, -hy),
@@ -945,9 +802,7 @@ export const findFocusPointForRectangulars = (
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement
| ExcalidrawEmbeddableElement
| ExcalidrawFrameElement,
| ExcalidrawTextElement,
// Between -1 and 1 for how far away should the focus point be relative
// to the size of the element. Sign determines orientation.
relativeDistance: number,

View File

@@ -6,8 +6,6 @@ import { NonDeletedExcalidrawElement } from "./types";
import { AppState, PointerDownState } from "../types";
import { getBoundTextElement } from "./textElement";
import { isSelectedViaGroup } from "../groups";
import Scene from "../scene/Scene";
import { isFrameElement } from "./typeChecks";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
@@ -18,31 +16,10 @@ export const dragSelectedElements = (
distanceX: number = 0,
distanceY: number = 0,
appState: AppState,
scene: Scene,
) => {
const [x1, y1] = getCommonBounds(selectedElements);
const offset = { x: pointerX - x1, y: pointerY - y1 };
// we do not want a frame and its elements to be selected at the same time
// but when it happens (due to some bug), we want to avoid updating element
// in the frame twice, hence the use of set
const elementsToUpdate = new Set<NonDeletedExcalidrawElement>(
selectedElements,
);
const frames = selectedElements
.filter((e) => isFrameElement(e))
.map((f) => f.id);
if (frames.length > 0) {
const elementsInFrames = scene
.getNonDeletedElements()
.filter((e) => e.frameId !== null)
.filter((e) => frames.includes(e.frameId!));
elementsInFrames.forEach((element) => elementsToUpdate.add(element));
}
elementsToUpdate.forEach((element) => {
selectedElements.forEach((element) => {
updateElementCoords(
lockDirection,
distanceX,
@@ -61,13 +38,7 @@ export const dragSelectedElements = (
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
) {
const textElement = getBoundTextElement(element);
if (
textElement &&
// when container is added to a frame, so will its bound text
// so the text is already in `elementsToUpdate` and we should avoid
// updating its coords again
(!textElement.frameId || !frames.includes(textElement.frameId))
) {
if (textElement) {
updateElementCoords(
lockDirection,
distanceX,
@@ -79,7 +50,7 @@ export const dragSelectedElements = (
}
}
updateBoundElements(element, {
simultaneouslyUpdated: Array.from(elementsToUpdate),
simultaneouslyUpdated: selectedElements,
});
});
};

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