diff options
Diffstat (limited to 'packages/excalidraw/scene/selection.ts')
| -rw-r--r-- | packages/excalidraw/scene/selection.ts | 250 |
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; +}; |
