Moved selection in element, refactor common

This commit is contained in:
Marcel Mraz 2025-03-19 15:26:05 +01:00
parent e7a0a7e0b7
commit dfd48c221c
No known key found for this signature in database
GPG key ID: 4EBD6E62DC830CD2
19 changed files with 67 additions and 110 deletions

View file

@ -12,7 +12,9 @@ import type {
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import { InvalidFractionalIndexError } from "../../excalidraw/errors";
export class InvalidFractionalIndexError extends Error {
public code = "ELEMENT_HAS_INVALID_INDEX" as const;
}
/**
* Envisioned relation between array order and fractional indices:

View file

@ -5,11 +5,6 @@ import {
elementsOverlappingBBox,
} from "@excalidraw/utils";
import {
getElementsWithinSelection,
getSelectedElements,
} from "@excalidraw/excalidraw/scene";
import { getElementAbsoluteCoords, isTextElement } from "@excalidraw/element";
import type {
@ -31,6 +26,7 @@ import type {
import type { ReadonlySetLike } from "@excalidraw/common/utility-types";
import { getElementsWithinSelection, getSelectedElements } from "./selection";
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
import { getElementLineSegments, getCommonBounds } from "./bounds";

View file

@ -1,8 +1,5 @@
import { getBoundTextElement } from "@excalidraw/element/textElement";
import { getSelectedElements } from "@excalidraw/excalidraw/scene";
import { makeNextSelectedElementIds } from "@excalidraw/excalidraw/scene/selection";
import type {
GroupId,
ExcalidrawElement,
@ -19,6 +16,8 @@ import type {
} from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
export const selectGroup = (
groupId: GroupId,
appState: InteractiveCanvasAppState,

View file

@ -11,7 +11,6 @@ import {
import {
MIN_FONT_SIZE,
SHIFT_LOCKING_ANGLE,
isInGroup,
rescalePoints,
getFontString,
} from "@excalidraw/common";
@ -59,6 +58,8 @@ import {
isTextElement,
} from "./typeChecks";
import { isInGroup } from "./groups";
import type { BoundingBox } from "./bounds";
import type {
MaybeTransformHandleType,

View file

@ -0,0 +1,256 @@
import { isShallowEqual } from "@excalidraw/common";
import type {
ElementsMap,
ElementsMapOrArray,
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import type {
AppState,
InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { isElementInViewport } from "./sizeHelpers";
import { isBoundToContainer, isFrameLikeElement } from "./typeChecks";
import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameChildren,
} from "./frame";
/**
* Frames and their containing elements are not to be selected at the same time.
* Given an array of selected elements, if there are frames and their containing elements
* we only keep the frames.
* @param selectedElements
*/
export const excludeElementsInFramesFromSelection = <
T extends ExcalidrawElement,
>(
selectedElements: readonly T[],
) => {
const framesInSelection = new Set<T["id"]>();
selectedElements.forEach((element) => {
if (isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
});
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
};
export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
excludeElementsInFrames: boolean = true,
) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(selection, elementsMap);
let elementsInSelection = elements.filter((element) => {
let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
element,
elementsMap,
);
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
const [fx1, fy1, fx2, fy2] = getElementBounds(
containingFrame,
elementsMap,
);
elementX1 = Math.max(fx1, elementX1);
elementY1 = Math.max(fy1, elementY1);
elementX2 = Math.min(fx2, elementX2);
elementY2 = Math.min(fy2, elementY2);
}
return (
element.locked === false &&
element.type !== "selection" &&
!isBoundToContainer(element) &&
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2
);
});
elementsInSelection = excludeElementsInFrames
? excludeElementsInFramesFromSelection(elementsInSelection)
: elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
}
return true;
});
return elementsInSelection;
};
export const getVisibleAndNonSelectedElements = (
elements: readonly NonDeletedExcalidrawElement[],
selectedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
elementsMap: ElementsMap,
) => {
const selectedElementsSet = new Set(
selectedElements.map((element) => element.id),
);
return elements.filter((element) => {
const isVisible = isElementInViewport(
element,
appState.width,
appState.height,
appState,
elementsMap,
);
return !selectedElementsSet.has(element.id) && isVisible;
});
};
// FIXME move this into the editor instance to keep utility methods stateless
export const isSomeElementSelected = (function () {
let lastElements: readonly NonDeletedExcalidrawElement[] | null = null;
let lastSelectedElementIds: AppState["selectedElementIds"] | null = null;
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
* elements. If elements don't share the same value, returns `null`.
*/
export const getCommonAttributeOfSelectedElements = <T>(
elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">,
getAttribute: (element: ExcalidrawElement) => T,
): T | null => {
const attributes = Array.from(
new Set(
getSelectedElements(elements, appState).map((element) =>
getAttribute(element),
),
),
);
return attributes.length === 1 ? attributes[0] : null;
};
export const getSelectedElements = (
elements: ElementsMapOrArray,
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
opts?: {
includeBoundTextElement?: boolean;
includeElementsInFrames?: boolean;
},
) => {
const addedElements = new Set<ExcalidrawElement["id"]>();
const selectedElements: ExcalidrawElement[] = [];
for (const element of elements.values()) {
if (appState.selectedElementIds[element.id]) {
selectedElements.push(element);
addedElements.add(element.id);
continue;
}
if (
opts?.includeBoundTextElement &&
isBoundToContainer(element) &&
appState.selectedElementIds[element?.containerId]
) {
selectedElements.push(element);
addedElements.add(element.id);
continue;
}
}
if (opts?.includeElementsInFrames) {
const elementsToInclude: ExcalidrawElement[] = [];
selectedElements.forEach((element) => {
if (isFrameLikeElement(element)) {
getFrameChildren(elements, element.id).forEach(
(e) => !addedElements.has(e.id) && elementsToInclude.push(e),
);
}
elementsToInclude.push(element);
});
return elementsToInclude;
}
return selectedElements;
};
export const getTargetElements = (
elements: ElementsMapOrArray,
appState: Pick<
AppState,
"selectedElementIds" | "editingTextElement" | "newElement"
>,
) =>
appState.editingTextElement
? [appState.editingTextElement]
: appState.newElement
? [appState.newElement]
: getSelectedElements(elements, appState, {
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;
};

View file

@ -1,7 +1,7 @@
import { getSelectedElements } from "@excalidraw/excalidraw/scene";
import type { UIAppState } from "@excalidraw/excalidraw/types";
import { getSelectedElements } from "./selection";
import type { NonDeletedExcalidrawElement } from "./types";
export const showSelectedShapeActions = (

View file

@ -0,0 +1,35 @@
import { makeNextSelectedElementIds } from "../src/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,
);
});
});