- ZOOM(Z): 「윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력 또는 [전체(A)/범위(E)/이전(P)/윈도우(W)] <실시간>:」 덧물음 · E · A = 범위 · P = 앞 화면 · W · 누름 = 두 구석 윈도우 · 2X = 배율 곱 · 빈 Enter = 실시간 안내 · 중심 · 동적 · 축척 · 객체 = 없음 안내 · 'Z 투명 · ⛶ 단추 = 바로 범위 - 약속 더함: SubPrompt.onText(꼴이 정해지지 않은 글 · nX) — routeTyped 가 덧물음에 먼저 물음 · undefined = 사다리 그대로(브레인 알림) - Shift+우클릭: 점 물음에서만 한 번 스냅 메뉴(시작점에서 · 두 점 사이의 중간 · 끝점 · 중간점 · 교차점 · 중심 · 사분점 · 수직 · 노드 · 근처점 · 없음) → 고른 이름을 친 것과 같은 길 · Esc · 바깥 누름 = 메뉴만 닫음(ic/snap-menu.ts) - 고르기 숨은 키워드 ALL · L · P(선택 도구 첫 점 대기 · 쉼 · 명령 안 자식) — 앞 고름에 더함 · 「n개를 찾음, 총 m개」 - 그립 물음 새 꼴 「** 신축 ** 신축점 지정 또는 [종료(X)]:」 · X = 놓음 · 기준점(B) · 복사(C) · 명령 취소(U) 없음 안내 - ↶ ↷ 단추 = undoCommand · redoCommand(끊고 되돌림) · 우클릭 = 명령 중 Enter · 쉴 때 반복(가-1 길 · 시험) - 시험: mouse-acad 마-2 더함(21) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
205 lines
7.7 KiB
TypeScript
205 lines
7.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,
|
|
getActiveToolActor,
|
|
isDrawingReadOnly,
|
|
isEntitySelected,
|
|
isViewOnly,
|
|
setEntities,
|
|
setGhostHelperEntities,
|
|
setSelectedEntityIds,
|
|
} from '../state';
|
|
import { Tool } from '../tools';
|
|
import { previousSelection } from '../commands/prompt';
|
|
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 {
|
|
// 선택된 객체의 그립이 먼저다 — 집으면 그립 편집으로 넘어간다.
|
|
// 쉴 때(맨 위 = 선택 도구)만 · 명령 안 고르기는 그립 없음(N7) · 보기 전용 · 확정 도면은 막음(H2)
|
|
const gripHit = gripsEditable()
|
|
? findGripAt(getSelectedEntities(), event.worldMouseLocation, pickRadius())
|
|
: null;
|
|
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) {
|
|
// 오토캐드 PICKADD 2 — 누름 = 더함 · Shift = 뺌 · Ctrl = 넣고 빼기(그룹은 함께 · GROUP)
|
|
const ids = expandSelectionWithGroups([pickedEntity.id]);
|
|
const remove = event.holdingShift || (event.holdingCtrl && isEntitySelected(pickedEntity));
|
|
setSelectedEntityIds(combineSelection(ids, remove ? 'remove' : 'add'));
|
|
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 combineSelection(ids: string[], mode: 'add' | 'remove' | 'toggle'): string[] {
|
|
const before = getSelectedEntityIds();
|
|
if (mode === 'remove') return before.filter((id) => !ids.includes(id));
|
|
if (mode === 'toggle') {
|
|
return [
|
|
...before.filter((id) => !ids.includes(id)),
|
|
...ids.filter((id) => !before.includes(id)),
|
|
];
|
|
}
|
|
return [...before, ...ids.filter((id) => !before.includes(id))];
|
|
}
|
|
|
|
/** 알림 줄 — 입력 층이 붙임(`ic/selecting.ts` · prompt-input 을 바로 부르면 불러오기 고리) */
|
|
let notice: (text: string) => void = () => {};
|
|
export const setKeywordNotice = (sink: (text: string) => void): void => {
|
|
notice = sink;
|
|
};
|
|
|
|
/** 고르기 키워드 — 전체(ALL = Ctrl+A 와 같은 목록) · 최종(LAST = 집을 수 있는 마지막) · 이전(P) · 앞 고름에 더함 */
|
|
export function selectByKeyword(key: 'ALL' | 'LAST' | 'PREVIOUS'): void {
|
|
const pickable = getPickableEntities();
|
|
const found =
|
|
key === 'ALL'
|
|
? pickable.map((entity) => entity.id)
|
|
: key === 'LAST'
|
|
? pickable.slice(-1).map((entity) => entity.id)
|
|
: previousSelection().filter((id) => pickable.some((entity) => entity.id === id));
|
|
const ids = combineSelection(expandSelectionWithGroups(found), 'add');
|
|
setSelectedEntityIds(ids);
|
|
notice(`${found.length}개를 찾음, 총 ${ids.length}개`);
|
|
}
|
|
|
|
/** 그립 집기 · 그리기 — 쉴 때 · 고칠 수 있는 도면만 */
|
|
export const gripsEditable = (): boolean =>
|
|
!isViewOnly() &&
|
|
!isDrawingReadOnly() &&
|
|
getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT;
|
|
|
|
/** 상자 고르기 — 상자 = 앞 고름 ∪ 상자 · Shift = 앞 고름 − 상자 · Ctrl = 넣고 빼기(오토캐드 PICKADD 2) */
|
|
export function selectEntitiesInsideRectangle(
|
|
startPoint: Point,
|
|
endPoint: Point,
|
|
holdingCtrl: boolean,
|
|
holdingShift = false
|
|
): void {
|
|
// Finish the selection
|
|
const activeSelectionRectangle = new RectangleEntity(getActiveLayerId(), startPoint, endPoint);
|
|
const intersectionSelection = getIsIntersectionSelection(activeSelectionRectangle, startPoint);
|
|
const box = activeSelectionRectangle.getBoundingBox() as Box;
|
|
const boxIds: 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 (layer.isLocked) return null;
|
|
// 걸침(오→왼) = 걸치거나 든 것 · 창(왼→오) = 통째로 든 것만
|
|
const inside =
|
|
entity.isContainedInBox(box) || (intersectionSelection && entity.intersectsWithBox(box));
|
|
return inside ? entity.id : null;
|
|
})
|
|
);
|
|
const ids = expandSelectionWithGroups(boxIds);
|
|
setSelectedEntityIds(
|
|
combineSelection(ids, holdingShift ? 'remove' : holdingCtrl ? 'toggle' : 'add')
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|