aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/scene/selection.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/scene/selection.ts')
-rw-r--r--packages/excalidraw/scene/selection.ts250
1 files changed, 250 insertions, 0 deletions
diff --git a/packages/excalidraw/scene/selection.ts b/packages/excalidraw/scene/selection.ts
new file mode 100644
index 0000000..3ca91cd
--- /dev/null
+++ b/packages/excalidraw/scene/selection.ts
@@ -0,0 +1,250 @@
+import type {
+ ElementsMap,
+ ElementsMapOrArray,
+ ExcalidrawElement,
+ NonDeletedExcalidrawElement,
+} from "../element/types";
+import { getElementAbsoluteCoords, getElementBounds } from "../element";
+import type { AppState, InteractiveCanvasAppState } from "../types";
+import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
+import {
+ elementOverlapsWithFrame,
+ getContainingFrame,
+ getFrameChildren,
+} from "../frame";
+import { isShallowEqual } from "../utils";
+import { isElementInViewport } from "../element/sizeHelpers";
+
+/**
+ * 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;
+};