feat(CAD): 337-1 가-1 옵션 틀 — 물음 꼴 · 친 글 사다리 한 곳 · 명령 끝 → 쉼 · 순서 엔진 v2 · 키보드 흠
- 새 src/commands/prompt.ts — PromptSpec · renderPrompt(키워드 물음은 「또는」 없음) · matchOption(initget 줄임) · 세션 기억 · OPT · SELECT_OBJECTS - 새 inputController/prompt-input.ts — routeTyped 사다리(점 · 옵션 묻는 중 명령 이름 거절 · 식 · 단위 거절 · 투명 ') · 알림 줄 · 덧물음 · cancelActiveCommand · undoCommand - 새 tools/factories/end-command.ts — final(END) 갈고리 → 같은 틱 선택 도구 · state.ts 갈고리 · LASTPOINT - sequence-tool v2 — ROUTE · id · prompt · when · next · options · keyword · loopFrom · escCommits · pickfirst · 확정 뒤 기본 끝 · Esc 늘 끝 - 흠 — N1 ②③ · N5 · N6 · N11 · N18(앞글자 실행 없앰) · H1(Ctrl · Alt 글자 안 쌓음 · Ctrl+C · X · Shift+C · Shift+V · 1) · 우클릭 = 쉴 때 반복 · SE 뺌 · ED 더함 - 시험 — cad-drive 도우미 · prompt-frame · prompt-contract · 옛 시험 손질(keyboard-chain · ime-sink · command-catalog · input-prefs-cad · eraser 녹화) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 명령 끝(설계 2-8) — 도구 머신이 final(`END`)에 들면 그림자 · 기준점 · 도움선 · 덧물음을 끄고
|
||||
* 같은 틱에 새 선택 도구(쉬는 상태)로 바꿈. 선택 도구 자체의 끝(Enter)도 이 길.
|
||||
* 불러오기만 하면 갈고리가 붙음(`commands/registry.ts` 가 불러옴). 고른 것 풂은 도구 몫.
|
||||
*/
|
||||
import { Actor } from 'xstate';
|
||||
import { closeSubPrompt } from '../../inputController/prompt-input';
|
||||
import {
|
||||
setActiveToolActor,
|
||||
setAngleGuideOriginPoint,
|
||||
setCommandEndHook,
|
||||
setGhostHelperEntities,
|
||||
setShouldDrawHelpers,
|
||||
} from '../../state';
|
||||
import { selectToolStateMachine } from '../select-tool';
|
||||
|
||||
setCommandEndHook(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
setShouldDrawHelpers(false);
|
||||
closeSubPrompt();
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
});
|
||||
@@ -1,363 +1,551 @@
|
||||
/**
|
||||
* 시퀀스 도구 공장 — AutoCAD 명령의 공통 골격(단계별 입력 → 미리보기 → 확정)을
|
||||
* 선언만으로 만든다.
|
||||
* 시퀀스 도구 공장 v2(337 설계 2-13) — AutoCAD 명령의 공통 골격(물음 차례 → 미리보기 → 확정)을
|
||||
* 선언만으로 만든다. `steps`(점·숫자·문자·객체·고르기·키워드)를 나열하고 `commit` 만 채우면 머신이 나온다.
|
||||
*
|
||||
* 명령마다 xstate 머신을 손으로 쓰면 2점 그리기에도 170줄이 든다. 여기서는
|
||||
* `steps`(점·숫자·문자·객체 선택)를 나열하고 `commit`만 채우면 같은 머신이 나온다.
|
||||
* 흐름 = INIT → ROUTE(갈 단계 고름 · `when` 건너뜀) → STEP_i(값 · 옵션) → … → COMMIT → END(쉬는 상태).
|
||||
* 확정 뒤 기본은 끝 — 오토캐드 되풀이 명령만 `loopFrom`. Esc 는 늘 끝(`escCommits` 면 모은 값으로 확정 뒤).
|
||||
* 옵션 = `OPTION.<key>` 사건(물음 `prompt.options`) · 처리 = 단계 `options`.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { Actor, assign, createMachine, sendTo } from 'xstate';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { findClosestEntity } from '../../helpers/find-closest-entity';
|
||||
import { getPointFromEvent } from '../../helpers/get-point-from-event';
|
||||
import { pickRadius } from '../../helpers/pick-radius';
|
||||
import { queryEntitiesNearPoint } from '../../helpers/spatial-index';
|
||||
import { Point } from "@flatten-js/core";
|
||||
import { assign, createMachine, sendTo } from "xstate";
|
||||
import {
|
||||
getLayerById,
|
||||
getScreenCanvasDrawController,
|
||||
getSelectedEntities,
|
||||
setActiveToolActor,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setShouldDrawHelpers,
|
||||
} from '../../state';
|
||||
import type { Tool } from '../../tools';
|
||||
import { selectToolStateMachine } from '../select-tool';
|
||||
import type {
|
||||
DrawEvent,
|
||||
MouseClickEvent,
|
||||
NumberInputEvent,
|
||||
PointInputEvent,
|
||||
StateEvent,
|
||||
TextInputEvent,
|
||||
ToolContext,
|
||||
} from '../tool.types';
|
||||
type PromptSpec,
|
||||
SELECT_OBJECTS,
|
||||
remember,
|
||||
rememberSelection,
|
||||
} from "../../commands/prompt";
|
||||
import type { Entity } from "../../entities/Entity";
|
||||
import { findClosestEntity } from "../../helpers/find-closest-entity";
|
||||
import { getPointFromEvent } from "../../helpers/get-point-from-event";
|
||||
import { pickRadius } from "../../helpers/pick-radius";
|
||||
import { queryEntitiesNearPoint } from "../../helpers/spatial-index";
|
||||
import {
|
||||
getLayerById,
|
||||
getScreenCanvasDrawController,
|
||||
getSelectedEntities,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from "../../state";
|
||||
import type { Tool } from "../../tools";
|
||||
import { selectToolStateMachine } from "../select-tool";
|
||||
import {
|
||||
type DrawEvent,
|
||||
END_STATE,
|
||||
type MouseClickEvent,
|
||||
type NumberInputEvent,
|
||||
type PointInputEvent,
|
||||
type StateEvent,
|
||||
type StepKind,
|
||||
type TextInputEvent,
|
||||
type ToolContext,
|
||||
optionKey,
|
||||
} from "../tool.types";
|
||||
|
||||
export type SequenceValue = Point | number | string | Entity | Entity[];
|
||||
|
||||
export type SequenceStepKind = 'point' | 'number' | 'text' | 'entity' | 'selection';
|
||||
export type SequenceStepKind = StepKind;
|
||||
|
||||
/** 단계 옵션 처리 — `OPTION.<key>` 를 받았을 때 */
|
||||
export type StepOption =
|
||||
/** `input.mode` 세우고 다음(또는 next) 단계 */
|
||||
| { mode: string; next?: string | number }
|
||||
/** 설정만 바꾸고 같은 물음 */
|
||||
| { run: (input: SequenceInput) => void }
|
||||
/** 마지막 값 하나 뺌 · 그 값의 단계로 */
|
||||
| "undo"
|
||||
/** `input.closed` 세우고 확정 */
|
||||
| "close"
|
||||
/** 확정 없이 끝 */
|
||||
| "end";
|
||||
|
||||
export interface SequenceStep {
|
||||
kind: SequenceStepKind;
|
||||
/** 명령행·커서 옆에 뜨는 안내문 */
|
||||
instructions: string;
|
||||
/** number·text 단계에서 ENTER만 눌렀을 때 채택할 값 */
|
||||
defaultValue?: number | string;
|
||||
kind: SequenceStepKind;
|
||||
/** 값 이름 — `input.get(id)` · `next` · `loopFrom` 이 가리킴 */
|
||||
id?: string;
|
||||
/** 옛 꼴 안내문 — `prompt` 가 있으면 안 씀 */
|
||||
instructions?: string;
|
||||
/** 새 꼴 물음(함수면 지금 입력으로 그림) */
|
||||
prompt?: PromptSpec | ((input: SequenceInput) => PromptSpec);
|
||||
/** 옛 꼴 — number·text 단계에서 ENTER만 눌렀을 때 채택할 값 */
|
||||
defaultValue?: number | string;
|
||||
/** 거짓이면 이 단계를 건너뜀 */
|
||||
when?: (input: SequenceInput) => boolean;
|
||||
/** 값을 받은 뒤 갈 단계(id · 번호) — 없으면 다음 칸 */
|
||||
next?: string | number;
|
||||
/** `OPTION.<key>` 처리 — keyword 단계는 없어도 고른 key 가 값 */
|
||||
options?: Record<string, StepOption>;
|
||||
}
|
||||
|
||||
/** commit·preview에 넘어가는 입력 묶음. 단계 순서 그대로 인덱스로 읽는다. */
|
||||
export interface SequenceInput {
|
||||
values: SequenceValue[];
|
||||
/** 커서(미리보기) 또는 마지막 확정 점 */
|
||||
cursor: Point;
|
||||
point(index: number): Point;
|
||||
/** 모든 점 값 (가변 점 명령에서 사용) */
|
||||
points(): Point[];
|
||||
number(index: number): number;
|
||||
text(index: number): string;
|
||||
entity(index: number): Entity;
|
||||
entities(index: number): Entity[];
|
||||
/** 객체 선택 단계에서 실제로 클릭한 위치 (어느 쪽을 집었는지 필요한 명령용) */
|
||||
pick(index: number): Point;
|
||||
values: SequenceValue[];
|
||||
/** 커서(미리보기) 또는 마지막 확정 점 */
|
||||
cursor: Point;
|
||||
point(index: number): Point;
|
||||
/** 모든 점 값 (가변 점 명령에서 사용) */
|
||||
points(): Point[];
|
||||
number(index: number): number;
|
||||
text(index: number): string;
|
||||
entity(index: number): Entity;
|
||||
entities(index: number): Entity[];
|
||||
/** 객체 선택 단계에서 실제로 클릭한 위치 (어느 쪽을 집었는지 필요한 명령용) */
|
||||
pick(index: number): Point;
|
||||
/** id 단계의 마지막 값(없으면 undefined) */
|
||||
get<T = SequenceValue>(id: string): T;
|
||||
has(id: string): boolean;
|
||||
/** 옵션 `{ mode }` 가 세운 값 */
|
||||
mode: string | null;
|
||||
/** 닫기(`'close'`)로 확정했는가 */
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
export interface SequenceToolConfig {
|
||||
tool: Tool;
|
||||
steps: SequenceStep[];
|
||||
/** 마지막 점 단계를 ENTER 전까지 반복해 점을 모은다 (폴리선·스플라인) */
|
||||
repeatLastStep?: boolean;
|
||||
/** 커서를 따라 그려줄 임시 엔티티 */
|
||||
preview?: (input: SequenceInput) => Entity[];
|
||||
/** 확정 — 엔티티 추가·상태 변경을 직접 수행한다 */
|
||||
commit: (input: SequenceInput) => void;
|
||||
/**
|
||||
* 확정 뒤 같은 명령을 처음부터 다시 시작할지.
|
||||
* 기본값은 "점만 찍는 그리기 명령이면 계속, 숫자·문자·객체 선택이 섞이면 종료".
|
||||
* 종료하면 선택 도구로 돌아가 명령행 입력이 다음 명령으로 해석된다 (AutoCAD와 같다).
|
||||
*/
|
||||
restart?: boolean;
|
||||
/** 확정 뒤 마지막 점을 첫 값으로 이어받아 계속 그린다 (선) */
|
||||
chainFromLastPoint?: boolean;
|
||||
/** 점 입력 중 스냅·각도 가이드 사용 여부 (기본 true) */
|
||||
helpers?: boolean;
|
||||
/** ESC 로 확정 없이 그만둘 때 */
|
||||
onCancel?: () => void;
|
||||
tool: Tool;
|
||||
steps: SequenceStep[];
|
||||
/** 마지막 점 단계를 ENTER 전까지 반복해 점을 모은다 (폴리선·스플라인) */
|
||||
repeatLastStep?: boolean;
|
||||
/** 커서를 따라 그려줄 임시 엔티티 */
|
||||
preview?: (input: SequenceInput) => Entity[];
|
||||
/** 확정 — 엔티티 추가·상태 변경을 직접 수행한다 */
|
||||
commit: (input: SequenceInput) => void;
|
||||
/** 확정 뒤 그 단계(id · 번호)부터 다시 · 앞 값 둠 — 없으면 확정 뒤 끝 */
|
||||
loopFrom?: string | number;
|
||||
/** Esc 때 모은 값으로 확정 뒤 끝(PLINE) — 마지막 단계 값이 하나라도 있을 때 */
|
||||
escCommits?: boolean;
|
||||
/** 첫 단계가 고르기이고 고른 것이 있으면 그대로 받음(기본 참 · PICKFIRST) */
|
||||
pickfirst?: boolean;
|
||||
/** 옛 꼴 — true = `loopFrom: 0` · false = 끝 */
|
||||
restart?: boolean;
|
||||
/** 옛 꼴 — 확정 뒤 마지막 점을 첫 값으로 이어받아 계속 그린다 */
|
||||
chainFromLastPoint?: boolean;
|
||||
/** 점 입력 중 스냅·각도 가이드 사용 여부 (기본 true) */
|
||||
helpers?: boolean;
|
||||
/** ESC 로 확정 없이 그만둘 때 */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export interface SequenceContext extends ToolContext {
|
||||
values: SequenceValue[];
|
||||
/** values와 같은 자리에 놓이는 클릭 위치 (객체를 집은 지점) */
|
||||
picks: (Point | null)[];
|
||||
values: SequenceValue[];
|
||||
/** values와 같은 자리에 놓이는 클릭 위치 (객체를 집은 지점) */
|
||||
picks: (Point | null)[];
|
||||
/** values와 같은 자리 — 그 값을 받은 단계 번호 */
|
||||
stepOf: number[];
|
||||
/** 갈 단계 · -1 = 끝 · 단계 수 = 확정 */
|
||||
at: number;
|
||||
mode: string | null;
|
||||
closed: boolean;
|
||||
/** 되풀이 횟수 */
|
||||
loops: number;
|
||||
}
|
||||
|
||||
type Ctx = { context: SequenceContext; event: StateEvent };
|
||||
|
||||
const STEP_STATE = (index: number) => `STEP_${index}`;
|
||||
const COMMIT_STATE = 'COMMIT';
|
||||
const INIT_STATE = 'INIT';
|
||||
|
||||
function makeInput(
|
||||
values: SequenceValue[],
|
||||
cursor: Point,
|
||||
picks: (Point | null)[] = []
|
||||
): SequenceInput {
|
||||
/** 단계 인덱스로 값을 꺼낸다. 종류가 다르면 어느 단계가 잘못됐는지 바로 알린다. */
|
||||
const expect = <T>(index: number, kind: string, ok: (value: SequenceValue) => boolean): T => {
|
||||
const value = values[index];
|
||||
if (!ok(value)) {
|
||||
throw new Error(`[sequence-tool] ${index}번 단계 값이 ${kind}이(가) 아닙니다`);
|
||||
}
|
||||
return value as T;
|
||||
};
|
||||
|
||||
return {
|
||||
values,
|
||||
cursor,
|
||||
pick: (index: number) => (picks[index] ?? cursor) as Point,
|
||||
point: (index: number) => expect<Point>(index, '점', isPointValue),
|
||||
points: () => values.filter((value) => isPointValue(value)) as Point[],
|
||||
number: (index: number) => expect<number>(index, '숫자', (v) => typeof v === 'number'),
|
||||
text: (index: number) => expect<string>(index, '문자', (v) => typeof v === 'string'),
|
||||
entity: (index: number) =>
|
||||
expect<Entity>(index, '객체', (v) => !!v && typeof (v as Entity).getType === 'function'),
|
||||
entities: (index: number) => expect<Entity[]>(index, '선택 목록', Array.isArray),
|
||||
};
|
||||
}
|
||||
const INIT_STATE = "INIT";
|
||||
const ROUTE_STATE = "ROUTE";
|
||||
const COMMIT_STATE = "COMMIT";
|
||||
const ORIGIN = new Point(0, 0);
|
||||
|
||||
/** flatten-js Point 판별 — instanceof는 번들 중복 시 실패할 수 있어 형태로 본다. */
|
||||
function isPointValue(value: SequenceValue): boolean {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as Point).x === 'number' &&
|
||||
typeof (value as Point).y === 'number'
|
||||
);
|
||||
function isPointValue(value: SequenceValue | undefined): boolean {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as Point).x === "number" &&
|
||||
typeof (value as Point).y === "number"
|
||||
);
|
||||
}
|
||||
|
||||
/** 마지막으로 찍은 점 — 상대 좌표·거리 입력의 기준점이 된다. */
|
||||
function lastPoint(values: SequenceValue[]): Point | null {
|
||||
for (let index = values.length - 1; index >= 0; index--) {
|
||||
if (isPointValue(values[index])) return values[index] as Point;
|
||||
}
|
||||
return null;
|
||||
for (let index = values.length - 1; index >= 0; index--) {
|
||||
if (isPointValue(values[index])) return values[index] as Point;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cursorPoint(event: StateEvent | undefined): Point {
|
||||
if (event && (event as DrawEvent).drawController) {
|
||||
return (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
}
|
||||
return getScreenCanvasDrawController().getWorldMouseLocation();
|
||||
if (event && (event as DrawEvent).drawController) {
|
||||
return (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
}
|
||||
return getScreenCanvasDrawController().getWorldMouseLocation();
|
||||
}
|
||||
|
||||
/** 클릭 지점에서 가장 가까운 엔티티 (선택 반경 안에 있을 때만) */
|
||||
function pickEntityAt(worldPoint: Point): Entity | null {
|
||||
const radius = pickRadius();
|
||||
// 잠금 도면층(도각 등)은 집지 않는다 — 수정 도구가 참조 선을 잡으면 안 된다.
|
||||
const candidates = queryEntitiesNearPoint(worldPoint.x, worldPoint.y, radius).filter(
|
||||
(entity) => !getLayerById(entity.layerId)?.isLocked
|
||||
);
|
||||
const { distance, entity } = findClosestEntity(worldPoint, candidates);
|
||||
return entity && distance <= radius ? entity : null;
|
||||
const radius = pickRadius();
|
||||
// 잠금 도면층(도각 등)은 집지 않는다 — 수정 도구가 참조 선을 잡으면 안 된다.
|
||||
const candidates = queryEntitiesNearPoint(worldPoint.x, worldPoint.y, radius).filter(
|
||||
(entity) => !getLayerById(entity.layerId)?.isLocked,
|
||||
);
|
||||
const { distance, entity } = findClosestEntity(worldPoint, candidates);
|
||||
return entity && distance <= radius ? entity : null;
|
||||
}
|
||||
|
||||
export function createSequenceTool(config: SequenceToolConfig) {
|
||||
const { steps, tool } = config;
|
||||
const useHelpers = config.helpers !== false;
|
||||
const lastIndex = steps.length - 1;
|
||||
const { steps, tool } = config;
|
||||
const useHelpers = config.helpers !== false;
|
||||
const lastIndex = steps.length - 1;
|
||||
|
||||
const nextTarget = (index: number): string => {
|
||||
if (config.repeatLastStep && index === lastIndex) return STEP_STATE(index);
|
||||
return index < lastIndex ? STEP_STATE(index + 1) : COMMIT_STATE;
|
||||
};
|
||||
const indexOf = (ref: string | number): number => {
|
||||
const index = typeof ref === "number" ? ref : steps.findIndex((step) => step.id === ref);
|
||||
if (index < 0 || index > lastIndex) {
|
||||
throw new Error(`[sequence-tool] ${tool}: 없는 단계 ${String(ref)}`);
|
||||
}
|
||||
return index;
|
||||
};
|
||||
const loopIndex =
|
||||
config.loopFrom !== undefined
|
||||
? indexOf(config.loopFrom)
|
||||
: config.restart === true
|
||||
? 0
|
||||
: config.chainFromLastPoint && config.restart !== false
|
||||
? 1
|
||||
: null;
|
||||
for (const step of steps) if (step.next !== undefined) indexOf(step.next);
|
||||
const isRepeat = (index: number) => !!config.repeatLastStep && index === lastIndex;
|
||||
|
||||
const resetTool = () => {
|
||||
setShouldDrawHelpers(useHelpers);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
};
|
||||
/** context → 입력 묶음. 단계 인덱스로 값을 꺼낸다(종류가 다르면 어느 단계가 잘못됐는지 바로 알림) */
|
||||
const makeInput = (context: Partial<SequenceContext>, cursor: Point): SequenceInput => {
|
||||
const values = context.values ?? [];
|
||||
const picks = context.picks ?? [];
|
||||
const stepOf = context.stepOf ?? [];
|
||||
const expect = <T>(index: number, kind: string, ok: (value: SequenceValue) => boolean): T => {
|
||||
const value = values[index];
|
||||
if (!ok(value)) {
|
||||
throw new Error(`[sequence-tool] ${index}번 단계 값이 ${kind}이(가) 아닙니다`);
|
||||
}
|
||||
return value as T;
|
||||
};
|
||||
const slot = (id: string) => {
|
||||
for (let index = values.length - 1; index >= 0; index--) {
|
||||
if (steps[stepOf[index]]?.id === id) return index;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
return {
|
||||
values,
|
||||
cursor,
|
||||
pick: (index: number) => (picks[index] ?? cursor) as Point,
|
||||
point: (index: number) => expect<Point>(index, "점", isPointValue),
|
||||
points: () => values.filter((value) => isPointValue(value)) as Point[],
|
||||
number: (index: number) => expect<number>(index, "숫자", (v) => typeof v === "number"),
|
||||
text: (index: number) => expect<string>(index, "문자", (v) => typeof v === "string"),
|
||||
entity: (index: number) =>
|
||||
expect<Entity>(index, "객체", (v) => !!v && typeof (v as Entity).getType === "function"),
|
||||
entities: (index: number) => expect<Entity[]>(index, "선택 목록", Array.isArray),
|
||||
get: <T>(id: string) => values[slot(id)] as T,
|
||||
has: (id: string) => slot(id) >= 0,
|
||||
mode: context.mode ?? null,
|
||||
closed: !!context.closed,
|
||||
};
|
||||
};
|
||||
const inputOf = (context: Partial<SequenceContext>) =>
|
||||
makeInput(context, lastPoint(context.values ?? []) ?? ORIGIN);
|
||||
const promptOf = (index: number, context: Partial<SequenceContext>): PromptSpec | null => {
|
||||
const prompt = steps[index].prompt;
|
||||
return typeof prompt === "function" ? prompt(inputOf(context)) : (prompt ?? null);
|
||||
};
|
||||
|
||||
const drawPreview = ({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
if (!config.preview) return;
|
||||
// 각도 고정(`<30`) 중이면 커서를 고정 방향 위로
|
||||
const cursor = getPointFromEvent(lastPoint(context.values), event as DrawEvent);
|
||||
const ghosts = config.preview(makeInput(context.values, cursor, context.picks));
|
||||
setGhostHelperEntities(ghosts);
|
||||
};
|
||||
/** index 부터 `when` 거짓인 단계를 건너뜀 · 단계 수 = 확정 */
|
||||
const skipWhen = (index: number, context: Partial<SequenceContext>): number => {
|
||||
let at = index;
|
||||
while (at <= lastIndex && steps[at].when && !steps[at].when?.(inputOf(context))) at++;
|
||||
return at;
|
||||
};
|
||||
const after = (index: number, context: Partial<SequenceContext>): number => {
|
||||
const step = steps[index];
|
||||
if (step.next !== undefined) return skipWhen(indexOf(step.next), context);
|
||||
return isRepeat(index) ? index : skipWhen(index + 1, context);
|
||||
};
|
||||
|
||||
const pushPoint = assign(
|
||||
({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
const point = getPointFromEvent(lastPoint(context.values), event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(point);
|
||||
return { values: [...context.values, point], picks: [...context.picks, point] };
|
||||
}
|
||||
);
|
||||
/** 단계 index 가 값을 받음 → 다음 갈 곳 · 기본값 기억 */
|
||||
const accepted = (
|
||||
context: SequenceContext,
|
||||
index: number,
|
||||
value: SequenceValue,
|
||||
pick: Point | null,
|
||||
): Partial<SequenceContext> => {
|
||||
const next = {
|
||||
...context,
|
||||
values: [...context.values, value],
|
||||
picks: [...context.picks, pick],
|
||||
stepOf: [...context.stepOf, index],
|
||||
};
|
||||
const fallback = promptOf(index, context)?.default;
|
||||
if (fallback?.kind === "value" && (typeof value === "number" || isPointValue(value))) {
|
||||
remember(fallback.remember, value);
|
||||
}
|
||||
return { ...next, at: after(index, next) };
|
||||
};
|
||||
|
||||
const pushNumberFromEvent = assign(
|
||||
({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
const value =
|
||||
event.type === 'NUMBER_INPUT'
|
||||
? (event as NumberInputEvent).value
|
||||
: Number.parseFloat((event as TextInputEvent).value);
|
||||
return { values: [...context.values, value], picks: [...context.picks, null] };
|
||||
}
|
||||
);
|
||||
const resetTool = () => {
|
||||
setShouldDrawHelpers(useHelpers);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
};
|
||||
|
||||
const pushDefault = (index: number) =>
|
||||
assign(({ context }: { context: SequenceContext }) => ({
|
||||
values: [...context.values, steps[index].defaultValue as SequenceValue],
|
||||
picks: [...context.picks, null],
|
||||
}));
|
||||
const drawPreview = ({ context, event }: Ctx) => {
|
||||
if (!config.preview) return;
|
||||
// 각도 고정(`<30`) 중이면 커서를 고정 방향 위로
|
||||
const cursor = getPointFromEvent(lastPoint(context.values), event as DrawEvent);
|
||||
setGhostHelperEntities(config.preview(makeInput(context, cursor)));
|
||||
};
|
||||
|
||||
const pushText = assign(
|
||||
({ context, event }: { context: SequenceContext; event: StateEvent }) => ({
|
||||
values: [...context.values, (event as TextInputEvent).value],
|
||||
picks: [...context.picks, null],
|
||||
})
|
||||
);
|
||||
const pushPoint = (index: number) =>
|
||||
assign(({ context, event }: Ctx) => {
|
||||
const point = getPointFromEvent(lastPoint(context.values), event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(point);
|
||||
return accepted(context, index, point, point);
|
||||
});
|
||||
|
||||
const pushEntity = assign(
|
||||
({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
const location = (event as MouseClickEvent).worldMouseLocation;
|
||||
const picked = pickEntityAt(location);
|
||||
return picked
|
||||
? { values: [...context.values, picked], picks: [...context.picks, location] }
|
||||
: { values: context.values, picks: context.picks };
|
||||
}
|
||||
);
|
||||
const pushNumber = (index: number) =>
|
||||
assign(({ context, event }: Ctx) =>
|
||||
accepted(context, index, (event as NumberInputEvent).value, null),
|
||||
);
|
||||
|
||||
const pushSelection = assign(({ context }: { context: SequenceContext }) => ({
|
||||
values: [...context.values, getSelectedEntities()],
|
||||
picks: [...context.picks, null],
|
||||
}));
|
||||
const pushDefault = (index: number) =>
|
||||
assign(({ context }: Ctx) =>
|
||||
accepted(context, index, steps[index].defaultValue as SequenceValue, null),
|
||||
);
|
||||
|
||||
// 점만 받는 그리기 명령은 계속 그리게 두고, 그 밖의 명령은 끝나면 선택 도구로 돌아간다
|
||||
const keepRunning =
|
||||
config.restart ?? (steps.every((step) => step.kind === 'point') || !!config.chainFromLastPoint);
|
||||
const pushText = (index: number) =>
|
||||
assign(({ context, event }: Ctx) =>
|
||||
accepted(context, index, (event as TextInputEvent).value, null),
|
||||
);
|
||||
|
||||
const commit = assign(({ context }: { context: SequenceContext }) => {
|
||||
const points = context.values.filter(isPointValue) as Point[];
|
||||
const cursor = points.length ? points[points.length - 1] : cursorPoint(undefined);
|
||||
config.commit(makeInput(context.values, cursor, context.picks));
|
||||
setGhostHelperEntities([]);
|
||||
if (!keepRunning) {
|
||||
// 전이 도중에 액터를 갈아치우지 않도록 다음 틱에 도구를 바꾼다
|
||||
setTimeout(() => setActiveToolActor(new Actor(selectToolStateMachine)), 0);
|
||||
}
|
||||
if (config.chainFromLastPoint && points.length) {
|
||||
const tail = points[points.length - 1];
|
||||
setAngleGuideOriginPoint(tail);
|
||||
return { values: [tail] as SequenceValue[], picks: [tail] as (Point | null)[] };
|
||||
}
|
||||
return { values: [] as SequenceValue[], picks: [] as (Point | null)[] };
|
||||
});
|
||||
const pushEntity = (index: number) =>
|
||||
assign(({ context, event }: Ctx) => {
|
||||
const location = (event as MouseClickEvent).worldMouseLocation;
|
||||
const picked = pickEntityAt(location);
|
||||
return picked ? accepted(context, index, picked, location) : {};
|
||||
});
|
||||
|
||||
const cancel = assign(({ context }: { context: SequenceContext }) => {
|
||||
config.onCancel?.();
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
if (context.values.length === 0) {
|
||||
// 입력이 하나도 없는 상태의 ESC → 선택 도구로 빠져나간다 (AutoCAD와 동일)
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
}
|
||||
return { values: [] as SequenceValue[], picks: [] as (Point | null)[] };
|
||||
});
|
||||
const pushSelection = (index: number) =>
|
||||
assign(({ context }: Ctx) => {
|
||||
const selected = getSelectedEntities();
|
||||
rememberSelection(selected.map((entity) => entity.id));
|
||||
return accepted(context, index, selected, null);
|
||||
});
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: xstate 상태 구성은 동적으로 만든다
|
||||
const states: Record<string, any> = {
|
||||
[INIT_STATE]: {
|
||||
always: { actions: resetTool, target: STEP_STATE(0) },
|
||||
},
|
||||
[COMMIT_STATE]: {
|
||||
always: {
|
||||
actions: commit,
|
||||
target: config.chainFromLastPoint ? STEP_STATE(1) : INIT_STATE,
|
||||
},
|
||||
},
|
||||
};
|
||||
/** 단계 index 가 이 옵션 key 를 처리하는가 */
|
||||
const handles = (index: number, key: string, context: SequenceContext): boolean =>
|
||||
!!steps[index].options?.[key] ||
|
||||
(steps[index].kind === "keyword" &&
|
||||
!!promptOf(index, context)?.options?.some((option) => option.key === key));
|
||||
|
||||
steps.forEach((step, index) => {
|
||||
const target = nextTarget(index);
|
||||
const escTransition = { actions: cancel, target: INIT_STATE };
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 이벤트 맵도 단계 종류마다 달라진다
|
||||
const on: Record<string, any> = { ESC: escTransition };
|
||||
const applyOption = (index: number) =>
|
||||
assign(({ context, event }: Ctx): Partial<SequenceContext> => {
|
||||
const key = optionKey(event) as string;
|
||||
const handler = steps[index].options?.[key];
|
||||
if (handler === "end") return { at: -1 };
|
||||
if (handler === "close") return { closed: true, at: steps.length };
|
||||
if (handler === "undo") {
|
||||
if (!context.values.length) return { at: index };
|
||||
const values = context.values.slice(0, -1);
|
||||
setAngleGuideOriginPoint(lastPoint(values));
|
||||
setGhostHelperEntities([]);
|
||||
return {
|
||||
values,
|
||||
picks: context.picks.slice(0, -1),
|
||||
stepOf: context.stepOf.slice(0, -1),
|
||||
at: context.stepOf[context.stepOf.length - 1],
|
||||
};
|
||||
}
|
||||
if (handler && "run" in handler) {
|
||||
handler.run(inputOf(context));
|
||||
return { at: index };
|
||||
}
|
||||
const mode = handler ? handler.mode : context.mode;
|
||||
const moved = { ...context, mode };
|
||||
const jump = handler?.next !== undefined ? skipWhen(indexOf(handler.next), moved) : null;
|
||||
if (steps[index].kind === "keyword") {
|
||||
const taken = accepted(moved, index, key, null); // 고른 key 가 값
|
||||
return { ...taken, mode, at: jump ?? (taken.at as number) };
|
||||
}
|
||||
return { mode, at: jump ?? skipWhen(index + 1, moved) };
|
||||
});
|
||||
|
||||
if (step.kind === 'point') {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.MOUSE_CLICK = { actions: pushPoint, target };
|
||||
on.ABSOLUTE_POINT_INPUT = { actions: pushPoint, target };
|
||||
// 거리 · 상대 좌표는 기준점(앞 점)이 있을 때만 — 첫 점이면 무시(도구가 멈추지 않게)
|
||||
const hasBase = ({ context }: { context: SequenceContext }) => !!lastPoint(context.values);
|
||||
on.RELATIVE_POINT_INPUT = { guard: hasBase, actions: pushPoint, target };
|
||||
on.NUMBER_INPUT = { guard: hasBase, actions: pushPoint, target };
|
||||
if (config.repeatLastStep && index === lastIndex) {
|
||||
on.ENTER = { target: COMMIT_STATE };
|
||||
}
|
||||
} else if (step.kind === 'number') {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.NUMBER_INPUT = { actions: pushNumberFromEvent, target };
|
||||
on.TEXT_INPUT = {
|
||||
guard: ({ event }: { event: StateEvent }) =>
|
||||
Number.isFinite(Number.parseFloat((event as TextInputEvent).value)),
|
||||
actions: pushNumberFromEvent,
|
||||
target,
|
||||
};
|
||||
if (step.defaultValue !== undefined) {
|
||||
on.ENTER = { actions: pushDefault(index), target };
|
||||
}
|
||||
} else if (step.kind === 'text') {
|
||||
on.TEXT_INPUT = { actions: pushText, target };
|
||||
on.NUMBER_INPUT = { actions: pushNumberFromEvent, target };
|
||||
if (step.defaultValue !== undefined) {
|
||||
on.ENTER = { actions: pushDefault(index), target };
|
||||
}
|
||||
} else if (step.kind === 'entity') {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.MOUSE_CLICK = {
|
||||
guard: ({ event }: { event: StateEvent }) =>
|
||||
!!pickEntityAt((event as MouseClickEvent).worldMouseLocation),
|
||||
actions: pushEntity,
|
||||
target,
|
||||
};
|
||||
}
|
||||
const commitNow = ({ context }: { context: SequenceContext }) => {
|
||||
const points = context.values.filter(isPointValue) as Point[];
|
||||
const cursor = points.length ? points[points.length - 1] : cursorPoint(undefined);
|
||||
config.commit(makeInput(context, cursor));
|
||||
setGhostHelperEntities([]);
|
||||
};
|
||||
|
||||
if (step.kind === 'selection') {
|
||||
const actorId = `selectInside_${index}`;
|
||||
states[STEP_STATE(index)] = {
|
||||
meta: { instructions: step.instructions },
|
||||
invoke: {
|
||||
id: actorId,
|
||||
src: selectToolStateMachine,
|
||||
onDone: { actions: pushSelection, target },
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: { actions: sendTo(actorId, ({ event }) => event) },
|
||||
ENTER: { actions: sendTo(actorId, ({ event }) => event) },
|
||||
DRAW: { actions: sendTo(actorId, ({ event }) => event) },
|
||||
ESC: escTransition,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
/** 확정 뒤 되풀이 — loopFrom 앞 값만 둠(옛 chainFromLastPoint = 마지막 점 이어받음) */
|
||||
const loopAgain = assign(({ context }: Ctx): Partial<SequenceContext> => {
|
||||
const from = loopIndex as number;
|
||||
const tail = lastPoint(context.values);
|
||||
const chain = config.chainFromLastPoint && config.loopFrom === undefined && tail;
|
||||
const keep = context.values.map((_, index) => chain || context.stepOf[index] < from);
|
||||
const values = chain ? [tail] : context.values.filter((_, index) => keep[index]);
|
||||
const next = {
|
||||
...context,
|
||||
values,
|
||||
picks: chain ? [tail] : context.picks.filter((_, index) => keep[index]),
|
||||
stepOf: chain ? [0] : context.stepOf.filter((_, index) => keep[index]),
|
||||
closed: false,
|
||||
loops: context.loops + 1,
|
||||
};
|
||||
setAngleGuideOriginPoint(lastPoint(values));
|
||||
return { ...next, at: skipWhen(from, next) };
|
||||
});
|
||||
|
||||
states[STEP_STATE(index)] = {
|
||||
meta: { instructions: step.instructions, kind: step.kind },
|
||||
entry: () => {
|
||||
// 객체 선택 단계가 아니면 스냅·각도 가이드를 켠다
|
||||
setShouldDrawHelpers(useHelpers && step.kind === 'point');
|
||||
},
|
||||
on,
|
||||
};
|
||||
});
|
||||
const cancel = ({ context }: { context: SequenceContext }) => {
|
||||
config.onCancel?.();
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
if (context.stepOf.some((index) => steps[index].kind === "selection")) setSelectedEntityIds([]);
|
||||
};
|
||||
const escCommits = ({ context }: { context: SequenceContext }) =>
|
||||
!!config.escCommits && context.stepOf.includes(lastIndex);
|
||||
const escTransition = [
|
||||
{ guard: escCommits, actions: commitNow, target: END_STATE },
|
||||
{ actions: cancel, target: END_STATE },
|
||||
];
|
||||
|
||||
return createMachine(
|
||||
{
|
||||
types: {} as { context: SequenceContext; events: StateEvent },
|
||||
context: { values: [] as SequenceValue[], picks: [] as (Point | null)[], type: tool },
|
||||
initial: INIT_STATE,
|
||||
states,
|
||||
},
|
||||
{
|
||||
// 하위에서 돌리는 선택 도구의 액션 구현을 함께 넘긴다
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 자식 머신의 컨텍스트 타입이 달라 그대로 넘긴다
|
||||
actions: { ...(selectToolStateMachine.implementations.actions as any) },
|
||||
}
|
||||
);
|
||||
const pickfirst = () =>
|
||||
config.pickfirst !== false &&
|
||||
steps[0]?.kind === "selection" &&
|
||||
getSelectedEntities().length > 0;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: xstate 상태 구성은 동적으로 만든다
|
||||
const states: Record<string, any> = {
|
||||
[INIT_STATE]: {
|
||||
always: [
|
||||
{ guard: pickfirst, actions: [resetTool, pushSelection(0)], target: ROUTE_STATE },
|
||||
{
|
||||
actions: [resetTool, assign(({ context }: Ctx) => ({ at: skipWhen(0, context) }))],
|
||||
target: ROUTE_STATE,
|
||||
},
|
||||
],
|
||||
},
|
||||
[ROUTE_STATE]: {
|
||||
always: [
|
||||
{ guard: ({ context }: Ctx) => context.at < 0, target: END_STATE },
|
||||
...steps.map((_, index) => ({
|
||||
guard: ({ context }: Ctx) => context.at === index,
|
||||
target: STEP_STATE(index),
|
||||
})),
|
||||
{ target: COMMIT_STATE },
|
||||
],
|
||||
},
|
||||
[COMMIT_STATE]: {
|
||||
always:
|
||||
loopIndex === null
|
||||
? { actions: commitNow, target: END_STATE }
|
||||
: { actions: [commitNow, loopAgain], target: ROUTE_STATE },
|
||||
},
|
||||
[END_STATE]: { type: "final" },
|
||||
};
|
||||
|
||||
steps.forEach((step, index) => {
|
||||
const toRoute = (actions: unknown) => ({ actions, target: ROUTE_STATE });
|
||||
const wrapPrompt = step.prompt
|
||||
? (context: SequenceContext) => promptOf(index, context ?? {})
|
||||
: undefined;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 이벤트 맵도 단계 종류마다 달라진다
|
||||
const on: Record<string, any> = {
|
||||
ESC: escTransition,
|
||||
"OPTION.*": {
|
||||
guard: ({ context, event }: Ctx) => handles(index, optionKey(event) as string, context),
|
||||
...toRoute(applyOption(index)),
|
||||
},
|
||||
};
|
||||
// 빈 Enter — 옛 기본값 · 되풀이 점 = 확정 · 되풀이 첫 물음(한 번 이상 돈 뒤) = 끝
|
||||
const enter: unknown[] = [];
|
||||
if (step.defaultValue !== undefined) enter.push(toRoute(pushDefault(index)));
|
||||
if (isRepeat(index)) enter.push({ target: COMMIT_STATE });
|
||||
if (index === loopIndex) {
|
||||
enter.push({ guard: ({ context }: Ctx) => context.loops > 0, target: END_STATE });
|
||||
}
|
||||
if (enter.length && step.kind !== "selection") on.ENTER = enter;
|
||||
|
||||
if (step.kind === "point") {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.MOUSE_CLICK = toRoute(pushPoint(index));
|
||||
on.ABSOLUTE_POINT_INPUT = toRoute(pushPoint(index));
|
||||
// 거리 · 상대 좌표는 기준점(앞 점)이 있을 때만 — 첫 점이면 무시(도구가 멈추지 않게)
|
||||
const hasBase = ({ context }: Ctx) => !!lastPoint(context.values);
|
||||
on.RELATIVE_POINT_INPUT = { guard: hasBase, ...toRoute(pushPoint(index)) };
|
||||
on.NUMBER_INPUT = { guard: hasBase, ...toRoute(pushPoint(index)) };
|
||||
} else if (step.kind === "number") {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.NUMBER_INPUT = toRoute(pushNumber(index));
|
||||
} else if (step.kind === "text") {
|
||||
on.TEXT_INPUT = toRoute(pushText(index));
|
||||
on.NUMBER_INPUT = toRoute(pushNumber(index));
|
||||
} else if (step.kind === "entity") {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.MOUSE_CLICK = {
|
||||
guard: ({ event }: Ctx) => !!pickEntityAt((event as MouseClickEvent).worldMouseLocation),
|
||||
...toRoute(pushEntity(index)),
|
||||
};
|
||||
} else if (step.kind === "keyword") {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
}
|
||||
|
||||
if (step.kind === "selection") {
|
||||
const actorId = `selectInside_${index}`;
|
||||
const forward = { actions: sendTo(actorId, ({ event }) => event) };
|
||||
states[STEP_STATE(index)] = {
|
||||
meta: { kind: "selection", prompt: wrapPrompt ?? SELECT_OBJECTS },
|
||||
entry: () => setShouldDrawHelpers(false),
|
||||
invoke: {
|
||||
id: actorId,
|
||||
src: selectToolStateMachine,
|
||||
onDone: [
|
||||
// 고른 것 없이 Enter = 명령 끝(오토캐드)
|
||||
{ guard: () => getSelectedEntities().length === 0, target: END_STATE },
|
||||
toRoute(pushSelection(index)),
|
||||
],
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: forward,
|
||||
ENTER: forward,
|
||||
DRAW: forward,
|
||||
"OPTION.*": forward,
|
||||
ESC: escTransition,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
states[STEP_STATE(index)] = {
|
||||
meta: wrapPrompt
|
||||
? { kind: step.kind, prompt: wrapPrompt }
|
||||
: { kind: step.kind, instructions: step.instructions },
|
||||
entry: () => {
|
||||
// 객체 선택 단계가 아니면 스냅·각도 가이드를 켠다
|
||||
setShouldDrawHelpers(useHelpers && step.kind === "point");
|
||||
},
|
||||
on,
|
||||
};
|
||||
});
|
||||
|
||||
return createMachine(
|
||||
{
|
||||
types: {} as { context: SequenceContext; events: StateEvent },
|
||||
context: {
|
||||
values: [] as SequenceValue[],
|
||||
picks: [] as (Point | null)[],
|
||||
stepOf: [] as number[],
|
||||
at: 0,
|
||||
mode: null,
|
||||
closed: false,
|
||||
loops: 0,
|
||||
type: tool,
|
||||
},
|
||||
initial: INIT_STATE,
|
||||
states,
|
||||
},
|
||||
{
|
||||
// 하위에서 돌리는 선택 도구의 액션 구현을 함께 넘긴다
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 자식 머신의 컨텍스트 타입이 달라 그대로 넘긴다
|
||||
actions: { ...(selectToolStateMachine.implementations.actions as any) },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface MouseClickEvent extends EventObject {
|
||||
screenMouseLocation: Point;
|
||||
holdingCtrl: boolean;
|
||||
holdingShift: boolean;
|
||||
/** 객체 스냅 · 한 번 스냅이 고른 점 — 직교가 덮지 않음 */
|
||||
snapped?: boolean;
|
||||
}
|
||||
|
||||
export interface KeyboardEscEvent extends EventObject {
|
||||
@@ -89,6 +91,21 @@ export interface DrawEvent extends EventObject {
|
||||
drawController: ScreenCanvasDrawController;
|
||||
}
|
||||
|
||||
/** 옵션 사건 — 물음 `prompt.options` 의 key 마다 `OPTION.<key>`(설계 2-3) */
|
||||
export interface OptionEvent extends EventObject {
|
||||
type: `OPTION.${string}`;
|
||||
}
|
||||
|
||||
/** 옵션 사건이면 그 key · 아니면 null */
|
||||
export const optionKey = (event: EventObject): string | null =>
|
||||
event.type.startsWith('OPTION.') ? event.type.slice('OPTION.'.length) : null;
|
||||
|
||||
/** 명령 끝 상태 이름 — `type: 'final'` · 들면 쉬는 상태(선택 도구)로 바뀜 */
|
||||
export const END_STATE = 'END';
|
||||
|
||||
/** 물음 종류 — 상태 `meta.kind` */
|
||||
export type StepKind = 'point' | 'number' | 'text' | 'entity' | 'selection' | 'keyword';
|
||||
|
||||
export type PointInputEvent =
|
||||
| DrawEvent
|
||||
| MouseClickEvent
|
||||
@@ -106,7 +123,8 @@ export type StateEvent =
|
||||
| AbsolutePointInputEvent
|
||||
| RelativePointInputEvent
|
||||
| FileSelectedEvent
|
||||
| DrawEvent;
|
||||
| DrawEvent
|
||||
| OptionEvent;
|
||||
|
||||
export interface ToolContext {
|
||||
type: Tool;
|
||||
|
||||
Reference in New Issue
Block a user