Compare commits

...

12 Commits

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

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

* add size-limit.json

* try with pull request target

* fix

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

* fix

* update yml

* update path

* fix

* fix

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

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

* fix build command

* fix

* remove

* fix

* update script

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

* chore: fix lint

* ci: fix

* ci: fix workflow

* ci: fix workflow

* add size limit deps

* use node 18

---------

Co-authored-by: Nitin Kumar <nitin.kumar@razorpay.com>
2023-07-19 13:55:50 +05:30
David Luzar 9f76f8677b feat: cache most of element selection (#6747) 2023-07-17 01:09:44 +02:00
David Luzar 2e46e27490 fix: use actual dock state to not close docked library on insert (#6766) 2023-07-14 20:21:02 +02:00
David Luzar cf0413338e feat: support customizing what parts of frames are rendered (#6752) 2023-07-10 17:13:44 +02:00
David Luzar 49e4289878 feat: make appState.selectedElementIds more stable (#6745) 2023-07-08 23:33:34 +02:00
49 changed files with 2282 additions and 4169 deletions
+30
View File
@@ -0,0 +1,30 @@
name: "Bundle Size check @excalidraw/excalidraw"
on:
pull_request:
branches:
- master
jobs:
size:
runs-on: ubuntu-latest
env:
CI_JOB_NUMBER: 1
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Node.js 18.x
uses: actions/setup-node@v3
with:
node-version: 18.x
- name: Install
run: yarn --frozen-lockfile
- name: Install in src/packages/excalidraw
run: yarn --frozen-lockfile
working-directory: src/packages/excalidraw
env:
CI: true
- uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:umd
skip_step: install
directory: src/packages/excalidraw
@@ -165,3 +165,35 @@ function App() {
| Prop | Type | Required | Default | Description | | Prop | Type | Required | Default | Description |
| --- | --- | :-: | :-: | --- | | --- | --- | :-: | :-: | --- |
| `children ` | `React.ReactNode` | Yes | - | The content of the `Menu Group` | | `children ` | `React.ReactNode` | Yes | - | The content of the `Menu Group` |
### MainMenu.Sub
The MainMenu component now supports submenus. To render a submenu, you can use `MainMenu.Sub`, `MainMenu.Sub.Trigger`, `MainMenu.Sub.Content` and `MainMenu.Sub.Item`. Note that `MainMenu.Sub.Trigger` and `MainMenu.Sub.Content` must be direct children of `MainMenu.Sub`.
```jsx live
function App() {
return (
<div style={{ height: "500px" }}>
<Excalidraw>
<MainMenu>
<MainMenu.Sub>
<MainMenu.Sub.Trigger>Submenu</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content>
<MainMenu.Sub.Item
onSelect={() => window.alert("Submenu item 1")}
>
Submenu item 1
</MainMenu.Sub.Item>
<MainMenu.Sub.Item
onSelect={() => window.alert("Submenu item 2")}
>
Submenu item 2
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
</MainMenu>
</Excalidraw>
</div>
);
}
```
+5 -10
View File
@@ -1,6 +1,4 @@
import { register } from "./register"; import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import { deepCopyElement } from "../element/newElement"; import { deepCopyElement } from "../element/newElement";
import { randomId } from "../random"; import { randomId } from "../random";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -9,14 +7,11 @@ export const actionAddToLibrary = register({
name: "addToLibrary", name: "addToLibrary",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements( const selectedElements = app.scene.getSelectedElements({
getNonDeletedElements(elements), selectedElementIds: appState.selectedElementIds,
appState, includeBoundTextElement: true,
{ includeElementsInFrames: true,
includeBoundTextElement: true, });
includeElementsInFrames: true,
},
);
if (selectedElements.some((element) => element.type === "image")) { if (selectedElements.some((element) => element.type === "image")) {
return { return {
commitToHistory: false, commitToHistory: false,
+32 -34
View File
@@ -13,19 +13,18 @@ import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame"; import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { AppState } from "../types"; import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const alignActionsPredicate = ( const alignActionsPredicate = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
_: unknown,
app: AppClassProperties,
) => { ) => {
const selectedElements = getSelectedElements( const selectedElements = app.scene.getSelectedElements(appState);
getNonDeletedElements(elements),
appState,
);
return ( return (
selectedElements.length > 1 && selectedElements.length > 1 &&
// TODO enable aligning frames when implemented properly // TODO enable aligning frames when implemented properly
@@ -36,12 +35,10 @@ const alignActionsPredicate = (
const alignSelectedElements = ( const alignSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>, appState: Readonly<AppState>,
app: AppClassProperties,
alignment: Alignment, alignment: Alignment,
) => { ) => {
const selectedElements = getSelectedElements( const selectedElements = app.scene.getSelectedElements(appState);
getNonDeletedElements(elements),
appState,
);
const updatedElements = alignElements(selectedElements, alignment); const updatedElements = alignElements(selectedElements, alignment);
@@ -50,6 +47,7 @@ const alignSelectedElements = (
return updateFrameMembershipOfSelectedElements( return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element), elements.map((element) => updatedElementsMap.get(element.id) || element),
appState, appState,
app,
); );
}; };
@@ -57,10 +55,10 @@ export const actionAlignTop = register({
name: "alignTop", name: "alignTop",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
elements: alignSelectedElements(elements, appState, { elements: alignSelectedElements(elements, appState, app, {
position: "start", position: "start",
axis: "y", axis: "y",
}), }),
@@ -69,9 +67,9 @@ export const actionAlignTop = register({
}, },
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState)} hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button" type="button"
icon={AlignTopIcon} icon={AlignTopIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -88,10 +86,10 @@ export const actionAlignBottom = register({
name: "alignBottom", name: "alignBottom",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
elements: alignSelectedElements(elements, appState, { elements: alignSelectedElements(elements, appState, app, {
position: "end", position: "end",
axis: "y", axis: "y",
}), }),
@@ -100,9 +98,9 @@ export const actionAlignBottom = register({
}, },
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState)} hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button" type="button"
icon={AlignBottomIcon} icon={AlignBottomIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -119,10 +117,10 @@ export const actionAlignLeft = register({
name: "alignLeft", name: "alignLeft",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
elements: alignSelectedElements(elements, appState, { elements: alignSelectedElements(elements, appState, app, {
position: "start", position: "start",
axis: "x", axis: "x",
}), }),
@@ -131,9 +129,9 @@ export const actionAlignLeft = register({
}, },
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState)} hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button" type="button"
icon={AlignLeftIcon} icon={AlignLeftIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -150,10 +148,10 @@ export const actionAlignRight = register({
name: "alignRight", name: "alignRight",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
elements: alignSelectedElements(elements, appState, { elements: alignSelectedElements(elements, appState, app, {
position: "end", position: "end",
axis: "x", axis: "x",
}), }),
@@ -162,9 +160,9 @@ export const actionAlignRight = register({
}, },
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState)} hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button" type="button"
icon={AlignRightIcon} icon={AlignRightIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -181,19 +179,19 @@ export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered", name: "alignVerticallyCentered",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
elements: alignSelectedElements(elements, appState, { elements: alignSelectedElements(elements, appState, app, {
position: "center", position: "center",
axis: "y", axis: "y",
}), }),
commitToHistory: true, commitToHistory: true,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState)} hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button" type="button"
icon={CenterVerticallyIcon} icon={CenterVerticallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -208,19 +206,19 @@ export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered", name: "alignHorizontallyCentered",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
elements: alignSelectedElements(elements, appState, { elements: alignSelectedElements(elements, appState, app, {
position: "center", position: "center",
axis: "x", axis: "x",
}), }),
commitToHistory: true, commitToHistory: true,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState)} hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button" type="button"
icon={CenterHorizontallyIcon} icon={CenterHorizontallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
+15 -24
View File
@@ -4,7 +4,7 @@ import {
VERTICAL_ALIGN, VERTICAL_ALIGN,
TEXT_ALIGN, TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { getNonDeletedElements, isTextElement, newElement } from "../element"; import { isTextElement, newElement } from "../element";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { import {
computeBoundTextPosition, computeBoundTextPosition,
@@ -29,8 +29,8 @@ import {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
} from "../element/types"; } from "../element/types";
import { getSelectedElements } from "../scene";
import { AppState } from "../types"; import { AppState } from "../types";
import { Mutable } from "../utility-types";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
import { register } from "./register"; import { register } from "./register";
@@ -38,16 +38,13 @@ export const actionUnbindText = register({
name: "unbindText", name: "unbindText",
contextItemLabel: "labels.unbindText", contextItemLabel: "labels.unbindText",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState) => { predicate: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = app.scene.getSelectedElements(appState);
return selectedElements.some((element) => hasBoundTextElement(element)); return selectedElements.some((element) => hasBoundTextElement(element));
}, },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements( const selectedElements = app.scene.getSelectedElements(appState);
getNonDeletedElements(elements),
appState,
);
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
@@ -92,8 +89,8 @@ export const actionBindText = register({
name: "bindText", name: "bindText",
contextItemLabel: "labels.bindText", contextItemLabel: "labels.bindText",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState) => { predicate: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.length === 2) { if (selectedElements.length === 2) {
const textElement = const textElement =
@@ -116,11 +113,8 @@ export const actionBindText = register({
} }
return false; return false;
}, },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements( const selectedElements = app.scene.getSelectedElements(appState);
getNonDeletedElements(elements),
appState,
);
let textElement: ExcalidrawTextElement; let textElement: ExcalidrawTextElement;
let container: ExcalidrawTextContainer; let container: ExcalidrawTextContainer;
@@ -200,18 +194,15 @@ export const actionWrapTextInContainer = register({
name: "wrapTextInContainer", name: "wrapTextInContainer",
contextItemLabel: "labels.createContainerFromText", contextItemLabel: "labels.createContainerFromText",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState) => { predicate: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = app.scene.getSelectedElements(appState);
const areTextElements = selectedElements.every((el) => isTextElement(el)); const areTextElements = selectedElements.every((el) => isTextElement(el));
return selectedElements.length > 0 && areTextElements; return selectedElements.length > 0 && areTextElements;
}, },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements( const selectedElements = app.scene.getSelectedElements(appState);
getNonDeletedElements(elements),
appState,
);
let updatedElements: readonly ExcalidrawElement[] = elements.slice(); let updatedElements: readonly ExcalidrawElement[] = elements.slice();
const containerIds: AppState["selectedElementIds"] = {}; const containerIds: Mutable<AppState["selectedElementIds"]> = {};
for (const textElement of selectedElements) { for (const textElement of selectedElements) {
if (isTextElement(textElement)) { if (isTextElement(textElement)) {
+5 -11
View File
@@ -6,7 +6,7 @@ import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { getNormalizedZoom, getSelectedElements } from "../scene"; import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types"; import { AppState, NormalizedZoomValue } from "../types";
@@ -302,11 +302,8 @@ export const zoomToFit = ({
export const actionZoomToFitSelectionInViewport = register({ export const actionZoomToFitSelectionInViewport = register({
name: "zoomToFitSelectionInViewport", name: "zoomToFitSelectionInViewport",
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements( const selectedElements = app.scene.getSelectedElements(appState);
getNonDeletedElements(elements),
appState,
);
return zoomToFit({ return zoomToFit({
targetElements: selectedElements.length ? selectedElements : elements, targetElements: selectedElements.length ? selectedElements : elements,
appState, appState,
@@ -325,11 +322,8 @@ export const actionZoomToFitSelectionInViewport = register({
export const actionZoomToFitSelection = register({ export const actionZoomToFitSelection = register({
name: "zoomToFitSelection", name: "zoomToFitSelection",
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements( const selectedElements = app.scene.getSelectedElements(appState);
getNonDeletedElements(elements),
appState,
);
return zoomToFit({ return zoomToFit({
targetElements: selectedElements.length ? selectedElements : elements, targetElements: selectedElements.length ? selectedElements : elements,
appState, appState,
+24 -30
View File
@@ -7,7 +7,6 @@ import {
probablySupportsClipboardWriteText, probablySupportsClipboardWriteText,
} from "../clipboard"; } from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected"; import { actionDeleteSelected } from "./actionDeleteSelected";
import { getSelectedElements } from "../scene/selection";
import { exportCanvas } from "../data/index"; import { exportCanvas } from "../data/index";
import { getNonDeletedElements, isTextElement } from "../element"; import { getNonDeletedElements, isTextElement } from "../element";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -16,7 +15,8 @@ export const actionCopy = register({
name: "copy", name: "copy",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const elementsToCopy = getSelectedElements(elements, appState, { const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true, includeBoundTextElement: true,
includeElementsInFrames: true, includeElementsInFrames: true,
}); });
@@ -75,14 +75,11 @@ export const actionCopyAsSvg = register({
commitToHistory: false, commitToHistory: false,
}; };
} }
const selectedElements = getSelectedElements( const selectedElements = app.scene.getSelectedElements({
getNonDeletedElements(elements), selectedElementIds: appState.selectedElementIds,
appState, includeBoundTextElement: true,
{ includeElementsInFrames: true,
includeBoundTextElement: true, });
includeElementsInFrames: true,
},
);
try { try {
await exportCanvas( await exportCanvas(
"clipboard-svg", "clipboard-svg",
@@ -122,14 +119,11 @@ export const actionCopyAsPng = register({
commitToHistory: false, commitToHistory: false,
}; };
} }
const selectedElements = getSelectedElements( const selectedElements = app.scene.getSelectedElements({
getNonDeletedElements(elements), selectedElementIds: appState.selectedElementIds,
appState, includeBoundTextElement: true,
{ includeElementsInFrames: true,
includeBoundTextElement: true, });
includeElementsInFrames: true,
},
);
try { try {
await exportCanvas( await exportCanvas(
"clipboard", "clipboard",
@@ -177,14 +171,11 @@ export const actionCopyAsPng = register({
export const copyText = register({ export const copyText = register({
name: "copyText", name: "copyText",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements( const selectedElements = app.scene.getSelectedElements({
getNonDeletedElements(elements), selectedElementIds: appState.selectedElementIds,
appState, includeBoundTextElement: true,
{ });
includeBoundTextElement: true,
},
);
const text = selectedElements const text = selectedElements
.reduce((acc: string[], element) => { .reduce((acc: string[], element) => {
@@ -199,12 +190,15 @@ export const copyText = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
predicate: (elements, appState) => { predicate: (elements, appState, _, app) => {
return ( return (
probablySupportsClipboardWriteText && probablySupportsClipboardWriteText &&
getSelectedElements(elements, appState, { app.scene
includeBoundTextElement: true, .getSelectedElements({
}).some(isTextElement) selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})
.some(isTextElement)
); );
}, },
contextItemLabel: "labels.copyText", contextItemLabel: "labels.copyText",
+15 -22
View File
@@ -9,19 +9,13 @@ import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame"; import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { AppState } from "../types"; import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const enableActionGroup = ( const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
elements: readonly ExcalidrawElement[], const selectedElements = app.scene.getSelectedElements(appState);
appState: AppState,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return ( return (
selectedElements.length > 1 && selectedElements.length > 1 &&
// TODO enable distributing frames when implemented properly // TODO enable distributing frames when implemented properly
@@ -32,12 +26,10 @@ const enableActionGroup = (
const distributeSelectedElements = ( const distributeSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>, appState: Readonly<AppState>,
app: AppClassProperties,
distribution: Distribution, distribution: Distribution,
) => { ) => {
const selectedElements = getSelectedElements( const selectedElements = app.scene.getSelectedElements(appState);
getNonDeletedElements(elements),
appState,
);
const updatedElements = distributeElements(selectedElements, distribution); const updatedElements = distributeElements(selectedElements, distribution);
@@ -46,16 +38,17 @@ const distributeSelectedElements = (
return updateFrameMembershipOfSelectedElements( return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element), elements.map((element) => updatedElementsMap.get(element.id) || element),
appState, appState,
app,
); );
}; };
export const distributeHorizontally = register({ export const distributeHorizontally = register({
name: "distributeHorizontally", name: "distributeHorizontally",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
elements: distributeSelectedElements(elements, appState, { elements: distributeSelectedElements(elements, appState, app, {
space: "between", space: "between",
axis: "x", axis: "x",
}), }),
@@ -64,9 +57,9 @@ export const distributeHorizontally = register({
}, },
keyTest: (event) => keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H, !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(appState, app)}
type="button" type="button"
icon={DistributeHorizontallyIcon} icon={DistributeHorizontallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -82,10 +75,10 @@ export const distributeHorizontally = register({
export const distributeVertically = register({ export const distributeVertically = register({
name: "distributeVertically", name: "distributeVertically",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
elements: distributeSelectedElements(elements, appState, { elements: distributeSelectedElements(elements, appState, app, {
space: "between", space: "between",
axis: "y", axis: "y",
}), }),
@@ -94,9 +87,9 @@ export const distributeVertically = register({
}, },
keyTest: (event) => keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V, !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(appState, app)}
type="button" type="button"
icon={DistributeVerticallyIcon} icon={DistributeVerticallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
+2
View File
@@ -274,6 +274,8 @@ const duplicateElements = (
), ),
}, },
getNonDeletedElements(finalElements), getNonDeletedElements(finalElements),
appState,
null,
), ),
}; };
}; };
+11 -9
View File
@@ -1,7 +1,6 @@
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { register } from "./register"; import { register } from "./register";
@@ -11,14 +10,15 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
export const actionToggleElementLock = register({ export const actionToggleElementLock = register({
name: "toggleElementLock", name: "toggleElementLock",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState) => { predicate: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = app.scene.getSelectedElements(appState);
return !selectedElements.some( return !selectedElements.some(
(element) => element.locked && element.frameId, (element) => element.locked && element.frameId,
); );
}, },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState, { const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true, includeBoundTextElement: true,
includeElementsInFrames: true, includeElementsInFrames: true,
}); });
@@ -46,8 +46,9 @@ export const actionToggleElementLock = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
contextItemLabel: (elements, appState) => { contextItemLabel: (elements, appState, app) => {
const selected = getSelectedElements(elements, appState, { const selected = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false, includeBoundTextElement: false,
}); });
if (selected.length === 1 && selected[0].type !== "frame") { if (selected.length === 1 && selected[0].type !== "frame") {
@@ -60,12 +61,13 @@ export const actionToggleElementLock = register({
? "labels.elementLock.lockAll" ? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll"; : "labels.elementLock.unlockAll";
}, },
keyTest: (event, appState, elements) => { keyTest: (event, appState, elements, app) => {
return ( return (
event.key.toLocaleLowerCase() === KEYS.L && event.key.toLocaleLowerCase() === KEYS.L &&
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
event.shiftKey && event.shiftKey &&
getSelectedElements(elements, appState, { app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false, includeBoundTextElement: false,
}).length > 0 }).length > 0
); );
+5 -5
View File
@@ -65,7 +65,7 @@ export const actionChangeExportScale = register({
); );
const scaleButtonTitle = `${t( const scaleButtonTitle = `${t(
"buttons.scale", "imageExportDialog.label.scale",
)} ${s}x (${width}x${height})`; )} ${s}x (${width}x${height})`;
return ( return (
@@ -102,7 +102,7 @@ export const actionChangeExportBackground = register({
checked={appState.exportBackground} checked={appState.exportBackground}
onChange={(checked) => updateData(checked)} onChange={(checked) => updateData(checked)}
> >
{t("labels.withBackground")} {t("imageExportDialog.label.withBackground")}
</CheckboxItem> </CheckboxItem>
), ),
}); });
@@ -121,8 +121,8 @@ export const actionChangeExportEmbedScene = register({
checked={appState.exportEmbedScene} checked={appState.exportEmbedScene}
onChange={(checked) => updateData(checked)} onChange={(checked) => updateData(checked)}
> >
{t("labels.exportEmbedScene")} {t("imageExportDialog.label.embedScene")}
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}> <Tooltip label={t("imageExportDialog.tooltip.embedScene")} long={true}>
<div className="excalidraw-tooltip-icon">{questionCircle}</div> <div className="excalidraw-tooltip-icon">{questionCircle}</div>
</Tooltip> </Tooltip>
</CheckboxItem> </CheckboxItem>
@@ -277,7 +277,7 @@ export const actionExportWithDarkMode = register({
onChange={(theme: Theme) => { onChange={(theme: Theme) => {
updateData(theme === THEME.DARK); updateData(theme === THEME.DARK);
}} }}
title={t("labels.toggleExportColorScheme")} title={t("imageExportDialog.label.darkMode")}
/> />
</div> </div>
), ),
-7
View File
@@ -125,13 +125,6 @@ export const actionFinalize = register({
{ x, y }, { x, y },
); );
} }
if (
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
) {
appState.selectedElementIds[multiPointElement.id] = true;
}
} }
if ( if (
+4 -2
View File
@@ -17,11 +17,12 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
return { return {
elements: updateFrameMembershipOfSelectedElements( elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "horizontal"), flipSelectedElements(elements, appState, "horizontal"),
appState, appState,
app,
), ),
appState, appState,
commitToHistory: true, commitToHistory: true,
@@ -34,11 +35,12 @@ export const actionFlipHorizontal = register({
export const actionFlipVertical = register({ export const actionFlipVertical = register({
name: "flipVertical", name: "flipVertical",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
return { return {
elements: updateFrameMembershipOfSelectedElements( elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "vertical"), flipSelectedElements(elements, appState, "vertical"),
appState, appState,
app,
), ),
appState, appState,
commitToHistory: true, commitToHistory: true,
+19 -27
View File
@@ -3,19 +3,12 @@ import { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame"; import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame"; import { getFrameElements } from "../frame";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getSelectedElements } from "../scene"; import { AppClassProperties, AppState } from "../types";
import { AppState } from "../types";
import { setCursorForShape, updateActiveTool } from "../utils"; import { setCursorForShape, updateActiveTool } from "../utils";
import { register } from "./register"; import { register } from "./register";
const isSingleFrameSelected = ( const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
elements: readonly ExcalidrawElement[], const selectedElements = app.scene.getSelectedElements(appState);
appState: AppState,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return selectedElements.length === 1 && selectedElements[0].type === "frame"; return selectedElements.length === 1 && selectedElements[0].type === "frame";
}; };
@@ -23,11 +16,8 @@ const isSingleFrameSelected = (
export const actionSelectAllElementsInFrame = register({ export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame", name: "selectAllElementsInFrame",
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
const selectedFrame = getSelectedElements( const selectedFrame = app.scene.getSelectedElements(appState)[0];
getNonDeletedElements(elements),
appState,
)[0];
if (selectedFrame && selectedFrame.type === "frame") { if (selectedFrame && selectedFrame.type === "frame") {
const elementsInFrame = getFrameElements( const elementsInFrame = getFrameElements(
@@ -55,17 +45,15 @@ export const actionSelectAllElementsInFrame = register({
}; };
}, },
contextItemLabel: "labels.selectAllElementsInFrame", contextItemLabel: "labels.selectAllElementsInFrame",
predicate: (elements, appState) => isSingleFrameSelected(elements, appState), predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
}); });
export const actionRemoveAllElementsFromFrame = register({ export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame", name: "removeAllElementsFromFrame",
trackEvent: { category: "history" }, trackEvent: { category: "history" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
const selectedFrame = getSelectedElements( const selectedFrame = app.scene.getSelectedElements(appState)[0];
getNonDeletedElements(elements),
appState,
)[0];
if (selectedFrame && selectedFrame.type === "frame") { if (selectedFrame && selectedFrame.type === "frame") {
return { return {
@@ -87,11 +75,12 @@ export const actionRemoveAllElementsFromFrame = register({
}; };
}, },
contextItemLabel: "labels.removeAllElementsFromFrame", contextItemLabel: "labels.removeAllElementsFromFrame",
predicate: (elements, appState) => isSingleFrameSelected(elements, appState), predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
}); });
export const actionToggleFrameRendering = register({ export const actionupdateFrameRendering = register({
name: "toggleFrameRendering", name: "updateFrameRendering",
viewMode: true, viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState) => { perform: (elements, appState) => {
@@ -99,13 +88,16 @@ export const actionToggleFrameRendering = register({
elements, elements,
appState: { appState: {
...appState, ...appState,
shouldRenderFrames: !appState.shouldRenderFrames, frameRendering: {
...appState.frameRendering,
enabled: !appState.frameRendering.enabled,
},
}, },
commitToHistory: false, commitToHistory: false,
}; };
}, },
contextItemLabel: "labels.toggleFrameRendering", contextItemLabel: "labels.updateFrameRendering",
checked: (appState: AppState) => appState.shouldRenderFrames, checked: (appState: AppState) => appState.frameRendering.enabled,
}); });
export const actionSetFrameAsActiveTool = register({ export const actionSetFrameAsActiveTool = register({
+29 -22
View File
@@ -4,7 +4,7 @@ import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { UngroupIcon, GroupIcon } from "../components/icons"; import { UngroupIcon, GroupIcon } from "../components/icons";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { import {
getSelectedGroupIds, getSelectedGroupIds,
selectGroup, selectGroup,
@@ -22,7 +22,7 @@ import {
ExcalidrawFrameElement, ExcalidrawFrameElement,
ExcalidrawTextElement, ExcalidrawTextElement,
} from "../element/types"; } from "../element/types";
import { AppState } from "../types"; import { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import { import {
getElementsInResizingFrame, getElementsInResizingFrame,
@@ -51,14 +51,12 @@ const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
const enableActionGroup = ( const enableActionGroup = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
app: AppClassProperties,
) => { ) => {
const selectedElements = getSelectedElements( const selectedElements = app.scene.getSelectedElements({
getNonDeletedElements(elements), selectedElementIds: appState.selectedElementIds,
appState, includeBoundTextElement: true,
{ });
includeBoundTextElement: true,
},
);
return ( return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements) selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
); );
@@ -68,13 +66,10 @@ export const actionGroup = register({
name: "group", name: "group",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements( const selectedElements = app.scene.getSelectedElements({
getNonDeletedElements(elements), selectedElementIds: appState.selectedElementIds,
appState, includeBoundTextElement: true,
{ });
includeBoundTextElement: true,
},
);
if (selectedElements.length < 2) { if (selectedElements.length < 2) {
// nothing to group // nothing to group
return { appState, elements, commitToHistory: false }; return { appState, elements, commitToHistory: false };
@@ -164,12 +159,13 @@ export const actionGroup = register({
}; };
}, },
contextItemLabel: "labels.group", contextItemLabel: "labels.group",
predicate: (elements, appState) => enableActionGroup(elements, appState), predicate: (elements, appState, _, app) =>
enableActionGroup(elements, appState, app),
keyTest: (event) => keyTest: (event) =>
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G, !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!enableActionGroup(elements, appState, app)}
type="button" type="button"
icon={<GroupIcon theme={appState.theme} />} icon={<GroupIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -191,7 +187,7 @@ export const actionUngroup = register({
let nextElements = [...elements]; let nextElements = [...elements];
const selectedElements = getSelectedElements(nextElements, appState); const selectedElements = app.scene.getSelectedElements(appState);
const frames = selectedElements const frames = selectedElements
.filter((element) => element.frameId) .filter((element) => element.frameId)
.map((element) => .map((element) =>
@@ -218,6 +214,8 @@ export const actionUngroup = register({
const updateAppState = selectGroupsForSelectedElements( const updateAppState = selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} }, { ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements), getNonDeletedElements(nextElements),
appState,
null,
); );
frames.forEach((frame) => { frames.forEach((frame) => {
@@ -232,9 +230,18 @@ export const actionUngroup = register({
}); });
// remove binded text elements from selection // remove binded text elements from selection
boundTextElementIds.forEach( updateAppState.selectedElementIds = Object.entries(
(id) => (updateAppState.selectedElementIds[id] = false), updateAppState.selectedElementIds,
).reduce(
(acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => {
if (selected && !boundTextElementIds.includes(id)) {
acc[id] = true;
}
return acc;
},
{},
); );
return { return {
appState: updateAppState, appState: updateAppState,
elements: nextElements, elements: nextElements,
+11 -19
View File
@@ -1,8 +1,6 @@
import { getNonDeletedElements } from "../element";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { ExcalidrawLinearElement } from "../element/types"; import { ExcalidrawLinearElement } from "../element/types";
import { getSelectedElements } from "../scene";
import { register } from "./register"; import { register } from "./register";
export const actionToggleLinearEditor = register({ export const actionToggleLinearEditor = register({
@@ -10,21 +8,18 @@ export const actionToggleLinearEditor = register({
trackEvent: { trackEvent: {
category: "element", category: "element",
}, },
predicate: (elements, appState) => { predicate: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) { if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
return true; return true;
} }
return false; return false;
}, },
perform(elements, appState, _, app) { perform(elements, appState, _, app) {
const selectedElement = getSelectedElements( const selectedElement = app.scene.getSelectedElements({
getNonDeletedElements(elements), selectedElementIds: appState.selectedElementIds,
appState, includeBoundTextElement: true,
{ })[0] as ExcalidrawLinearElement;
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement;
const editingLinearElement = const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id appState.editingLinearElement?.elementId === selectedElement.id
@@ -38,14 +33,11 @@ export const actionToggleLinearEditor = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
contextItemLabel: (elements, appState) => { contextItemLabel: (elements, appState, app) => {
const selectedElement = getSelectedElements( const selectedElement = app.scene.getSelectedElements({
getNonDeletedElements(elements), selectedElementIds: appState.selectedElementIds,
appState, includeBoundTextElement: true,
{ })[0] as ExcalidrawLinearElement;
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement;
return appState.editingLinearElement?.elementId === selectedElement.id return appState.editingLinearElement?.elementId === selectedElement.id
? "labels.lineEditor.exit" ? "labels.lineEditor.exit"
: "labels.lineEditor.edit"; : "labels.lineEditor.edit";
+2
View File
@@ -41,6 +41,8 @@ export const actionSelectAll = register({
selectedElementIds, selectedElementIds,
}, },
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState,
app,
), ),
commitToHistory: true, commitToHistory: true,
}; };
+2
View File
@@ -90,6 +90,7 @@ export class ActionManager {
event, event,
this.getAppState(), this.getAppState(),
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
this.app,
), ),
); );
@@ -168,6 +169,7 @@ export class ActionManager {
appState={this.getAppState()} appState={this.getAppState()}
updateData={updateData} updateData={updateData}
appProps={this.app.props} appProps={this.app.props}
app={this.app}
data={data} data={data}
/> />
); );
+4 -1
View File
@@ -119,7 +119,7 @@ export type ActionName =
| "toggleHandTool" | "toggleHandTool"
| "selectAllElementsInFrame" | "selectAllElementsInFrame"
| "removeAllElementsFromFrame" | "removeAllElementsFromFrame"
| "toggleFrameRendering" | "updateFrameRendering"
| "setFrameAsActiveTool" | "setFrameAsActiveTool"
| "createContainerFromText" | "createContainerFromText"
| "wrapTextInContainer"; | "wrapTextInContainer";
@@ -130,6 +130,7 @@ export type PanelComponentProps = {
updateData: (formData?: any) => void; updateData: (formData?: any) => void;
appProps: ExcalidrawProps; appProps: ExcalidrawProps;
data?: Record<string, any>; data?: Record<string, any>;
app: AppClassProperties;
}; };
export interface Action { export interface Action {
@@ -141,12 +142,14 @@ export interface Action {
event: React.KeyboardEvent | KeyboardEvent, event: React.KeyboardEvent | KeyboardEvent,
appState: AppState, appState: AppState,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
app: AppClassProperties,
) => boolean; ) => boolean;
contextItemLabel?: contextItemLabel?:
| string | string
| (( | ((
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>, appState: Readonly<AppState>,
app: AppClassProperties,
) => string); ) => string);
predicate?: ( predicate?: (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
+2 -2
View File
@@ -84,7 +84,7 @@ export const getDefaultAppState = (): Omit<
showStats: false, showStats: false,
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
shouldRenderFrames: true, frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null, frameToHighlight: null,
editingFrame: null, editingFrame: null,
elementsToHighlight: null, elementsToHighlight: null,
@@ -191,7 +191,7 @@ const APP_STATE_STORAGE_CONF = (<
showStats: { browser: true, export: false, server: false }, showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false }, suggestedBindings: { browser: false, export: false, server: false },
shouldRenderFrames: { browser: false, export: false, server: false }, frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false }, frameToHighlight: { browser: false, export: false, server: false },
editingFrame: { browser: false, export: false, server: false }, editingFrame: { browser: false, export: false, server: false },
elementsToHighlight: { browser: false, export: false, server: false }, elementsToHighlight: { browser: false, export: false, server: false },
+277 -218
View File
@@ -315,7 +315,10 @@ import {
updateFrameMembershipOfSelectedElements, updateFrameMembershipOfSelectedElements,
isElementInFrame, isElementInFrame,
} from "../frame"; } from "../frame";
import { excludeElementsInFramesFromSelection } from "../scene/selection"; import {
excludeElementsInFramesFromSelection,
makeNextSelectedElementIds,
} from "../scene/selection";
import { actionPaste } from "../actions/actionClipboard"; import { actionPaste } from "../actions/actionClipboard";
import { import {
actionRemoveAllElementsFromFrame, actionRemoveAllElementsFromFrame,
@@ -327,6 +330,7 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionWrapTextInContainer } from "../actions/actionBoundText"; import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError"; import BraveMeasureTextError from "./BraveMeasureTextError";
import { activeEyeDropperAtom } from "./EyeDropper"; import { activeEyeDropperAtom } from "./EyeDropper";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
const AppContext = React.createContext<AppClassProperties>(null!); const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@@ -470,8 +474,6 @@ class App extends React.Component<AppProps, AppState> {
name, name,
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight, height: window.innerHeight,
showHyperlinkPopup: false,
defaultSidebarDockedPreference: false,
}; };
this.id = nanoid(); this.id = nanoid();
@@ -502,7 +504,7 @@ class App extends React.Component<AppProps, AppState> {
setActiveTool: this.setActiveTool, setActiveTool: this.setActiveTool,
setCursor: this.setCursor, setCursor: this.setCursor,
resetCursor: this.resetCursor, resetCursor: this.resetCursor,
toggleFrameRendering: this.toggleFrameRendering, updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar, toggleSidebar: this.toggleSidebar,
} as const; } as const;
if (typeof excalidrawRef === "function") { if (typeof excalidrawRef === "function") {
@@ -648,7 +650,7 @@ class App extends React.Component<AppProps, AppState> {
}; };
private renderFrameNames = () => { private renderFrameNames = () => {
if (!this.state.shouldRenderFrames) { if (!this.state.frameRendering.enabled || !this.state.frameRendering.name) {
return null; return null;
} }
@@ -796,10 +798,7 @@ class App extends React.Component<AppProps, AppState> {
}; };
public render() { public render() {
const selectedElement = getSelectedElements( const selectedElement = this.scene.getSelectedElements(this.state);
this.scene.getNonDeletedElements(),
this.state,
);
const { renderTopRightUI, renderCustomStats } = this.props; const { renderTopRightUI, renderCustomStats } = this.props;
return ( return (
@@ -856,6 +855,7 @@ class App extends React.Component<AppProps, AppState> {
!this.state.zenModeEnabled && !this.state.zenModeEnabled &&
!this.scene.getElementsIncludingDeleted().length !this.scene.getElementsIncludingDeleted().length
} }
app={this}
> >
{this.props.children} {this.props.children}
</LayerUI> </LayerUI>
@@ -961,10 +961,7 @@ class App extends React.Component<AppProps, AppState> {
const shouldUpdateStrokeColor = const shouldUpdateStrokeColor =
(type === "background" && event.altKey) || (type === "background" && event.altKey) ||
(type === "stroke" && !event.altKey); (type === "stroke" && !event.altKey);
const selectedElements = getSelectedElements( const selectedElements = this.scene.getSelectedElements(this.state);
this.scene.getElementsIncludingDeleted(),
this.state,
);
if ( if (
!selectedElements.length || !selectedElements.length ||
this.state.activeTool.type !== "selection" this.state.activeTool.type !== "selection"
@@ -1353,6 +1350,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.destroy(); this.scene.destroy();
this.library.destroy(); this.library.destroy();
clearTimeout(touchTimeout); clearTimeout(touchTimeout);
isSomeElementSelected.clearCache();
touchTimeout = 0; touchTimeout = 0;
} }
@@ -1825,7 +1823,7 @@ class App extends React.Component<AppProps, AppState> {
if (event.touches.length === 2) { if (event.touches.length === 2) {
this.setState({ this.setState({
selectedElementIds: {}, selectedElementIds: makeNextSelectedElementIds({}, this.state),
}); });
} }
}; };
@@ -1835,7 +1833,10 @@ class App extends React.Component<AppProps, AppState> {
if (event.touches.length > 0) { if (event.touches.length > 0) {
this.setState({ this.setState({
previousSelectedElementIds: {}, previousSelectedElementIds: {},
selectedElementIds: this.state.previousSelectedElementIds, selectedElementIds: makeNextSelectedElementIds(
this.state.previousSelectedElementIds,
this.state,
),
}); });
} else { } else {
gesture.pointers.clear(); gesture.pointers.clear();
@@ -1895,7 +1896,14 @@ class App extends React.Component<AppProps, AppState> {
const imageElement = this.createImageElement({ sceneX, sceneY }); const imageElement = this.createImageElement({ sceneX, sceneY });
this.insertImageElement(imageElement, file); this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement); this.initializeImageDimensions(imageElement);
this.setState({ selectedElementIds: { [imageElement.id]: true } }); this.setState({
selectedElementIds: makeNextSelectedElementIds(
{
[imageElement.id]: true,
},
this.state,
),
});
return; return;
} }
@@ -2017,7 +2025,7 @@ class App extends React.Component<AppProps, AppState> {
openSidebar: openSidebar:
this.state.openSidebar && this.state.openSidebar &&
this.device.canDeviceFitSidebar && this.device.canDeviceFitSidebar &&
this.state.defaultSidebarDockedPreference jotaiStore.get(isSidebarDockedAtom)
? this.state.openSidebar ? this.state.openSidebar
: null, : null,
selectedElementIds: nextElementsToSelect.reduce( selectedElementIds: nextElementsToSelect.reduce(
@@ -2032,6 +2040,8 @@ class App extends React.Component<AppProps, AppState> {
selectedGroupIds: {}, selectedGroupIds: {},
}, },
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.state,
this,
), ),
() => { () => {
if (opts.files) { if (opts.files) {
@@ -2130,8 +2140,9 @@ class App extends React.Component<AppProps, AppState> {
} }
this.setState({ this.setState({
selectedElementIds: Object.fromEntries( selectedElementIds: makeNextSelectedElementIds(
textElements.map((el) => [el.id, true]), Object.fromEntries(textElements.map((el) => [el.id, true])),
this.state,
), ),
}); });
@@ -2192,10 +2203,23 @@ class App extends React.Component<AppProps, AppState> {
}); });
}; };
toggleFrameRendering = () => { updateFrameRendering = (
opts:
| Partial<AppState["frameRendering"]>
| ((
prevState: AppState["frameRendering"],
) => Partial<AppState["frameRendering"]>),
) => {
this.setState((prevState) => { this.setState((prevState) => {
const next =
typeof opts === "function" ? opts(prevState.frameRendering) : opts;
return { return {
shouldRenderFrames: !prevState.shouldRenderFrames, frameRendering: {
enabled: next?.enabled ?? prevState.frameRendering.enabled,
clip: next?.clip ?? prevState.frameRendering.clip,
name: next?.name ?? prevState.frameRendering.name,
outline: next?.outline ?? prevState.frameRendering.outline,
},
}; };
}); });
}; };
@@ -2582,14 +2606,11 @@ class App extends React.Component<AppProps, AppState> {
offsetY = step; offsetY = step;
} }
const selectedElements = getSelectedElements( const selectedElements = this.scene.getSelectedElements({
this.scene.getNonDeletedElements(), selectedElementIds: this.state.selectedElementIds,
this.state, includeBoundTextElement: true,
{ includeElementsInFrames: true,
includeBoundTextElement: true, });
includeElementsInFrames: true,
},
);
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
mutateElement(element, { mutateElement(element, {
@@ -2606,10 +2627,7 @@ class App extends React.Component<AppProps, AppState> {
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.ENTER) { } else if (event.key === KEYS.ENTER) {
const selectedElements = getSelectedElements( const selectedElements = this.scene.getSelectedElements(this.state);
this.scene.getNonDeletedElements(),
this.state,
);
if (selectedElements.length === 1) { if (selectedElements.length === 1) {
const selectedElement = selectedElements[0]; const selectedElement = selectedElements[0];
if (event[KEYS.CTRL_OR_CMD]) { if (event[KEYS.CTRL_OR_CMD]) {
@@ -2685,10 +2703,7 @@ class App extends React.Component<AppProps, AppState> {
!event.altKey && !event.altKey &&
!event[KEYS.CTRL_OR_CMD] !event[KEYS.CTRL_OR_CMD]
) { ) {
const selectedElements = getSelectedElements( const selectedElements = this.scene.getSelectedElements(this.state);
this.scene.getNonDeletedElements(),
this.state,
);
if ( if (
this.state.activeTool.type === "selection" && this.state.activeTool.type === "selection" &&
!selectedElements.length !selectedElements.length
@@ -2749,7 +2764,7 @@ class App extends React.Component<AppProps, AppState> {
} else { } else {
setCursorForShape(this.canvas, this.state); setCursorForShape(this.canvas, this.state);
this.setState({ this.setState({
selectedElementIds: {}, selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {}, selectedGroupIds: {},
editingGroupId: null, editingGroupId: null,
}); });
@@ -2760,10 +2775,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ isBindingEnabled: true }); this.setState({ isBindingEnabled: true });
} }
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
const selectedElements = getSelectedElements( const selectedElements = this.scene.getSelectedElements(this.state);
this.scene.getNonDeletedElements(),
this.state,
);
isBindingEnabled(this.state) isBindingEnabled(this.state)
? bindOrUnbindSelectedElements(selectedElements) ? bindOrUnbindSelectedElements(selectedElements)
: unbindLinearElements(selectedElements); : unbindLinearElements(selectedElements);
@@ -2794,7 +2806,7 @@ class App extends React.Component<AppProps, AppState> {
if (nextActiveTool.type !== "selection") { if (nextActiveTool.type !== "selection") {
this.setState({ this.setState({
activeTool: nextActiveTool, activeTool: nextActiveTool,
selectedElementIds: {}, selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {}, selectedGroupIds: {},
editingGroupId: null, editingGroupId: null,
}); });
@@ -2831,7 +2843,7 @@ class App extends React.Component<AppProps, AppState> {
// elements by mistake while zooming // elements by mistake while zooming
if (this.isTouchScreenMultiTouchGesture()) { if (this.isTouchScreenMultiTouchGesture()) {
this.setState({ this.setState({
selectedElementIds: {}, selectedElementIds: makeNextSelectedElementIds({}, this.state),
}); });
} }
gesture.initialScale = this.state.zoom.value; gesture.initialScale = this.state.zoom.value;
@@ -2876,7 +2888,10 @@ class App extends React.Component<AppProps, AppState> {
if (this.isTouchScreenMultiTouchGesture()) { if (this.isTouchScreenMultiTouchGesture()) {
this.setState({ this.setState({
previousSelectedElementIds: {}, previousSelectedElementIds: {},
selectedElementIds: this.state.previousSelectedElementIds, selectedElementIds: makeNextSelectedElementIds(
this.state.previousSelectedElementIds,
this.state,
),
}); });
} }
gesture.initialScale = null; gesture.initialScale = null;
@@ -2941,10 +2956,13 @@ class App extends React.Component<AppProps, AppState> {
? element.containerId ? element.containerId
: element.id; : element.id;
this.setState((prevState) => ({ this.setState((prevState) => ({
selectedElementIds: { selectedElementIds: makeNextSelectedElementIds(
...prevState.selectedElementIds, {
[elementIdToSelect]: true, ...prevState.selectedElementIds,
}, [elementIdToSelect]: true,
},
prevState,
),
})); }));
} }
if (isDeleted) { if (isDeleted) {
@@ -2980,7 +2998,7 @@ class App extends React.Component<AppProps, AppState> {
private deselectElements() { private deselectElements() {
this.setState({ this.setState({
selectedElementIds: {}, selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {}, selectedGroupIds: {},
editingGroupId: null, editingGroupId: null,
}); });
@@ -3067,7 +3085,9 @@ class App extends React.Component<AppProps, AppState> {
).filter((element) => { ).filter((element) => {
// hitting a frame's element from outside the frame is not considered a hit // hitting a frame's element from outside the frame is not considered a hit
const containingFrame = getContainingFrame(element); const containingFrame = getContainingFrame(element);
return containingFrame && this.state.shouldRenderFrames return containingFrame &&
this.state.frameRendering.enabled &&
this.state.frameRendering.clip
? isCursorInFrame({ x, y }, containingFrame) ? isCursorInFrame({ x, y }, containingFrame)
: true; : true;
}); });
@@ -3105,10 +3125,7 @@ class App extends React.Component<AppProps, AppState> {
} }
let existingTextElement: NonDeleted<ExcalidrawTextElement> | null = null; let existingTextElement: NonDeleted<ExcalidrawTextElement> | null = null;
const selectedElements = getSelectedElements( const selectedElements = this.scene.getSelectedElements(this.state);
this.scene.getNonDeletedElements(),
this.state,
);
if (selectedElements.length === 1) { if (selectedElements.length === 1) {
if (isTextElement(selectedElements[0])) { if (isTextElement(selectedElements[0])) {
@@ -3238,10 +3255,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const selectedElements = getSelectedElements( const selectedElements = this.scene.getSelectedElements(this.state);
this.scene.getNonDeletedElements(),
this.state,
);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) { if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if ( if (
@@ -3291,6 +3305,8 @@ class App extends React.Component<AppProps, AppState> {
selectedGroupIds: {}, selectedGroupIds: {},
}, },
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
prevState,
this,
), ),
); );
return; return;
@@ -3667,7 +3683,7 @@ class App extends React.Component<AppProps, AppState> {
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
const selectedElements = getSelectedElements(elements, this.state); const selectedElements = this.scene.getSelectedElements(this.state);
if ( if (
selectedElements.length === 1 && selectedElements.length === 1 &&
!isOverScrollBar && !isOverScrollBar &&
@@ -3998,12 +4014,15 @@ class App extends React.Component<AppProps, AppState> {
editingElement: null, editingElement: null,
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
selectedElementIds: Object.keys(this.state.selectedElementIds) selectedElementIds: makeNextSelectedElementIds(
.filter((key) => key !== element.id) Object.keys(this.state.selectedElementIds)
.reduce((obj: { [id: string]: boolean }, key) => { .filter((key) => key !== element.id)
obj[key] = this.state.selectedElementIds[key]; .reduce((obj: { [id: string]: true }, key) => {
return obj; obj[key] = this.state.selectedElementIds[key];
}, {}), return obj;
}, {}),
this.state,
),
}, },
}); });
return; return;
@@ -4367,10 +4386,7 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
): PointerDownState { ): PointerDownState {
const origin = viewportCoordsToSceneCoords(event, this.state); const origin = viewportCoordsToSceneCoords(event, this.state);
const selectedElements = getSelectedElements( const selectedElements = this.scene.getSelectedElements(this.state);
this.scene.getNonDeletedElements(),
this.state,
);
const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements); const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
return { return {
@@ -4472,7 +4488,7 @@ class App extends React.Component<AppProps, AppState> {
private clearSelectionIfNotUsingSelection = (): void => { private clearSelectionIfNotUsingSelection = (): void => {
if (this.state.activeTool.type !== "selection") { if (this.state.activeTool.type !== "selection") {
this.setState({ this.setState({
selectedElementIds: {}, selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {}, selectedGroupIds: {},
editingGroupId: null, editingGroupId: null,
}); });
@@ -4488,7 +4504,7 @@ class App extends React.Component<AppProps, AppState> {
): boolean => { ): boolean => {
if (this.state.activeTool.type === "selection") { if (this.state.activeTool.type === "selection") {
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
const selectedElements = getSelectedElements(elements, this.state); const selectedElements = this.scene.getSelectedElements(this.state);
if (selectedElements.length === 1 && !this.state.editingLinearElement) { if (selectedElements.length === 1 && !this.state.editingLinearElement) {
const elementWithTransformHandleType = const elementWithTransformHandleType =
getElementWithTransformHandleType( getElementWithTransformHandleType(
@@ -4604,9 +4620,12 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.editingLinearElement) { if (this.state.editingLinearElement) {
this.setState({ this.setState({
selectedElementIds: { selectedElementIds: makeNextSelectedElementIds(
[this.state.editingLinearElement.elementId]: true, {
}, [this.state.editingLinearElement.elementId]: true,
},
this.state,
),
}); });
// If we click on something // If we click on something
} else if (hitElement != null) { } else if (hitElement != null) {
@@ -4634,7 +4653,7 @@ class App extends React.Component<AppProps, AppState> {
!isElementInGroup(hitElement, this.state.editingGroupId) !isElementInGroup(hitElement, this.state.editingGroupId)
) { ) {
this.setState({ this.setState({
selectedElementIds: {}, selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {}, selectedGroupIds: {},
editingGroupId: null, editingGroupId: null,
}); });
@@ -4650,7 +4669,7 @@ class App extends React.Component<AppProps, AppState> {
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
) { ) {
this.setState((prevState) => { this.setState((prevState) => {
const nextSelectedElementIds = { const nextSelectedElementIds: { [id: string]: true } = {
...prevState.selectedElementIds, ...prevState.selectedElementIds,
[hitElement.id]: true, [hitElement.id]: true,
}; };
@@ -4668,13 +4687,13 @@ class App extends React.Component<AppProps, AppState> {
previouslySelectedElements, previouslySelectedElements,
hitElement.id, hitElement.id,
).forEach((element) => { ).forEach((element) => {
nextSelectedElementIds[element.id] = false; delete nextSelectedElementIds[element.id];
}); });
} else if (hitElement.frameId) { } else if (hitElement.frameId) {
// if hitElement is in a frame and its frame has been selected // if hitElement is in a frame and its frame has been selected
// disable selection for the given element // disable selection for the given element
if (nextSelectedElementIds[hitElement.frameId]) { if (nextSelectedElementIds[hitElement.frameId]) {
nextSelectedElementIds[hitElement.id] = false; delete nextSelectedElementIds[hitElement.id];
} }
} else { } else {
// hitElement is neither a frame nor an element in a frame // hitElement is neither a frame nor an element in a frame
@@ -4704,7 +4723,7 @@ class App extends React.Component<AppProps, AppState> {
framesInGroups.has(element.frameId) framesInGroups.has(element.frameId)
) { ) {
// deselect element and groups containing the element // deselect element and groups containing the element
nextSelectedElementIds[element.id] = false; delete nextSelectedElementIds[element.id];
element.groupIds element.groupIds
.flatMap((gid) => .flatMap((gid) =>
getElementsInGroup( getElementsInGroup(
@@ -4712,10 +4731,9 @@ class App extends React.Component<AppProps, AppState> {
gid, gid,
), ),
) )
.forEach( .forEach((element) => {
(element) => delete nextSelectedElementIds[element.id];
(nextSelectedElementIds[element.id] = false), });
);
} }
}); });
} }
@@ -4728,6 +4746,8 @@ class App extends React.Component<AppProps, AppState> {
showHyperlinkPopup: hitElement.link ? "info" : false, showHyperlinkPopup: hitElement.link ? "info" : false,
}, },
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
prevState,
this,
); );
}); });
pointerDownState.hit.wasAddedToSelection = true; pointerDownState.hit.wasAddedToSelection = true;
@@ -4844,12 +4864,18 @@ class App extends React.Component<AppProps, AppState> {
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
this.setState((prevState) => ({ this.setState((prevState) => {
selectedElementIds: { const nextSelectedElementIds = {
...prevState.selectedElementIds, ...prevState.selectedElementIds,
[element.id]: false, };
}, delete nextSelectedElementIds[element.id];
})); return {
selectedElementIds: makeNextSelectedElementIds(
nextSelectedElementIds,
prevState,
),
};
});
const pressures = element.simulatePressure const pressures = element.simulatePressure
? element.pressures ? element.pressures
@@ -4945,10 +4971,13 @@ class App extends React.Component<AppProps, AppState> {
} }
this.setState((prevState) => ({ this.setState((prevState) => ({
selectedElementIds: { selectedElementIds: makeNextSelectedElementIds(
...prevState.selectedElementIds, {
[multiElement.id]: true, ...prevState.selectedElementIds,
}, [multiElement.id]: true,
},
prevState,
),
})); }));
// clicking outside commit zone → update reference for last committed // clicking outside commit zone → update reference for last committed
// point // point
@@ -4999,12 +5028,18 @@ class App extends React.Component<AppProps, AppState> {
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
this.setState((prevState) => ({ this.setState((prevState) => {
selectedElementIds: { const nextSelectedElementIds = {
...prevState.selectedElementIds, ...prevState.selectedElementIds,
[element.id]: false, };
}, delete nextSelectedElementIds[element.id];
})); return {
selectedElementIds: makeNextSelectedElementIds(
nextSelectedElementIds,
prevState,
),
};
});
mutateElement(element, { mutateElement(element, {
points: [...element.points, [0, 0]], points: [...element.points, [0, 0]],
}); });
@@ -5140,7 +5175,7 @@ class App extends React.Component<AppProps, AppState> {
if (pointerDownState.drag.offset === null) { if (pointerDownState.drag.offset === null) {
pointerDownState.drag.offset = tupleToCoors( pointerDownState.drag.offset = tupleToCoors(
getDragOffsetXY( getDragOffsetXY(
getSelectedElements(this.scene.getNonDeletedElements(), this.state), this.scene.getSelectedElements(this.state),
pointerDownState.origin.x, pointerDownState.origin.x,
pointerDownState.origin.y, pointerDownState.origin.y,
), ),
@@ -5303,10 +5338,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) && pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
!isSelectingPointsInLineEditor !isSelectingPointsInLineEditor
) { ) {
const selectedElements = getSelectedElements( const selectedElements = this.scene.getSelectedElements(this.state);
this.scene.getNonDeletedElements(),
this.state,
);
if (selectedElements.every((element) => element.locked)) { if (selectedElements.every((element) => element.locked)) {
return; return;
@@ -5377,16 +5409,21 @@ class App extends React.Component<AppProps, AppState> {
const groupIdMap = new Map(); const groupIdMap = new Map();
const oldIdToDuplicatedId = new Map(); const oldIdToDuplicatedId = new Map();
const hitElement = pointerDownState.hit.element; const hitElement = pointerDownState.hit.element;
const elements = this.scene.getElementsIncludingDeleted(); const selectedElementIds = new Set(
const selectedElementIds: Array<ExcalidrawElement["id"]> = this.scene
getSelectedElements(elements, this.state, { .getSelectedElements({
includeBoundTextElement: true, selectedElementIds: this.state.selectedElementIds,
includeElementsInFrames: true, includeBoundTextElement: true,
}).map((element) => element.id); includeElementsInFrames: true,
})
.map((element) => element.id),
);
const elements = this.scene.getNonDeletedElements();
for (const element of elements) { for (const element of elements) {
if ( if (
selectedElementIds.includes(element.id) || selectedElementIds.has(element.id) ||
// case: the state.selectedElementIds might not have been // case: the state.selectedElementIds might not have been
// updated yet by the time this mousemove event is fired // updated yet by the time this mousemove event is fired
(element.id === hitElement?.id && (element.id === hitElement?.id &&
@@ -5524,14 +5561,10 @@ class App extends React.Component<AppProps, AppState> {
}, },
}, },
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
prevState,
this,
), ),
); );
} else {
this.setState({
selectedElementIds: {},
selectedGroupIds: {},
editingGroupId: null,
});
} }
} }
// box-select line editor points // box-select line editor points
@@ -5547,28 +5580,29 @@ class App extends React.Component<AppProps, AppState> {
elements, elements,
draggingElement, draggingElement,
); );
this.setState((prevState) => this.setState((prevState) => {
selectGroupsForSelectedElements( const nextSelectedElementIds = elementsWithinSelection.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
acc[element.id] = true;
return acc;
},
{},
);
if (pointerDownState.hit.element) {
// if using ctrl/cmd, select the hitElement only if we
// haven't box-selected anything else
if (!elementsWithinSelection.length) {
nextSelectedElementIds[pointerDownState.hit.element.id] = true;
} else {
delete nextSelectedElementIds[pointerDownState.hit.element.id];
}
}
return selectGroupsForSelectedElements(
{ {
...prevState, ...prevState,
selectedElementIds: { selectedElementIds: nextSelectedElementIds,
...prevState.selectedElementIds,
...elementsWithinSelection.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
acc[element.id] = true;
return acc;
},
{},
),
...(pointerDownState.hit.element
? {
// if using ctrl/cmd, select the hitElement only if we
// haven't box-selected anything else
[pointerDownState.hit.element.id]:
!elementsWithinSelection.length,
}
: null),
},
showHyperlinkPopup: showHyperlinkPopup:
elementsWithinSelection.length === 1 && elementsWithinSelection.length === 1 &&
elementsWithinSelection[0].link elementsWithinSelection[0].link
@@ -5585,8 +5619,10 @@ class App extends React.Component<AppProps, AppState> {
: null, : null,
}, },
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
), prevState,
); this,
);
});
} }
} }
}); });
@@ -5684,10 +5720,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.hit?.element?.id !== pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId this.state.selectedLinearElement.elementId
) { ) {
const selectedELements = getSelectedElements( const selectedELements = this.scene.getSelectedElements(this.state);
this.scene.getNonDeletedElements(),
this.state,
);
// set selectedLinearElement to null if there is more than one element selected since we don't want to show linear element handles // set selectedLinearElement to null if there is more than one element selected since we don't want to show linear element handles
if (selectedELements.length > 1) { if (selectedELements.length > 1) {
this.setState({ selectedLinearElement: null }); this.setState({ selectedLinearElement: null });
@@ -5780,7 +5813,12 @@ class App extends React.Component<AppProps, AppState> {
try { try {
this.initializeImageDimensions(imageElement); this.initializeImageDimensions(imageElement);
this.setState( this.setState(
{ selectedElementIds: { [imageElement.id]: true } }, {
selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true },
this.state,
),
},
() => { () => {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
}, },
@@ -5844,10 +5882,13 @@ class App extends React.Component<AppProps, AppState> {
activeTool: updateActiveTool(this.state, { activeTool: updateActiveTool(this.state, {
type: "selection", type: "selection",
}), }),
selectedElementIds: { selectedElementIds: makeNextSelectedElementIds(
...prevState.selectedElementIds, {
[draggingElement.id]: true, ...prevState.selectedElementIds,
}, [draggingElement.id]: true,
},
prevState,
),
selectedLinearElement: new LinearElementEditor( selectedLinearElement: new LinearElementEditor(
draggingElement, draggingElement,
this.scene, this.scene,
@@ -5921,10 +5962,7 @@ class App extends React.Component<AppProps, AppState> {
const topLayerFrame = const topLayerFrame =
this.getTopLayerFrameAtSceneCoords(sceneCoords); this.getTopLayerFrameAtSceneCoords(sceneCoords);
const selectedElements = getSelectedElements( const selectedElements = this.scene.getSelectedElements(this.state);
this.scene.getNonDeletedElements(),
this.state,
);
let nextElements = this.scene.getElementsIncludingDeleted(); let nextElements = this.scene.getElementsIncludingDeleted();
const updateGroupIdsAfterEditingGroup = ( const updateGroupIdsAfterEditingGroup = (
@@ -6003,6 +6041,7 @@ class App extends React.Component<AppProps, AppState> {
nextElements = updateFrameMembershipOfSelectedElements( nextElements = updateFrameMembershipOfSelectedElements(
this.scene.getElementsIncludingDeleted(), this.scene.getElementsIncludingDeleted(),
this.state, this.state,
this,
); );
this.scene.replaceAllElements(nextElements); this.scene.replaceAllElements(nextElements);
@@ -6047,14 +6086,14 @@ class App extends React.Component<AppProps, AppState> {
let nextElements = updateFrameMembershipOfSelectedElements( let nextElements = updateFrameMembershipOfSelectedElements(
this.scene.getElementsIncludingDeleted(), this.scene.getElementsIncludingDeleted(),
this.state, this.state,
this,
); );
const selectedFrames = getSelectedElements( const selectedFrames = this.scene
this.scene.getElementsIncludingDeleted(), .getSelectedElements(this.state)
this.state, .filter(
).filter( (element) => element.type === "frame",
(element) => element.type === "frame", ) as ExcalidrawFrameElement[];
) as ExcalidrawFrameElement[];
for (const frame of selectedFrames) { for (const frame of selectedFrames) {
nextElements = replaceAllElementsInFrame( nextElements = replaceAllElementsInFrame(
@@ -6079,10 +6118,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedLinearElement?.elementId !== hitElement?.id && this.state.selectedLinearElement?.elementId !== hitElement?.id &&
isLinearElement(hitElement) isLinearElement(hitElement)
) { ) {
const selectedELements = getSelectedElements( const selectedELements = this.scene.getSelectedElements(this.state);
this.scene.getNonDeletedElements(),
this.state,
);
// set selectedLinearElement when no other element selected except // set selectedLinearElement when no other element selected except
// the one we've hit // the one we've hit
if (selectedELements.length === 1) { if (selectedELements.length === 1) {
@@ -6141,31 +6177,37 @@ class App extends React.Component<AppProps, AppState> {
if (childEvent.shiftKey && !this.state.editingLinearElement) { if (childEvent.shiftKey && !this.state.editingLinearElement) {
if (this.state.selectedElementIds[hitElement.id]) { if (this.state.selectedElementIds[hitElement.id]) {
if (isSelectedViaGroup(this.state, hitElement)) { if (isSelectedViaGroup(this.state, hitElement)) {
// We want to unselect all groups hitElement is part of this.setState((_prevState) => {
// as well as all elements that are part of the groups const nextSelectedElementIds = {
// hitElement is part of ..._prevState.selectedElementIds,
const idsOfSelectedElementsThatAreInGroups = hitElement.groupIds };
.flatMap((groupId) =>
getElementsInGroup(
this.scene.getNonDeletedElements(),
groupId,
),
)
.map((element) => ({ [element.id]: false }))
.reduce((prevId, acc) => ({ ...prevId, ...acc }), {});
this.setState((_prevState) => ({ // We want to unselect all groups hitElement is part of
selectedGroupIds: { // as well as all elements that are part of the groups
..._prevState.selectedElementIds, // hitElement is part of
...hitElement.groupIds for (const groupedElement of hitElement.groupIds.flatMap(
.map((gId) => ({ [gId]: false })) (groupId) =>
.reduce((prev, acc) => ({ ...prev, ...acc }), {}), getElementsInGroup(
}, this.scene.getNonDeletedElements(),
selectedElementIds: { groupId,
..._prevState.selectedElementIds, ),
...idsOfSelectedElementsThatAreInGroups, )) {
}, delete nextSelectedElementIds[groupedElement.id];
})); }
return {
selectedGroupIds: {
..._prevState.selectedElementIds,
...hitElement.groupIds
.map((gId) => ({ [gId]: false }))
.reduce((prev, acc) => ({ ...prev, ...acc }), {}),
},
selectedElementIds: makeNextSelectedElementIds(
nextSelectedElementIds,
_prevState,
),
};
});
// if not gragging a linear element point (outside editor) // if not gragging a linear element point (outside editor)
} else if (!this.state.selectedLinearElement?.isDragging) { } else if (!this.state.selectedLinearElement?.isDragging) {
// remove element from selection while // remove element from selection while
@@ -6174,11 +6216,11 @@ class App extends React.Component<AppProps, AppState> {
this.setState((prevState) => { this.setState((prevState) => {
const newSelectedElementIds = { const newSelectedElementIds = {
...prevState.selectedElementIds, ...prevState.selectedElementIds,
[hitElement!.id]: false,
}; };
delete newSelectedElementIds[hitElement!.id];
const newSelectedElements = getSelectedElements( const newSelectedElements = getSelectedElements(
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
{ ...prevState, selectedElementIds: newSelectedElementIds }, { selectedElementIds: newSelectedElementIds },
); );
return selectGroupsForSelectedElements( return selectGroupsForSelectedElements(
@@ -6196,6 +6238,8 @@ class App extends React.Component<AppProps, AppState> {
: prevState.selectedLinearElement, : prevState.selectedLinearElement,
}, },
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
prevState,
this,
); );
}); });
} }
@@ -6206,21 +6250,23 @@ class App extends React.Component<AppProps, AppState> {
// when hitElement is part of a selected frame, deselect the frame // when hitElement is part of a selected frame, deselect the frame
// to avoid frame and containing elements selected simultaneously // to avoid frame and containing elements selected simultaneously
this.setState((prevState) => { this.setState((prevState) => {
const nextSelectedElementIds = { const nextSelectedElementIds: {
[id: string]: true;
} = {
...prevState.selectedElementIds, ...prevState.selectedElementIds,
[hitElement.id]: true, [hitElement.id]: true,
// deselect the frame
[hitElement.frameId!]: false,
}; };
// deselect the frame
delete nextSelectedElementIds[hitElement.frameId!];
// deselect groups containing the frame // deselect groups containing the frame
(this.scene.getElement(hitElement.frameId!)?.groupIds ?? []) (this.scene.getElement(hitElement.frameId!)?.groupIds ?? [])
.flatMap((gid) => .flatMap((gid) =>
getElementsInGroup(this.scene.getNonDeletedElements(), gid), getElementsInGroup(this.scene.getNonDeletedElements(), gid),
) )
.forEach( .forEach((element) => {
(element) => (nextSelectedElementIds[element.id] = false), delete nextSelectedElementIds[element.id];
); });
return selectGroupsForSelectedElements( return selectGroupsForSelectedElements(
{ {
@@ -6229,15 +6275,20 @@ class App extends React.Component<AppProps, AppState> {
showHyperlinkPopup: hitElement.link ? "info" : false, showHyperlinkPopup: hitElement.link ? "info" : false,
}, },
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
prevState,
this,
); );
}); });
} else { } else {
// add element to selection while keeping prev elements selected // add element to selection while keeping prev elements selected
this.setState((_prevState) => ({ this.setState((_prevState) => ({
selectedElementIds: { selectedElementIds: makeNextSelectedElementIds(
..._prevState.selectedElementIds, {
[hitElement!.id]: true, ..._prevState.selectedElementIds,
}, [hitElement!.id]: true,
},
_prevState,
),
})); }));
} }
} else { } else {
@@ -6255,6 +6306,8 @@ class App extends React.Component<AppProps, AppState> {
: prevState.selectedLinearElement, : prevState.selectedLinearElement,
}, },
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
prevState,
this,
), ),
})); }));
} }
@@ -6279,7 +6332,7 @@ class App extends React.Component<AppProps, AppState> {
} else { } else {
// Deselect selected elements // Deselect selected elements
this.setState({ this.setState({
selectedElementIds: {}, selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {}, selectedGroupIds: {},
editingGroupId: null, editingGroupId: null,
}); });
@@ -6290,13 +6343,17 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
!activeTool.locked && !activeTool.locked &&
activeTool.type !== "freedraw" && activeTool.type !== "freedraw" &&
draggingElement draggingElement &&
draggingElement.type !== "selection"
) { ) {
this.setState((prevState) => ({ this.setState((prevState) => ({
selectedElementIds: { selectedElementIds: makeNextSelectedElementIds(
...prevState.selectedElementIds, {
[draggingElement.id]: true, ...prevState.selectedElementIds,
}, [draggingElement.id]: true,
},
prevState,
),
})); }));
} }
@@ -6310,9 +6367,7 @@ class App extends React.Component<AppProps, AppState> {
if (pointerDownState.drag.hasOccurred || isResizing || isRotating) { if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
(isBindingEnabled(this.state) (isBindingEnabled(this.state)
? bindOrUnbindSelectedElements ? bindOrUnbindSelectedElements
: unbindLinearElements)( : unbindLinearElements)(this.scene.getSelectedElements(this.state));
getSelectedElements(this.scene.getNonDeletedElements(), this.state),
);
} }
if (!activeTool.locked && activeTool.type !== "freedraw") { if (!activeTool.locked && activeTool.type !== "freedraw") {
@@ -6610,7 +6665,10 @@ class App extends React.Component<AppProps, AppState> {
this.initializeImageDimensions(imageElement); this.initializeImageDimensions(imageElement);
this.setState( this.setState(
{ {
selectedElementIds: { [imageElement.id]: true }, selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true },
this.state,
),
}, },
() => { () => {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
@@ -6837,7 +6895,7 @@ class App extends React.Component<AppProps, AppState> {
private clearSelection(hitElement: ExcalidrawElement | null): void { private clearSelection(hitElement: ExcalidrawElement | null): void {
this.setState((prevState) => ({ this.setState((prevState) => ({
selectedElementIds: {}, selectedElementIds: makeNextSelectedElementIds({}, prevState),
selectedGroupIds: {}, selectedGroupIds: {},
// Continue editing the same group if the user selected a different // Continue editing the same group if the user selected a different
// element from it // element from it
@@ -6849,7 +6907,7 @@ class App extends React.Component<AppProps, AppState> {
: null, : null,
})); }));
this.setState({ this.setState({
selectedElementIds: {}, selectedElementIds: makeNextSelectedElementIds({}, this.state),
previousSelectedElementIds: this.state.selectedElementIds, previousSelectedElementIds: this.state.selectedElementIds,
}); });
} }
@@ -6918,7 +6976,12 @@ class App extends React.Component<AppProps, AppState> {
const imageElement = this.createImageElement({ sceneX, sceneY }); const imageElement = this.createImageElement({ sceneX, sceneY });
this.insertImageElement(imageElement, file); this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement); this.initializeImageDimensions(imageElement);
this.setState({ selectedElementIds: { [imageElement.id]: true } }); this.setState({
selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true },
this.state,
),
});
return; return;
} }
@@ -7011,10 +7074,7 @@ class App extends React.Component<AppProps, AppState> {
includeLockedElements: true, includeLockedElements: true,
}); });
const selectedElements = getSelectedElements( const selectedElements = this.scene.getSelectedElements(this.state);
this.scene.getNonDeletedElements(),
this.state,
);
const isHittignCommonBoundBox = const isHittignCommonBoundBox =
this.isHittingCommonBoundingBoxOfSelectedElements( this.isHittingCommonBoundingBoxOfSelectedElements(
{ x, y }, { x, y },
@@ -7043,6 +7103,8 @@ class App extends React.Component<AppProps, AppState> {
: null, : null,
}, },
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.state,
this,
) )
: this.state), : this.state),
showHyperlinkPopup: false, showHyperlinkPopup: false,
@@ -7130,10 +7192,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
event: MouseEvent | KeyboardEvent, event: MouseEvent | KeyboardEvent,
): boolean => { ): boolean => {
const selectedElements = getSelectedElements( const selectedElements = this.scene.getSelectedElements(this.state);
this.scene.getNonDeletedElements(),
this.state,
);
const selectedFrames = selectedElements.filter( const selectedFrames = selectedElements.filter(
(element) => element.type === "frame", (element) => element.type === "frame",
) as ExcalidrawFrameElement[]; ) as ExcalidrawFrameElement[];
@@ -8,7 +8,7 @@ import {
} from "./colorPickerUtils"; } from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel"; import HotkeyLabel from "./HotkeyLabel";
import { ColorPaletteCustom } from "../../colors"; import { ColorPaletteCustom } from "../../colors";
import { t } from "../../i18n"; import { TranslationKeys, t } from "../../i18n";
interface PickerColorListProps { interface PickerColorListProps {
palette: ColorPaletteCustom; palette: ColorPaletteCustom;
@@ -48,7 +48,11 @@ const PickerColorList = ({
(Array.isArray(value) ? value[activeShade] : value) || "transparent"; (Array.isArray(value) ? value[activeShade] : value) || "transparent";
const keybinding = colorPickerHotkeyBindings[index]; const keybinding = colorPickerHotkeyBindings[index];
const label = t(`colors.${key.replace(/\d+/, "")}`, null, ""); const label = t(
`colors.${key.replace(/\d+/, "")}` as unknown as TranslationKeys,
null,
"",
);
return ( return (
<button <button
+9 -3
View File
@@ -1,6 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import { Popover } from "./Popover"; import { Popover } from "./Popover";
import { t } from "../i18n"; import { t, TranslationKeys } from "../i18n";
import "./ContextMenu.scss"; import "./ContextMenu.scss";
import { import {
@@ -82,9 +82,15 @@ export const ContextMenu = React.memo(
let label = ""; let label = "";
if (item.contextItemLabel) { if (item.contextItemLabel) {
if (typeof item.contextItemLabel === "function") { if (typeof item.contextItemLabel === "function") {
label = t(item.contextItemLabel(elements, appState)); label = t(
item.contextItemLabel(
elements,
appState,
actionManager.app,
) as unknown as TranslationKeys,
);
} else { } else {
label = t(item.contextItemLabel); label = t(item.contextItemLabel as unknown as TranslationKeys);
} }
} }
+6 -13
View File
@@ -1,7 +1,5 @@
import { t } from "../i18n"; import { t } from "../i18n";
import { NonDeletedExcalidrawElement } from "../element/types"; import { AppClassProperties, Device, UIAppState } from "../types";
import { getSelectedElements } from "../scene";
import { Device, UIAppState } from "../types";
import { import {
isImageElement, isImageElement,
isLinearElement, isLinearElement,
@@ -15,17 +13,12 @@ import "./HintViewer.scss";
interface HintViewerProps { interface HintViewerProps {
appState: UIAppState; appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[];
isMobile: boolean; isMobile: boolean;
device: Device; device: Device;
app: AppClassProperties;
} }
const getHints = ({ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
appState,
elements,
isMobile,
device,
}: HintViewerProps) => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null; const multiMode = appState.multiElement !== null;
@@ -55,7 +48,7 @@ const getHints = ({
return t("hints.placeImage"); return t("hints.placeImage");
} }
const selectedElements = getSelectedElements(elements, appState); const selectedElements = app.scene.getSelectedElements(appState);
if ( if (
isResizing && isResizing &&
@@ -115,15 +108,15 @@ const getHints = ({
export const HintViewer = ({ export const HintViewer = ({
appState, appState,
elements,
isMobile, isMobile,
device, device,
app,
}: HintViewerProps) => { }: HintViewerProps) => {
let hint = getHints({ let hint = getHints({
appState, appState,
elements,
isMobile, isMobile,
device, device,
app,
}); });
if (!hint) { if (!hint) {
return null; return null;
+4 -1
View File
@@ -72,6 +72,7 @@ interface LayerUIProps {
onExportImage: AppClassProperties["onExportImage"]; onExportImage: AppClassProperties["onExportImage"];
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
children?: React.ReactNode; children?: React.ReactNode;
app: AppClassProperties;
} }
const DefaultMainMenu: React.FC<{ const DefaultMainMenu: React.FC<{
@@ -127,6 +128,7 @@ const LayerUI = ({
onExportImage, onExportImage,
renderWelcomeScreen, renderWelcomeScreen,
children, children,
app,
}: LayerUIProps) => { }: LayerUIProps) => {
const device = useDevice(); const device = useDevice();
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
@@ -240,9 +242,9 @@ const LayerUI = ({
> >
<HintViewer <HintViewer
appState={appState} appState={appState}
elements={elements}
isMobile={device.isMobile} isMobile={device.isMobile}
device={device} device={device}
app={app}
/> />
{heading} {heading}
<Stack.Row gap={1}> <Stack.Row gap={1}>
@@ -401,6 +403,7 @@ const LayerUI = ({
)} )}
{device.isMobile && ( {device.isMobile && (
<MobileMenu <MobileMenu
app={app}
appState={appState} appState={appState}
elements={elements} elements={elements}
actionManager={actionManager} actionManager={actionManager}
+10 -2
View File
@@ -1,5 +1,11 @@
import React from "react"; import React from "react";
import { AppState, Device, ExcalidrawProps, UIAppState } from "../types"; import {
AppClassProperties,
AppState,
Device,
ExcalidrawProps,
UIAppState,
} from "../types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { t } from "../i18n"; import { t } from "../i18n";
import Stack from "./Stack"; import Stack from "./Stack";
@@ -41,6 +47,7 @@ type MobileMenuProps = {
renderSidebars: () => JSX.Element | null; renderSidebars: () => JSX.Element | null;
device: Device; device: Device;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
app: AppClassProperties;
}; };
export const MobileMenu = ({ export const MobileMenu = ({
@@ -58,6 +65,7 @@ export const MobileMenu = ({
renderSidebars, renderSidebars,
device, device,
renderWelcomeScreen, renderWelcomeScreen,
app,
}: MobileMenuProps) => { }: MobileMenuProps) => {
const { const {
WelcomeScreenCenterTunnel, WelcomeScreenCenterTunnel,
@@ -119,9 +127,9 @@ export const MobileMenu = ({
</Section> </Section>
<HintViewer <HintViewer
appState={appState} appState={appState}
elements={elements}
isMobile={true} isMobile={true}
device={device} device={device}
app={app}
/> />
</FixedSideContainer> </FixedSideContainer>
); );
+1 -1
View File
@@ -3,7 +3,7 @@ import { t } from "../i18n";
import { useExcalidrawContainer } from "./App"; import { useExcalidrawContainer } from "./App";
export const Section: React.FC<{ export const Section: React.FC<{
heading: string; heading: "canvasActions" | "selectedShapeActions" | "shapes";
children?: React.ReactNode | ((heading: React.ReactNode) => React.ReactNode); children?: React.ReactNode | ((heading: React.ReactNode) => React.ReactNode);
className?: string; className?: string;
}> = ({ heading, children, ...props }) => { }> = ({ heading, children, ...props }) => {
+9 -5
View File
@@ -3,6 +3,7 @@ import { render } from "@testing-library/react";
import fallbackLangData from "../locales/en.json"; import fallbackLangData from "../locales/en.json";
import Trans from "./Trans"; import Trans from "./Trans";
import { TranslationKeys } from "../i18n";
describe("Test <Trans/>", () => { describe("Test <Trans/>", () => {
it("should translate the the strings correctly", () => { it("should translate the the strings correctly", () => {
@@ -18,24 +19,27 @@ describe("Test <Trans/>", () => {
const { getByTestId } = render( const { getByTestId } = render(
<> <>
<div data-testid="test1"> <div data-testid="test1">
<Trans i18nKey="transTest.key1" audience="world" /> <Trans
i18nKey={"transTest.key1" as unknown as TranslationKeys}
audience="world"
/>
</div> </div>
<div data-testid="test2"> <div data-testid="test2">
<Trans <Trans
i18nKey="transTest.key2" i18nKey={"transTest.key2" as unknown as TranslationKeys}
link={(el) => <a href="https://example.com">{el}</a>} link={(el) => <a href="https://example.com">{el}</a>}
/> />
</div> </div>
<div data-testid="test3"> <div data-testid="test3">
<Trans <Trans
i18nKey="transTest.key3" i18nKey={"transTest.key3" as unknown as TranslationKeys}
link={(el) => <a href="https://example.com">{el}</a>} link={(el) => <a href="https://example.com">{el}</a>}
location="the button" location="the button"
/> />
</div> </div>
<div data-testid="test4"> <div data-testid="test4">
<Trans <Trans
i18nKey="transTest.key4" i18nKey={"transTest.key4" as unknown as TranslationKeys}
link={(el) => <a href="https://example.com">{el}</a>} link={(el) => <a href="https://example.com">{el}</a>}
location="the button" location="the button"
bold={(el) => <strong>{el}</strong>} bold={(el) => <strong>{el}</strong>}
@@ -43,7 +47,7 @@ describe("Test <Trans/>", () => {
</div> </div>
<div data-testid="test5"> <div data-testid="test5">
<Trans <Trans
i18nKey="transTest.key5" i18nKey={"transTest.key5" as unknown as TranslationKeys}
connect-link={(el) => <a href="https://example.com">{el}</a>} connect-link={(el) => <a href="https://example.com">{el}</a>}
/> />
</div> </div>
+2 -2
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { useI18n } from "../i18n"; import { TranslationKeys, useI18n } from "../i18n";
// Used for splitting i18nKey into tokens in Trans component // Used for splitting i18nKey into tokens in Trans component
// Example: // Example:
@@ -153,7 +153,7 @@ const Trans = ({
children, children,
...props ...props
}: { }: {
i18nKey: string; i18nKey: TranslationKeys;
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode); [key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
+135
View File
@@ -0,0 +1,135 @@
declare global {
interface Window {
debug: typeof Debug;
}
}
const lessPrecise = (num: number, precision = 5) =>
parseFloat(num.toPrecision(precision));
const getAvgFrameTime = (times: number[]) =>
lessPrecise(times.reduce((a, b) => a + b) / times.length);
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
export class Debug {
public static DEBUG_LOG_TIMES = true;
private static TIMES_AGGR: Record<string, { t: number; times: number[] }> =
{};
private static TIMES_AVG: Record<
string,
{ t: number; times: number[]; avg: number | null }
> = {};
private static LAST_DEBUG_LOG_CALL = 0;
private static DEBUG_LOG_INTERVAL_ID: null | number = null;
private static setupInterval = () => {
if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
console.info("%c(starting perf recording)", "color: lime");
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
}
Debug.LAST_DEBUG_LOG_CALL = Date.now();
};
private static debugLogger = () => {
if (
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
Debug.DEBUG_LOG_INTERVAL_ID !== null
) {
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
Debug.DEBUG_LOG_INTERVAL_ID = null;
for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
if (avg != null) {
console.info(
`%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
"color: blue",
);
}
}
console.info("%c(stopping perf recording)", "color: red");
Debug.TIMES_AGGR = {};
Debug.TIMES_AVG = {};
return;
}
if (Debug.DEBUG_LOG_TIMES) {
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
if (times.length) {
console.info(
name,
lessPrecise(times.reduce((a, b) => a + b)),
times.sort((a, b) => a - b).map((x) => lessPrecise(x)),
);
Debug.TIMES_AGGR[name] = { t, times: [] };
}
}
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
if (times.length) {
const avgFrameTime = getAvgFrameTime(times);
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
Debug.TIMES_AVG[name] = {
t,
times: [],
avg:
avg != null ? getAvgFrameTime([avg, avgFrameTime]) : avgFrameTime,
};
}
}
}
};
public static logTime = (time?: number, name = "default") => {
Debug.setupInterval();
const now = performance.now();
const { t, times } = (Debug.TIMES_AGGR[name] = Debug.TIMES_AGGR[name] || {
t: 0,
times: [],
});
if (t) {
times.push(time != null ? time : now - t);
}
Debug.TIMES_AGGR[name].t = now;
};
public static logTimeAverage = (time?: number, name = "default") => {
Debug.setupInterval();
const now = performance.now();
const { t, times } = (Debug.TIMES_AVG[name] = Debug.TIMES_AVG[name] || {
t: 0,
times: [],
});
if (t) {
times.push(time != null ? time : now - t);
}
Debug.TIMES_AVG[name].t = now;
};
private static logWrapper =
(type: "logTime" | "logTimeAverage") =>
<T extends any[], R>(fn: (...args: T) => R, name = "default") => {
return (...args: T) => {
const t0 = performance.now();
const ret = fn(...args);
Debug.logTime(performance.now() - t0, name);
return ret;
};
};
public static logTimeWrap = Debug.logWrapper("logTime");
public static logTimeAverageWrap = Debug.logWrapper("logTimeAverage");
public static perfWrap = <T extends any[], R>(
fn: (...args: T) => R,
name = "default",
) => {
return (...args: T) => {
// eslint-disable-next-line no-console
console.time(name);
const ret = fn(...args);
// eslint-disable-next-line no-console
console.timeEnd(name);
return ret;
};
};
}
window.debug = Debug;
+7 -2
View File
@@ -16,7 +16,7 @@ import {
} from "./element/textElement"; } from "./element/textElement";
import { arrayToMap, findIndex } from "./utils"; import { arrayToMap, findIndex } from "./utils";
import { mutateElement } from "./element/mutateElement"; import { mutateElement } from "./element/mutateElement";
import { AppState } from "./types"; import { AppClassProperties, AppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene"; import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { isFrameElement } from "./element"; import { isFrameElement } from "./element";
import { moveOneRight } from "./zindex"; import { moveOneRight } from "./zindex";
@@ -571,8 +571,13 @@ export const replaceAllElementsInFrame = (
export const updateFrameMembershipOfSelectedElements = ( export const updateFrameMembershipOfSelectedElements = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
appState: AppState, appState: AppState,
app: AppClassProperties,
) => { ) => {
const selectedElements = getSelectedElements(allElements, appState); const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
// supplying elements explicitly in case we're passed non-state elements
elements: allElements,
});
const elementsToFilter = new Set<ExcalidrawElement>(selectedElements); const elementsToFilter = new Set<ExcalidrawElement>(selectedElements);
if (appState.editingGroupId) { if (appState.editingGroupId) {
+35 -5
View File
@@ -1,7 +1,13 @@
import { GroupId, ExcalidrawElement, NonDeleted } from "./element/types"; import {
import { AppState } from "./types"; GroupId,
ExcalidrawElement,
NonDeleted,
NonDeletedExcalidrawElement,
} from "./element/types";
import { AppClassProperties, AppState } from "./types";
import { getSelectedElements } from "./scene"; import { getSelectedElements } from "./scene";
import { getBoundTextElement } from "./element/textElement"; import { getBoundTextElement } from "./element/textElement";
import { makeNextSelectedElementIds } from "./scene/selection";
export const selectGroup = ( export const selectGroup = (
groupId: GroupId, groupId: GroupId,
@@ -66,14 +72,33 @@ export const getSelectedGroupIds = (appState: AppState): GroupId[] =>
*/ */
export const selectGroupsForSelectedElements = ( export const selectGroupsForSelectedElements = (
appState: AppState, appState: AppState,
elements: readonly NonDeleted<ExcalidrawElement>[], elements: readonly NonDeletedExcalidrawElement[],
prevAppState: AppState,
/**
* supply null in cases where you don't have access to App instance and
* you don't care about optimizing selectElements retrieval
*/
app: AppClassProperties | null,
): AppState => { ): AppState => {
let nextAppState: AppState = { ...appState, selectedGroupIds: {} }; let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
const selectedElements = getSelectedElements(elements, appState); const selectedElements = app
? app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
// supplying elements explicitly in case we're passed non-state elements
elements,
})
: getSelectedElements(elements, appState);
if (!selectedElements.length) { if (!selectedElements.length) {
return { ...nextAppState, editingGroupId: null }; return {
...nextAppState,
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds(
nextAppState.selectedElementIds,
prevAppState,
),
};
} }
for (const selectedElement of selectedElements) { for (const selectedElement of selectedElements) {
@@ -91,6 +116,11 @@ export const selectGroupsForSelectedElements = (
} }
} }
nextAppState.selectedElementIds = makeNextSelectedElementIds(
nextAppState.selectedElementIds,
prevAppState,
);
return nextAppState; return nextAppState;
}; };
+4 -1
View File
@@ -3,6 +3,7 @@ import percentages from "./locales/percentages.json";
import { ENV } from "./constants"; import { ENV } from "./constants";
import { jotaiScope, jotaiStore } from "./jotai"; import { jotaiScope, jotaiStore } from "./jotai";
import { atom, useAtomValue } from "jotai"; import { atom, useAtomValue } from "jotai";
import { NestedKeyOf } from "./utility-types";
const COMPLETION_THRESHOLD = 85; const COMPLETION_THRESHOLD = 85;
@@ -12,6 +13,8 @@ export interface Language {
rtl?: boolean; rtl?: boolean;
} }
export type TranslationKeys = NestedKeyOf<typeof fallbackLangData>;
export const defaultLang = { code: "en", label: "English" }; export const defaultLang = { code: "en", label: "English" };
export const languages: Language[] = [ export const languages: Language[] = [
@@ -123,7 +126,7 @@ const findPartsForData = (data: any, parts: string[]) => {
}; };
export const t = ( export const t = (
path: string, path: NestedKeyOf<typeof fallbackLangData>,
replacement?: { [key: string]: string | number } | null, replacement?: { [key: string]: string | number } | null,
fallback?: string, fallback?: string,
) => { ) => {
+16
View File
@@ -0,0 +1,16 @@
[
{
"path": "dist/excalidraw.production.min.js",
"limit": "285 kB"
},
{
"path": "dist/excalidraw-assets/locales",
"name": "dist/excalidraw-assets/locales",
"limit": "270 kB"
},
{
"path": "dist/excalidraw-assets/vendor-*.js",
"name": "dist/excalidraw-assets/vendor*.js",
"limit": "30 kB"
}
]
+5 -1
View File
@@ -52,6 +52,7 @@
"@babel/preset-env": "7.18.6", "@babel/preset-env": "7.18.6",
"@babel/preset-react": "7.18.6", "@babel/preset-react": "7.18.6",
"@babel/preset-typescript": "7.18.6", "@babel/preset-typescript": "7.18.6",
"@size-limit/preset-big-lib": "8.2.6",
"autoprefixer": "10.4.7", "autoprefixer": "10.4.7",
"babel-loader": "8.2.5", "babel-loader": "8.2.5",
"babel-plugin-transform-class-properties": "6.24.1", "babel-plugin-transform-class-properties": "6.24.1",
@@ -61,6 +62,8 @@
"mini-css-extract-plugin": "2.6.1", "mini-css-extract-plugin": "2.6.1",
"postcss-loader": "7.0.1", "postcss-loader": "7.0.1",
"sass-loader": "13.0.2", "sass-loader": "13.0.2",
"size-limit": "8.2.4",
"style-loader": "3.3.3",
"terser-webpack-plugin": "5.3.3", "terser-webpack-plugin": "5.3.3",
"ts-loader": "9.3.1", "ts-loader": "9.3.1",
"typescript": "4.9.4", "typescript": "4.9.4",
@@ -79,6 +82,7 @@
"pack": "yarn build:umd && yarn pack", "pack": "yarn build:umd && yarn pack",
"start": "webpack serve --config webpack.dev-server.config.js", "start": "webpack serve --config webpack.dev-server.config.js",
"install:deps": "yarn install --frozen-lockfile && yarn --cwd ../../../", "install:deps": "yarn install --frozen-lockfile && yarn --cwd ../../../",
"build:example": "EXAMPLE=true webpack --config webpack.dev-server.config.js && yarn gen:types" "build:example": "EXAMPLE=true webpack --config webpack.dev-server.config.js && yarn gen:types",
"size": "yarn build:umd && size-limit"
} }
} }
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -931,7 +931,11 @@ export const renderElement = (
break; break;
} }
case "frame": { case "frame": {
if (!renderConfig.isExporting && appState.shouldRenderFrames) { if (
!renderConfig.isExporting &&
appState.frameRendering.enabled &&
appState.frameRendering.outline
) {
context.save(); context.save();
context.translate( context.translate(
element.x + renderConfig.scrollX, element.x + renderConfig.scrollX,
+3 -1
View File
@@ -470,7 +470,9 @@ export const _renderScene = ({
if ( if (
frameId && frameId &&
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) || ((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
(!renderConfig.isExporting && appState.shouldRenderFrames)) (!renderConfig.isExporting &&
appState.frameRendering.enabled &&
appState.frameRendering.clip))
) { ) {
context.save(); context.save();
+93
View File
@@ -11,6 +11,9 @@ import {
} from "../element"; } from "../element";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { isFrameElement } from "../element/typeChecks"; import { isFrameElement } from "../element/typeChecks";
import { getSelectedElements } from "./selection";
import { AppState } from "../types";
import { Assert, SameType } from "../utility-types";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"]; type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey; type ElementKey = ExcalidrawElement | ElementIdKey;
@@ -18,6 +21,31 @@ type ElementKey = ExcalidrawElement | ElementIdKey;
type SceneStateCallback = () => void; type SceneStateCallback = () => void;
type SceneStateCallbackRemover = () => void; type SceneStateCallbackRemover = () => void;
type SelectionHash = string & { __brand: "selectionHash" };
const hashSelectionOpts = (
opts: Parameters<InstanceType<typeof Scene>["getSelectedElements"]>[0],
) => {
const keys = ["includeBoundTextElement", "includeElementsInFrames"] as const;
type HashableKeys = Omit<typeof opts, "selectedElementIds" | "elements">;
// just to ensure we're hashing all expected keys
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type _ = Assert<
SameType<
Required<HashableKeys>,
Pick<Required<HashableKeys>, typeof keys[number]>
>
>;
let hash = "";
for (const key of keys) {
hash += `${key}:${opts[key] ? "1" : "0"}`;
}
return hash as SelectionHash;
};
// ideally this would be a branded type but it'd be insanely hard to work with // ideally this would be a branded type but it'd be insanely hard to work with
// in our codebase // in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[]; export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
@@ -68,6 +96,15 @@ class Scene {
private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = []; private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = [];
private frames: readonly ExcalidrawFrameElement[] = []; private frames: readonly ExcalidrawFrameElement[] = [];
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>(); private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
private selectedElementsCache: {
selectedElementIds: AppState["selectedElementIds"] | null;
elements: readonly NonDeletedExcalidrawElement[] | null;
cache: Map<SelectionHash, NonDeletedExcalidrawElement[]>;
} = {
selectedElementIds: null,
elements: null,
cache: new Map(),
};
getElementsIncludingDeleted() { getElementsIncludingDeleted() {
return this.elements; return this.elements;
@@ -81,6 +118,52 @@ class Scene {
return this.frames; return this.frames;
} }
getSelectedElements(opts: {
// NOTE can be ommitted by making Scene constructor require App instance
selectedElementIds: AppState["selectedElementIds"];
/**
* for specific cases where you need to use elements not from current
* scene state. This in effect will likely result in cache-miss, and
* the cache won't be updated in this case.
*/
elements?: readonly ExcalidrawElement[];
// selection-related options
includeBoundTextElement?: boolean;
includeElementsInFrames?: boolean;
}): NonDeleted<ExcalidrawElement>[] {
const hash = hashSelectionOpts(opts);
const elements = opts?.elements || this.nonDeletedElements;
if (
this.selectedElementsCache.elements === elements &&
this.selectedElementsCache.selectedElementIds === opts.selectedElementIds
) {
const cached = this.selectedElementsCache.cache.get(hash);
if (cached) {
return cached;
}
} else if (opts?.elements == null) {
// if we're operating on latest scene elements and the cache is not
// storing the latest elements, clear the cache
this.selectedElementsCache.cache.clear();
}
const selectedElements = getSelectedElements(
elements,
{ selectedElementIds: opts.selectedElementIds },
opts,
);
// cache only if we're not using custom elements
if (opts?.elements == null) {
this.selectedElementsCache.selectedElementIds = opts.selectedElementIds;
this.selectedElementsCache.elements = this.nonDeletedElements;
this.selectedElementsCache.cache.set(hash, selectedElements);
}
return selectedElements;
}
getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] { getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] {
return this.nonDeletedFrames; return this.nonDeletedFrames;
} }
@@ -168,11 +251,21 @@ class Scene {
} }
destroy() { destroy() {
this.nonDeletedElements = [];
this.elements = [];
this.nonDeletedFrames = [];
this.frames = [];
this.elementsMap.clear();
this.selectedElementsCache.selectedElementIds = null;
this.selectedElementsCache.elements = null;
this.selectedElementsCache.cache.clear();
Scene.sceneMapById.forEach((scene, elementKey) => { Scene.sceneMapById.forEach((scene, elementKey) => {
if (scene === this) { if (scene === this) {
Scene.sceneMapById.delete(elementKey); Scene.sceneMapById.delete(elementKey);
} }
}); });
// done not for memory leaks, but to guard against possible late fires // done not for memory leaks, but to guard against possible late fires
// (I guess?) // (I guess?)
this.callbacks.clear(); this.callbacks.clear();
+35
View File
@@ -0,0 +1,35 @@
import { makeNextSelectedElementIds } from "./selection";
describe("makeNextSelectedElementIds", () => {
const _makeNextSelectedElementIds = (
selectedElementIds: { [id: string]: true },
prevSelectedElementIds: { [id: string]: true },
expectUpdated: boolean,
) => {
const ret = makeNextSelectedElementIds(selectedElementIds, {
selectedElementIds: prevSelectedElementIds,
});
expect(ret === selectedElementIds).toBe(expectUpdated);
};
it("should return prevState selectedElementIds if no change", () => {
_makeNextSelectedElementIds({}, {}, false);
_makeNextSelectedElementIds({ 1: true }, { 1: true }, false);
_makeNextSelectedElementIds(
{ 1: true, 2: true },
{ 1: true, 2: true },
false,
);
});
it("should return new selectedElementIds if changed", () => {
// _makeNextSelectedElementIds({ 1: true }, { 1: false }, true);
_makeNextSelectedElementIds({ 1: true }, {}, true);
_makeNextSelectedElementIds({}, { 1: true }, true);
_makeNextSelectedElementIds({ 1: true }, { 2: true }, true);
_makeNextSelectedElementIds({ 1: true }, { 1: true, 2: true }, true);
_makeNextSelectedElementIds(
{ 1: true, 2: true },
{ 1: true, 3: true },
true,
);
});
});
+51 -5
View File
@@ -10,6 +10,7 @@ import {
getContainingFrame, getContainingFrame,
getFrameElements, getFrameElements,
} from "../frame"; } from "../frame";
import { isShallowEqual } from "../utils";
/** /**
* Frames and their containing elements are not to be selected at the same time. * Frames and their containing elements are not to be selected at the same time.
@@ -88,11 +89,41 @@ export const getElementsWithinSelection = (
return elementsInSelection; return elementsInSelection;
}; };
export const isSomeElementSelected = ( // FIXME move this into the editor instance to keep utility methods stateless
elements: readonly NonDeletedExcalidrawElement[], export const isSomeElementSelected = (function () {
appState: Pick<AppState, "selectedElementIds">, let lastElements: readonly NonDeletedExcalidrawElement[] | null = null;
): boolean => let lastSelectedElementIds: AppState["selectedElementIds"] | null = null;
elements.some((element) => appState.selectedElementIds[element.id]); let isSelected: boolean | null = null;
const ret = (
elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">,
): boolean => {
if (
isSelected != null &&
elements === lastElements &&
appState.selectedElementIds === lastSelectedElementIds
) {
return isSelected;
}
isSelected = elements.some(
(element) => appState.selectedElementIds[element.id],
);
lastElements = elements;
lastSelectedElementIds = appState.selectedElementIds;
return isSelected;
};
ret.clearCache = () => {
lastElements = null;
lastSelectedElementIds = null;
isSelected = null;
};
return ret;
})();
/** /**
* Returns common attribute (picked by `getAttribute` callback) of selected * Returns common attribute (picked by `getAttribute` callback) of selected
@@ -161,3 +192,18 @@ export const getTargetElements = (
: getSelectedElements(elements, appState, { : getSelectedElements(elements, appState, {
includeBoundTextElement: true, includeBoundTextElement: true,
}); });
/**
* returns prevState's selectedElementids if no change from previous, so as to
* retain reference identity for memoization
*/
export const makeNextSelectedElementIds = (
nextSelectedElementIds: AppState["selectedElementIds"],
prevState: Pick<AppState, "selectedElementIds">,
) => {
if (isShallowEqual(prevState.selectedElementIds, nextSelectedElementIds)) {
return prevState.selectedElementIds;
}
return nextSelectedElementIds;
};
+138 -66
View File
@@ -314,6 +314,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -353,7 +359,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -501,6 +506,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -537,7 +548,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -694,6 +704,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -730,7 +746,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -1061,6 +1076,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -1097,7 +1118,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -1428,6 +1448,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -1464,7 +1490,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -1502,14 +1527,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 449462985, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 453191, "versionNonce": 449462985,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -1561,14 +1586,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 449462985, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 453191, "versionNonce": 449462985,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -1621,6 +1646,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -1655,7 +1686,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -1851,6 +1881,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -1887,7 +1923,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -2146,6 +2181,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -2179,7 +2220,6 @@ Object {
"selectedElementIds": Object { "selectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
"id2": true,
}, },
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": Object { "selectedGroupIds": Object {
@@ -2188,7 +2228,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -2413,7 +2452,6 @@ Object {
"selectedElementIds": Object { "selectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
"id2": true,
}, },
"selectedGroupIds": Object { "selectedGroupIds": Object {
"id3": true, "id3": true,
@@ -2531,6 +2569,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -2567,7 +2611,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -3404,6 +3447,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -3440,7 +3489,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -3771,6 +3819,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -3807,7 +3861,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -4138,6 +4191,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -4171,14 +4230,12 @@ Object {
"selectedElementIds": Object { "selectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
"id2": true,
}, },
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": Object {}, "selectedGroupIds": Object {},
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -4214,14 +4271,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 449462985, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 238820263, "versionNonce": 1014066025,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -4246,14 +4303,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 401146281, "seed": 453191,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 400692809, "versionNonce": 238820263,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -4305,14 +4362,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 449462985, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 453191, "versionNonce": 449462985,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -4348,14 +4405,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 449462985, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 453191, "versionNonce": 449462985,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -4377,14 +4434,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 401146281, "seed": 453191,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 2019559783, "versionNonce": 401146281,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -4399,7 +4456,6 @@ Object {
"selectedElementIds": Object { "selectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
"id2": true,
}, },
"selectedGroupIds": Object { "selectedGroupIds": Object {
"id3": true, "id3": true,
@@ -4426,14 +4482,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 449462985, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 1116226695, "versionNonce": 1150084233,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -4457,14 +4513,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 401146281, "seed": 453191,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 1014066025, "versionNonce": 1116226695,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -4479,7 +4535,6 @@ Object {
"selectedElementIds": Object { "selectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
"id2": true,
}, },
"selectedGroupIds": Object {}, "selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff", "viewBackgroundColor": "#ffffff",
@@ -4502,14 +4557,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 449462985, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 238820263, "versionNonce": 1014066025,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -4531,14 +4586,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 401146281, "seed": 453191,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 400692809, "versionNonce": 238820263,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -4867,6 +4922,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -4892,7 +4953,6 @@ Object {
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id2": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@@ -4901,15 +4961,12 @@ Object {
"selectedElementIds": Object { "selectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
"id2": true,
"id3": true,
}, },
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": Object {}, "selectedGroupIds": Object {},
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -5444,6 +5501,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -5469,7 +5532,6 @@ Object {
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id2": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@@ -5478,8 +5540,6 @@ Object {
"selectedElementIds": Object { "selectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
"id2": true,
"id3": true,
}, },
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": Object { "selectedGroupIds": Object {
@@ -5488,7 +5548,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -5526,14 +5585,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 449462985, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 1014066025, "versionNonce": 1116226695,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -5560,14 +5619,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 401146281, "seed": 453191,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 238820263, "versionNonce": 1014066025,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": 0,
@@ -5619,14 +5678,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 449462985, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 453191, "versionNonce": 449462985,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -5662,14 +5721,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 449462985, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 453191, "versionNonce": 449462985,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -5691,14 +5750,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 401146281, "seed": 453191,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 2019559783, "versionNonce": 401146281,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": 0,
@@ -5713,8 +5772,6 @@ Object {
"selectedElementIds": Object { "selectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
"id2": true,
"id3": true,
}, },
"selectedGroupIds": Object { "selectedGroupIds": Object {
"id4": true, "id4": true,
@@ -5741,14 +5798,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 449462985, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 1014066025, "versionNonce": 1116226695,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -5772,14 +5829,14 @@ Object {
"roundness": Object { "roundness": Object {
"type": 3, "type": 3,
}, },
"seed": 401146281, "seed": 453191,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 238820263, "versionNonce": 1014066025,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": 0,
@@ -5947,6 +6004,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -5981,7 +6044,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -6337,6 +6399,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -6373,7 +6441,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
@@ -6705,6 +6772,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
@@ -6741,7 +6814,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
File diff suppressed because it is too large Load Diff
+32 -8
View File
@@ -430,7 +430,10 @@ describe("arrow", () => {
const expectedAngle = (7 * Math.PI) / 4; const expectedAngle = (7 * Math.PI) / 4;
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow"); const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
h.app.scene.replaceAllElements([line]); h.app.scene.replaceAllElements([line]);
h.app.state.selectedElementIds[line.id] = true; h.state.selectedElementIds = {
...h.state.selectedElementIds,
[line.id]: true,
};
mutateElement(line, { mutateElement(line, {
angle: originalAngle, angle: originalAngle,
}); });
@@ -446,7 +449,10 @@ describe("arrow", () => {
const expectedAngle = (7 * Math.PI) / 4; const expectedAngle = (7 * Math.PI) / 4;
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow"); const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
h.app.scene.replaceAllElements([line]); h.app.scene.replaceAllElements([line]);
h.app.state.selectedElementIds[line.id] = true; h.state.selectedElementIds = {
...h.state.selectedElementIds,
[line.id]: true,
};
mutateElement(line, { mutateElement(line, {
angle: originalAngle, angle: originalAngle,
}); });
@@ -616,7 +622,10 @@ describe("line", () => {
const expectedAngle = (7 * Math.PI) / 4; const expectedAngle = (7 * Math.PI) / 4;
const line = createLinearElementWithCurveInsideMinMaxPoints("line"); const line = createLinearElementWithCurveInsideMinMaxPoints("line");
h.app.scene.replaceAllElements([line]); h.app.scene.replaceAllElements([line]);
h.app.state.selectedElementIds[line.id] = true; h.state.selectedElementIds = {
...h.state.selectedElementIds,
[line.id]: true,
};
mutateElement(line, { mutateElement(line, {
angle: originalAngle, angle: originalAngle,
}); });
@@ -632,7 +641,10 @@ describe("line", () => {
const expectedAngle = (7 * Math.PI) / 4; const expectedAngle = (7 * Math.PI) / 4;
const line = createLinearElementWithCurveInsideMinMaxPoints("line"); const line = createLinearElementWithCurveInsideMinMaxPoints("line");
h.app.scene.replaceAllElements([line]); h.app.scene.replaceAllElements([line]);
h.app.state.selectedElementIds[line.id] = true; h.state.selectedElementIds = {
...h.state.selectedElementIds,
[line.id]: true,
};
mutateElement(line, { mutateElement(line, {
angle: originalAngle, angle: originalAngle,
}); });
@@ -659,14 +671,20 @@ describe("freedraw", () => {
it("flips an unrotated drawing horizontally correctly", async () => { it("flips an unrotated drawing horizontally correctly", async () => {
const draw = createAndReturnOneDraw(); const draw = createAndReturnOneDraw();
// select draw, since not done automatically // select draw, since not done automatically
h.state.selectedElementIds[draw.id] = true; h.state.selectedElementIds = {
...h.state.selectedElementIds,
[draw.id]: true,
};
await checkHorizontalFlip(); await checkHorizontalFlip();
}); });
it("flips an unrotated drawing vertically correctly", async () => { it("flips an unrotated drawing vertically correctly", async () => {
const draw = createAndReturnOneDraw(); const draw = createAndReturnOneDraw();
// select draw, since not done automatically // select draw, since not done automatically
h.state.selectedElementIds[draw.id] = true; h.state.selectedElementIds = {
...h.state.selectedElementIds,
[draw.id]: true,
};
await checkVerticalFlip(); await checkVerticalFlip();
}); });
@@ -676,7 +694,10 @@ describe("freedraw", () => {
const draw = createAndReturnOneDraw(originalAngle); const draw = createAndReturnOneDraw(originalAngle);
// select draw, since not done automatically // select draw, since not done automatically
h.state.selectedElementIds[draw.id] = true; h.state.selectedElementIds = {
...h.state.selectedElementIds,
[draw.id]: true,
};
await checkRotatedHorizontalFlip(expectedAngle); await checkRotatedHorizontalFlip(expectedAngle);
}); });
@@ -687,7 +708,10 @@ describe("freedraw", () => {
const draw = createAndReturnOneDraw(originalAngle); const draw = createAndReturnOneDraw(originalAngle);
// select draw, since not done automatically // select draw, since not done automatically
h.state.selectedElementIds[draw.id] = true; h.state.selectedElementIds = {
...h.state.selectedElementIds,
[draw.id]: true,
};
await checkRotatedVerticalFlip(expectedAngle); await checkRotatedVerticalFlip(expectedAngle);
}); });
@@ -39,6 +39,12 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameRendering": Object {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridSize": null,
"isBindingEnabled": true, "isBindingEnabled": true,
@@ -70,7 +76,6 @@ Object {
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": false, "showWelcomeScreen": false,
+2
View File
@@ -89,6 +89,8 @@ const populateElements = (
...selectGroupsForSelectedElements( ...selectGroupsForSelectedElements(
{ ...h.state, ...appState, selectedElementIds }, { ...h.state, ...appState, selectedElementIds },
h.elements, h.elements,
h.state,
null,
), ),
...appState, ...appState,
selectedElementIds, selectedElementIds,
+9 -4
View File
@@ -115,7 +115,12 @@ export type AppState = {
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null; startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
suggestedBindings: SuggestedBinding[]; suggestedBindings: SuggestedBinding[];
frameToHighlight: NonDeleted<ExcalidrawFrameElement> | null; frameToHighlight: NonDeleted<ExcalidrawFrameElement> | null;
shouldRenderFrames: boolean; frameRendering: {
enabled: boolean;
name: boolean;
outline: boolean;
clip: boolean;
};
editingFrame: string | null; editingFrame: string | null;
elementsToHighlight: NonDeleted<ExcalidrawElement>[] | null; elementsToHighlight: NonDeleted<ExcalidrawElement>[] | null;
// element being edited, but not necessarily added to elements array yet // element being edited, but not necessarily added to elements array yet
@@ -181,8 +186,8 @@ export type AppState = {
defaultSidebarDockedPreference: boolean; defaultSidebarDockedPreference: boolean;
lastPointerDownWith: PointerType; lastPointerDownWith: PointerType;
selectedElementIds: { [id: string]: boolean }; selectedElementIds: Readonly<{ [id: string]: true }>;
previousSelectedElementIds: { [id: string]: boolean }; previousSelectedElementIds: { [id: string]: true };
selectedElementsAreBeingDragged: boolean; selectedElementsAreBeingDragged: boolean;
shouldCacheIgnoreZoom: boolean; shouldCacheIgnoreZoom: boolean;
toast: { message: string; closable?: boolean; duration?: number } | null; toast: { message: string; closable?: boolean; duration?: number } | null;
@@ -543,7 +548,7 @@ export type ExcalidrawImperativeAPI = {
* the frames are still interactive in edit mode. As such, this API should be * the frames are still interactive in edit mode. As such, this API should be
* used in conjunction with view mode (props.viewModeEnabled). * used in conjunction with view mode (props.viewModeEnabled).
*/ */
toggleFrameRendering: InstanceType<typeof App>["toggleFrameRendering"]; updateFrameRendering: InstanceType<typeof App>["updateFrameRendering"];
}; };
export type Device = Readonly<{ export type Device = Readonly<{
+7
View File
@@ -47,3 +47,10 @@ export type ForwardRef<T, P = any> = Parameters<
export type ExtractSetType<T extends Set<any>> = T extends Set<infer U> export type ExtractSetType<T extends Set<any>> = T extends Set<infer U>
? U ? U
: never; : never;
export type SameType<T, U> = T extends U ? (U extends T ? true : false) : false;
export type Assert<T extends true> = T;
export type NestedKeyOf<T, K = keyof T> = K extends keyof T & (string | number)
? `${K}` | (T[K] extends object ? `${K}.${NestedKeyOf<T[K]>}` : never)
: never;
+11 -1
View File
@@ -10267,7 +10267,7 @@ terser-webpack-plugin@^5.1.3, terser-webpack-plugin@^5.2.5:
serialize-javascript "^6.0.1" serialize-javascript "^6.0.1"
terser "^5.16.5" terser "^5.16.5"
terser@^5.0.0, terser@^5.10.0, terser@^5.16.5: terser@^5.0.0, terser@^5.10.0:
version "5.16.9" version "5.16.9"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.16.9.tgz#7a28cb178e330c484369886f2afd623d9847495f" resolved "https://registry.yarnpkg.com/terser/-/terser-5.16.9.tgz#7a28cb178e330c484369886f2afd623d9847495f"
integrity sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg== integrity sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg==
@@ -10277,6 +10277,16 @@ terser@^5.0.0, terser@^5.10.0, terser@^5.16.5:
commander "^2.20.0" commander "^2.20.0"
source-map-support "~0.5.20" source-map-support "~0.5.20"
terser@^5.16.5:
version "5.17.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.17.1.tgz#948f10830454761e2eeedc6debe45c532c83fd69"
integrity sha512-hVl35zClmpisy6oaoKALOpS0rDYLxRFLHhRuDlEGTKey9qHjS1w9GMORjuwIMt70Wan4lwsLYyWDVnWgF+KUEw==
dependencies:
"@jridgewell/source-map" "^0.3.2"
acorn "^8.5.0"
commander "^2.20.0"
source-map-support "~0.5.20"
test-exclude@^6.0.0: test-exclude@^6.0.0:
version "6.0.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e"