import type { Box, Point, Polygon } from '@flatten-js/core'; import { compact } from 'es-toolkit'; import { toast } from 'react-toastify'; import { EPSILON, SELECTION_RECTANGLE_COLOR_CONTAINS, SELECTION_RECTANGLE_COLOR_INTERSECTION, SELECTION_RECTANGLE_STYLE, SELECTION_RECTANGLE_WIDTH, } from '../App.consts'; import type { Entity } from '../entities/Entity'; import { RectangleEntity } from '../entities/RectangleEntity'; import { pointDistance } from '../helpers/distance-between-points'; import { expandSelectionWithGroups } from '../helpers/entity-groups'; import { findEntitiesWithinDistance } from '../helpers/find-closest-entity'; import { findGripAt, removePolylineVertex } from '../helpers/grips'; import { pickRadius } from '../helpers/pick-radius'; import { getActiveLayerId, getEntities, getPickableEntities, getLayers, getSelectedEntities, getSelectedEntityIds, isEntitySelected, setEntities, setGhostHelperEntities, setSelectedEntityIds, } from '../state'; import { startGripEdit } from './grip-edit-tool'; import type { SelectContext } from './select-tool'; import type { MouseClickEvent } from './tool.types'; /** 선택 순환 — 같은 자리를 다시 클릭하면 겹친 후보를 차례로 돌린다 */ let lastPickPoint: Point | null = null; let pickCycleIndex = 0; function pickEntityAt(worldPoint: Point): Entity | null { const radius = pickRadius(); const candidates = findEntitiesWithinDistance(worldPoint, getPickableEntities(), radius); if (!candidates.length) { lastPickPoint = null; return null; } const samePlace = !!lastPickPoint && pointDistance(lastPickPoint, worldPoint) < radius; pickCycleIndex = samePlace ? (pickCycleIndex + 1) % candidates.length : 0; lastPickPoint = worldPoint; return candidates[pickCycleIndex]; } export function handleFirstSelectionPoint( context: SelectContext, event: MouseClickEvent ): SelectContext { // 선택된 객체의 그립이 먼저다 — 집으면 그립 편집으로 넘어간다 const gripHit = findGripAt(getSelectedEntities(), event.worldMouseLocation, pickRadius()); if (gripHit) { if (event.holdingCtrl) { // Ctrl+클릭 => 폴리선 정점 제거 (다기능 그립) const trimmed = removePolylineVertex(gripHit.entity, gripHit.grip); if (trimmed) { setEntities( getEntities().map((entity) => (entity.id === trimmed.id ? trimmed : entity)), true ); return { ...context, startPoint: null }; } } else { startGripEdit(gripHit.entity, gripHit.grip); return { ...context, startPoint: null }; } } const pickedEntity = pickEntityAt(event.worldMouseLocation); // Mouse is close to entity and is not dragging a rectangle if (pickedEntity) { // Select the entity close to the mouse const closestEntity = pickedEntity; if (!event.holdingCtrl && !event.holdingShift) { // 그룹으로 묶인 객체는 하나만 집어도 함께 선택된다 (GROUP) setSelectedEntityIds(expandSelectionWithGroups([closestEntity.id])); } else if (event.holdingCtrl) { // ctrl => toggle selection if (isEntitySelected(closestEntity)) { // Remove the entity from the selection setSelectedEntityIds(getSelectedEntityIds().filter((id) => id !== closestEntity.id)); } else { // Add the entity to the selection setSelectedEntityIds( expandSelectionWithGroups([...getSelectedEntityIds(), closestEntity.id]) ); } } else { // shift => add to selection setSelectedEntityIds( expandSelectionWithGroups([...getSelectedEntityIds(), closestEntity.id]) ); } return { ...context, startPoint: null, }; } // No elements are close to the mouse and no selection dragging is in progress // Start a new selection rectangle drag return { ...context, startPoint: event.worldMouseLocation, }; } export function selectEntitiesInsideRectangle( startPoint: Point, endPoint: Point, holdingCtrl: boolean // holdingShift: boolean, // TODO implement add to selection using shift ): void { // Finish the selection const activeSelectionRectangle = new RectangleEntity(getActiveLayerId(), startPoint, endPoint); const intersectionSelection = getIsIntersectionSelection(activeSelectionRectangle, startPoint); const newSelectedEntityIds: string[] = compact( getEntities().map((entity): string | null => { const layer = getLayers().find((layer) => layer.id === entity.layerId); if (!layer) { toast.error(`Failed to find layer for entity: ${entity?.id}`); console.error('Failed to find layer for entity', entity); return null; } if (intersectionSelection) { // Select all entities that are inside the selection rectangle or intersect with the selection rectangle if ( entity.intersectsWithBox(activeSelectionRectangle.getBoundingBox() as Box) || entity.isContainedInBox(activeSelectionRectangle.getBoundingBox() as Box) ) { if (holdingCtrl) { if (isEntitySelected(entity)) { return null; } if (!layer.isLocked) { return entity.id; } } if (!layer.isLocked) { return entity.id; } } } else { // Select only entities that are completely inside the selection rectangle if (entity.isContainedInBox(activeSelectionRectangle.getBoundingBox() as Box)) { if (holdingCtrl) { if (isEntitySelected(entity)) { return null; } if (!layer.isLocked) { return entity.id; } } if (!layer.isLocked) { return entity.id; } } } return null; }) ); setSelectedEntityIds(expandSelectionWithGroups(newSelectedEntityIds)); } export function drawTempSelectionRectangle(startPoint: Point, endPoint: Point) { const activeSelectionRectangle = new RectangleEntity(getActiveLayerId(), startPoint, endPoint); const isIntersectionSelection: boolean = getIsIntersectionSelection( activeSelectionRectangle, startPoint ); activeSelectionRectangle.lineColor = isIntersectionSelection ? SELECTION_RECTANGLE_COLOR_INTERSECTION : SELECTION_RECTANGLE_COLOR_CONTAINS; activeSelectionRectangle.lineWidth = SELECTION_RECTANGLE_WIDTH; activeSelectionRectangle.lineDash = SELECTION_RECTANGLE_STYLE; setGhostHelperEntities([activeSelectionRectangle]); } /** * Selections to the left of the start point are intersection selections (green), and everything intersecting with the selection rectangle will be selected * Selections to the right of the start point are normal selections (blue), and only the entities fully inside the selection rectangle will be selected */ export function getIsIntersectionSelection( rectangleEntity: RectangleEntity, startPoint: Point ): boolean { if (!rectangleEntity.getShape() || !startPoint) { return false; } const selectionRectangleMinX = Math.min( ...(rectangleEntity.getShape() as Polygon).vertices.map((v) => v.x) ); return Math.abs(startPoint.x - selectionRectangleMinX) > EPSILON; }