앞선 감사가 불완전했다. 같은 부류로 도각·원지반을 건드리던 곳이 더 있었다. - select-tool.helpers pickEntityAt: 클릭 선택 후보에 잠금 객체가 섞여 도각을 물면 아무 일도 안 일어나는 죽은 클릭이 됐다. - eraser-tool: 자르기의 교차점 계산이 도각 선을 절단 경계로 썼다. - property-tools OVERKILL: 선택이 없으면 전체가 대상이라 도각까지 지웠다. - text-tools 찾기/바꾸기: 도각 표제란 글자를 바꿔 버렸다. - selection-tools 유형/유사 선택: 잠금 객체까지 세어 토스트 개수가 틀렸다. 전부 getPickableEntities()로 바꾼다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
201 lines
6.7 KiB
TypeScript
201 lines
6.7 KiB
TypeScript
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;
|
|
}
|