feat: make appState.selectedElementIds more stable (#6745)

This commit is contained in:
David Luzar 2023-07-08 23:33:34 +02:00 committed by GitHub
parent 3ddcc48e4c
commit 49e4289878
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 503 additions and 295 deletions

View file

@ -315,7 +315,10 @@ import {
updateFrameMembershipOfSelectedElements,
isElementInFrame,
} from "../frame";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import {
excludeElementsInFramesFromSelection,
makeNextSelectedElementIds,
} from "../scene/selection";
import { actionPaste } from "../actions/actionClipboard";
import {
actionRemoveAllElementsFromFrame,
@ -1353,6 +1356,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.destroy();
this.library.destroy();
clearTimeout(touchTimeout);
isSomeElementSelected.clearCache();
touchTimeout = 0;
}
@ -1825,7 +1829,7 @@ class App extends React.Component<AppProps, AppState> {
if (event.touches.length === 2) {
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
});
}
};
@ -1835,7 +1839,10 @@ class App extends React.Component<AppProps, AppState> {
if (event.touches.length > 0) {
this.setState({
previousSelectedElementIds: {},
selectedElementIds: this.state.previousSelectedElementIds,
selectedElementIds: makeNextSelectedElementIds(
this.state.previousSelectedElementIds,
this.state,
),
});
} else {
gesture.pointers.clear();
@ -1895,7 +1902,14 @@ class App extends React.Component<AppProps, AppState> {
const imageElement = this.createImageElement({ sceneX, sceneY });
this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement);
this.setState({ selectedElementIds: { [imageElement.id]: true } });
this.setState({
selectedElementIds: makeNextSelectedElementIds(
{
[imageElement.id]: true,
},
this.state,
),
});
return;
}
@ -2032,6 +2046,7 @@ class App extends React.Component<AppProps, AppState> {
selectedGroupIds: {},
},
this.scene.getNonDeletedElements(),
this.state,
),
() => {
if (opts.files) {
@ -2130,8 +2145,9 @@ class App extends React.Component<AppProps, AppState> {
}
this.setState({
selectedElementIds: Object.fromEntries(
textElements.map((el) => [el.id, true]),
selectedElementIds: makeNextSelectedElementIds(
Object.fromEntries(textElements.map((el) => [el.id, true])),
this.state,
),
});
@ -2749,7 +2765,7 @@ class App extends React.Component<AppProps, AppState> {
} else {
setCursorForShape(this.canvas, this.state);
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
});
@ -2794,7 +2810,7 @@ class App extends React.Component<AppProps, AppState> {
if (nextActiveTool.type !== "selection") {
this.setState({
activeTool: nextActiveTool,
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
});
@ -2831,7 +2847,7 @@ class App extends React.Component<AppProps, AppState> {
// elements by mistake while zooming
if (this.isTouchScreenMultiTouchGesture()) {
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
});
}
gesture.initialScale = this.state.zoom.value;
@ -2876,7 +2892,10 @@ class App extends React.Component<AppProps, AppState> {
if (this.isTouchScreenMultiTouchGesture()) {
this.setState({
previousSelectedElementIds: {},
selectedElementIds: this.state.previousSelectedElementIds,
selectedElementIds: makeNextSelectedElementIds(
this.state.previousSelectedElementIds,
this.state,
),
});
}
gesture.initialScale = null;
@ -2941,10 +2960,13 @@ class App extends React.Component<AppProps, AppState> {
? element.containerId
: element.id;
this.setState((prevState) => ({
selectedElementIds: {
...prevState.selectedElementIds,
[elementIdToSelect]: true,
},
selectedElementIds: makeNextSelectedElementIds(
{
...prevState.selectedElementIds,
[elementIdToSelect]: true,
},
prevState,
),
}));
}
if (isDeleted) {
@ -2980,7 +3002,7 @@ class App extends React.Component<AppProps, AppState> {
private deselectElements() {
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
});
@ -3291,6 +3313,7 @@ class App extends React.Component<AppProps, AppState> {
selectedGroupIds: {},
},
this.scene.getNonDeletedElements(),
prevState,
),
);
return;
@ -3998,12 +4021,15 @@ class App extends React.Component<AppProps, AppState> {
editingElement: null,
startBoundElement: null,
suggestedBindings: [],
selectedElementIds: Object.keys(this.state.selectedElementIds)
.filter((key) => key !== element.id)
.reduce((obj: { [id: string]: boolean }, key) => {
obj[key] = this.state.selectedElementIds[key];
return obj;
}, {}),
selectedElementIds: makeNextSelectedElementIds(
Object.keys(this.state.selectedElementIds)
.filter((key) => key !== element.id)
.reduce((obj: { [id: string]: true }, key) => {
obj[key] = this.state.selectedElementIds[key];
return obj;
}, {}),
this.state,
),
},
});
return;
@ -4472,7 +4498,7 @@ class App extends React.Component<AppProps, AppState> {
private clearSelectionIfNotUsingSelection = (): void => {
if (this.state.activeTool.type !== "selection") {
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
});
@ -4604,9 +4630,12 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.editingLinearElement) {
this.setState({
selectedElementIds: {
[this.state.editingLinearElement.elementId]: true,
},
selectedElementIds: makeNextSelectedElementIds(
{
[this.state.editingLinearElement.elementId]: true,
},
this.state,
),
});
// If we click on something
} else if (hitElement != null) {
@ -4634,7 +4663,7 @@ class App extends React.Component<AppProps, AppState> {
!isElementInGroup(hitElement, this.state.editingGroupId)
) {
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
});
@ -4650,7 +4679,7 @@ class App extends React.Component<AppProps, AppState> {
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
) {
this.setState((prevState) => {
const nextSelectedElementIds = {
const nextSelectedElementIds: { [id: string]: true } = {
...prevState.selectedElementIds,
[hitElement.id]: true,
};
@ -4668,13 +4697,13 @@ class App extends React.Component<AppProps, AppState> {
previouslySelectedElements,
hitElement.id,
).forEach((element) => {
nextSelectedElementIds[element.id] = false;
delete nextSelectedElementIds[element.id];
});
} else if (hitElement.frameId) {
// if hitElement is in a frame and its frame has been selected
// disable selection for the given element
if (nextSelectedElementIds[hitElement.frameId]) {
nextSelectedElementIds[hitElement.id] = false;
delete nextSelectedElementIds[hitElement.id];
}
} else {
// hitElement is neither a frame nor an element in a frame
@ -4704,7 +4733,7 @@ class App extends React.Component<AppProps, AppState> {
framesInGroups.has(element.frameId)
) {
// deselect element and groups containing the element
nextSelectedElementIds[element.id] = false;
delete nextSelectedElementIds[element.id];
element.groupIds
.flatMap((gid) =>
getElementsInGroup(
@ -4712,10 +4741,9 @@ class App extends React.Component<AppProps, AppState> {
gid,
),
)
.forEach(
(element) =>
(nextSelectedElementIds[element.id] = false),
);
.forEach((element) => {
delete nextSelectedElementIds[element.id];
});
}
});
}
@ -4728,6 +4756,7 @@ class App extends React.Component<AppProps, AppState> {
showHyperlinkPopup: hitElement.link ? "info" : false,
},
this.scene.getNonDeletedElements(),
prevState,
);
});
pointerDownState.hit.wasAddedToSelection = true;
@ -4844,12 +4873,18 @@ class App extends React.Component<AppProps, AppState> {
frameId: topLayerFrame ? topLayerFrame.id : null,
});
this.setState((prevState) => ({
selectedElementIds: {
this.setState((prevState) => {
const nextSelectedElementIds = {
...prevState.selectedElementIds,
[element.id]: false,
},
}));
};
delete nextSelectedElementIds[element.id];
return {
selectedElementIds: makeNextSelectedElementIds(
nextSelectedElementIds,
prevState,
),
};
});
const pressures = element.simulatePressure
? element.pressures
@ -4945,10 +4980,13 @@ class App extends React.Component<AppProps, AppState> {
}
this.setState((prevState) => ({
selectedElementIds: {
...prevState.selectedElementIds,
[multiElement.id]: true,
},
selectedElementIds: makeNextSelectedElementIds(
{
...prevState.selectedElementIds,
[multiElement.id]: true,
},
prevState,
),
}));
// clicking outside commit zone → update reference for last committed
// point
@ -4999,12 +5037,18 @@ class App extends React.Component<AppProps, AppState> {
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
this.setState((prevState) => ({
selectedElementIds: {
this.setState((prevState) => {
const nextSelectedElementIds = {
...prevState.selectedElementIds,
[element.id]: false,
},
}));
};
delete nextSelectedElementIds[element.id];
return {
selectedElementIds: makeNextSelectedElementIds(
nextSelectedElementIds,
prevState,
),
};
});
mutateElement(element, {
points: [...element.points, [0, 0]],
});
@ -5378,15 +5422,16 @@ class App extends React.Component<AppProps, AppState> {
const oldIdToDuplicatedId = new Map();
const hitElement = pointerDownState.hit.element;
const elements = this.scene.getElementsIncludingDeleted();
const selectedElementIds: Array<ExcalidrawElement["id"]> =
const selectedElementIds = new Set(
getSelectedElements(elements, this.state, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}).map((element) => element.id);
}).map((element) => element.id),
);
for (const element of elements) {
if (
selectedElementIds.includes(element.id) ||
selectedElementIds.has(element.id) ||
// case: the state.selectedElementIds might not have been
// updated yet by the time this mousemove event is fired
(element.id === hitElement?.id &&
@ -5524,14 +5569,9 @@ class App extends React.Component<AppProps, AppState> {
},
},
this.scene.getNonDeletedElements(),
prevState,
),
);
} else {
this.setState({
selectedElementIds: {},
selectedGroupIds: {},
editingGroupId: null,
});
}
}
// box-select line editor points
@ -5547,28 +5587,29 @@ class App extends React.Component<AppProps, AppState> {
elements,
draggingElement,
);
this.setState((prevState) =>
selectGroupsForSelectedElements(
this.setState((prevState) => {
const nextSelectedElementIds = elementsWithinSelection.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
acc[element.id] = true;
return acc;
},
{},
);
if (pointerDownState.hit.element) {
// if using ctrl/cmd, select the hitElement only if we
// haven't box-selected anything else
if (!elementsWithinSelection.length) {
nextSelectedElementIds[pointerDownState.hit.element.id] = true;
} else {
delete nextSelectedElementIds[pointerDownState.hit.element.id];
}
}
return selectGroupsForSelectedElements(
{
...prevState,
selectedElementIds: {
...prevState.selectedElementIds,
...elementsWithinSelection.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
acc[element.id] = true;
return acc;
},
{},
),
...(pointerDownState.hit.element
? {
// if using ctrl/cmd, select the hitElement only if we
// haven't box-selected anything else
[pointerDownState.hit.element.id]:
!elementsWithinSelection.length,
}
: null),
},
selectedElementIds: nextSelectedElementIds,
showHyperlinkPopup:
elementsWithinSelection.length === 1 &&
elementsWithinSelection[0].link
@ -5585,8 +5626,9 @@ class App extends React.Component<AppProps, AppState> {
: null,
},
this.scene.getNonDeletedElements(),
),
);
prevState,
);
});
}
}
});
@ -5780,7 +5822,12 @@ class App extends React.Component<AppProps, AppState> {
try {
this.initializeImageDimensions(imageElement);
this.setState(
{ selectedElementIds: { [imageElement.id]: true } },
{
selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true },
this.state,
),
},
() => {
this.actionManager.executeAction(actionFinalize);
},
@ -5844,10 +5891,13 @@ class App extends React.Component<AppProps, AppState> {
activeTool: updateActiveTool(this.state, {
type: "selection",
}),
selectedElementIds: {
...prevState.selectedElementIds,
[draggingElement.id]: true,
},
selectedElementIds: makeNextSelectedElementIds(
{
...prevState.selectedElementIds,
[draggingElement.id]: true,
},
prevState,
),
selectedLinearElement: new LinearElementEditor(
draggingElement,
this.scene,
@ -6141,31 +6191,37 @@ class App extends React.Component<AppProps, AppState> {
if (childEvent.shiftKey && !this.state.editingLinearElement) {
if (this.state.selectedElementIds[hitElement.id]) {
if (isSelectedViaGroup(this.state, hitElement)) {
// We want to unselect all groups hitElement is part of
// as well as all elements that are part of the groups
// hitElement is part of
const idsOfSelectedElementsThatAreInGroups = hitElement.groupIds
.flatMap((groupId) =>
getElementsInGroup(
this.scene.getNonDeletedElements(),
groupId,
),
)
.map((element) => ({ [element.id]: false }))
.reduce((prevId, acc) => ({ ...prevId, ...acc }), {});
this.setState((_prevState) => {
const nextSelectedElementIds = {
..._prevState.selectedElementIds,
};
this.setState((_prevState) => ({
selectedGroupIds: {
..._prevState.selectedElementIds,
...hitElement.groupIds
.map((gId) => ({ [gId]: false }))
.reduce((prev, acc) => ({ ...prev, ...acc }), {}),
},
selectedElementIds: {
..._prevState.selectedElementIds,
...idsOfSelectedElementsThatAreInGroups,
},
}));
// We want to unselect all groups hitElement is part of
// as well as all elements that are part of the groups
// hitElement is part of
for (const groupedElement of hitElement.groupIds.flatMap(
(groupId) =>
getElementsInGroup(
this.scene.getNonDeletedElements(),
groupId,
),
)) {
delete nextSelectedElementIds[groupedElement.id];
}
return {
selectedGroupIds: {
..._prevState.selectedElementIds,
...hitElement.groupIds
.map((gId) => ({ [gId]: false }))
.reduce((prev, acc) => ({ ...prev, ...acc }), {}),
},
selectedElementIds: makeNextSelectedElementIds(
nextSelectedElementIds,
_prevState,
),
};
});
// if not gragging a linear element point (outside editor)
} else if (!this.state.selectedLinearElement?.isDragging) {
// remove element from selection while
@ -6174,8 +6230,8 @@ class App extends React.Component<AppProps, AppState> {
this.setState((prevState) => {
const newSelectedElementIds = {
...prevState.selectedElementIds,
[hitElement!.id]: false,
};
delete newSelectedElementIds[hitElement!.id];
const newSelectedElements = getSelectedElements(
this.scene.getNonDeletedElements(),
{ ...prevState, selectedElementIds: newSelectedElementIds },
@ -6196,6 +6252,7 @@ class App extends React.Component<AppProps, AppState> {
: prevState.selectedLinearElement,
},
this.scene.getNonDeletedElements(),
prevState,
);
});
}
@ -6206,21 +6263,23 @@ class App extends React.Component<AppProps, AppState> {
// when hitElement is part of a selected frame, deselect the frame
// to avoid frame and containing elements selected simultaneously
this.setState((prevState) => {
const nextSelectedElementIds = {
const nextSelectedElementIds: {
[id: string]: true;
} = {
...prevState.selectedElementIds,
[hitElement.id]: true,
// deselect the frame
[hitElement.frameId!]: false,
};
// deselect the frame
delete nextSelectedElementIds[hitElement.frameId!];
// deselect groups containing the frame
(this.scene.getElement(hitElement.frameId!)?.groupIds ?? [])
.flatMap((gid) =>
getElementsInGroup(this.scene.getNonDeletedElements(), gid),
)
.forEach(
(element) => (nextSelectedElementIds[element.id] = false),
);
.forEach((element) => {
delete nextSelectedElementIds[element.id];
});
return selectGroupsForSelectedElements(
{
@ -6229,15 +6288,19 @@ class App extends React.Component<AppProps, AppState> {
showHyperlinkPopup: hitElement.link ? "info" : false,
},
this.scene.getNonDeletedElements(),
prevState,
);
});
} else {
// add element to selection while keeping prev elements selected
this.setState((_prevState) => ({
selectedElementIds: {
..._prevState.selectedElementIds,
[hitElement!.id]: true,
},
selectedElementIds: makeNextSelectedElementIds(
{
..._prevState.selectedElementIds,
[hitElement!.id]: true,
},
_prevState,
),
}));
}
} else {
@ -6255,6 +6318,7 @@ class App extends React.Component<AppProps, AppState> {
: prevState.selectedLinearElement,
},
this.scene.getNonDeletedElements(),
prevState,
),
}));
}
@ -6279,7 +6343,7 @@ class App extends React.Component<AppProps, AppState> {
} else {
// Deselect selected elements
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
});
@ -6290,13 +6354,17 @@ class App extends React.Component<AppProps, AppState> {
if (
!activeTool.locked &&
activeTool.type !== "freedraw" &&
draggingElement
draggingElement &&
draggingElement.type !== "selection"
) {
this.setState((prevState) => ({
selectedElementIds: {
...prevState.selectedElementIds,
[draggingElement.id]: true,
},
selectedElementIds: makeNextSelectedElementIds(
{
...prevState.selectedElementIds,
[draggingElement.id]: true,
},
prevState,
),
}));
}
@ -6610,7 +6678,10 @@ class App extends React.Component<AppProps, AppState> {
this.initializeImageDimensions(imageElement);
this.setState(
{
selectedElementIds: { [imageElement.id]: true },
selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true },
this.state,
),
},
() => {
this.actionManager.executeAction(actionFinalize);
@ -6837,7 +6908,7 @@ class App extends React.Component<AppProps, AppState> {
private clearSelection(hitElement: ExcalidrawElement | null): void {
this.setState((prevState) => ({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, prevState),
selectedGroupIds: {},
// Continue editing the same group if the user selected a different
// element from it
@ -6849,7 +6920,7 @@ class App extends React.Component<AppProps, AppState> {
: null,
}));
this.setState({
selectedElementIds: {},
selectedElementIds: makeNextSelectedElementIds({}, this.state),
previousSelectedElementIds: this.state.selectedElementIds,
});
}
@ -6918,7 +6989,12 @@ class App extends React.Component<AppProps, AppState> {
const imageElement = this.createImageElement({ sceneX, sceneY });
this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement);
this.setState({ selectedElementIds: { [imageElement.id]: true } });
this.setState({
selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true },
this.state,
),
});
return;
}
@ -7043,6 +7119,7 @@ class App extends React.Component<AppProps, AppState> {
: null,
},
this.scene.getNonDeletedElements(),
this.state,
)
: this.state),
showHyperlinkPopup: false,