refactor(B07,B08): 도면 코드 B08_DesignDetail 이동 + 수량 슬롯 B07_Quantity 확보
- B07_wf4_DesignDetail(8파일+openwebcad) -> B08_DesignDetail로 git mv (이력 보존) - B08_wf5_Quantity -> B07_Quantity (구 수량 백업 zip 보관 폴더) - 파생 문자열 일괄 전환: /b07-cad -> /b08-cad 정적 서빙, CAD 레이어 b07-* -> b08-*, postMessage aislo:b07:* -> aislo:b08:*, 패키지명 aislo-b08-cad, CSS .b08-*, 라우트 키/슬러그(B08_DESIGN_DETAIL/b08-design-detail, B07_QUANTITY/b07-quantity) - 상세설계 워크플로우 stage 4 -> 5 (확정/무효화 전이 3곳), 확정 완료 시 B09로 이동 - WORKFLOW_STEP_ROUTES 순서 재배열: index4=수량(B07), index5=상세설계(B08) - [임시] B08 이동 테스트 버튼 제거, openwebcad dist 재빌드 - 주석 의미 정렬: 수량 인계 주석 B08->B07, 도면 참조 B07->B08 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import {Tool} from '../tools';
|
||||
import {createMachine} from 'xstate';
|
||||
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
|
||||
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
|
||||
/**
|
||||
* AlignBottom tool state machine
|
||||
* This state machine is responsible for aligning entities on the canvas
|
||||
* It uses the select tool state machine to select entities to align
|
||||
* When the user presses enter, the selected entities are bottom aligned
|
||||
*/
|
||||
export const alignBottomToolStateMachine = createMachine(
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_BOTTOM),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
entity.move(0, boundingBox.minY - entity.getBoundingBox().ymin);
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import {Tool} from '../tools';
|
||||
import {createMachine} from 'xstate';
|
||||
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
|
||||
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
import {middle} from "../helpers/middle.ts";
|
||||
|
||||
/**
|
||||
* AlignCenterHorizontal tool state machine
|
||||
* This state machine is responsible for aligning entities on the canvas
|
||||
* It uses the select tool state machine to select entities to align
|
||||
* When the user presses enter, the selected entities are center horizontal aligned
|
||||
*/
|
||||
export const alignCenterHorizontalToolStateMachine = createMachine(
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_CENTER_HORIZONTAL),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
const entityBoundingBox = entity.getBoundingBox();
|
||||
const centerBoundingBoxX = middle(boundingBox.minX, boundingBox.maxX);
|
||||
const centerEntityX = middle(entityBoundingBox.xmin, entityBoundingBox.xmax);
|
||||
entity.move(centerBoundingBoxX - centerEntityX, 0);
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import {Tool} from '../tools';
|
||||
import {createMachine} from 'xstate';
|
||||
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
|
||||
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
|
||||
/**
|
||||
* AlignLeft tool state machine
|
||||
* This state machine is responsible for aligning entities on the canvas
|
||||
* It uses the select tool state machine to select entities to align
|
||||
* When the user presses enter, the selected entities are left aligned
|
||||
*/
|
||||
export const alignLeftToolStateMachine = createMachine(
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_LEFT),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
entity.move(boundingBox.minX - entity.getBoundingBox().xmin, 0);
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import {Tool} from '../tools';
|
||||
import {createMachine} from 'xstate';
|
||||
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
|
||||
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
import {middle} from "../helpers/middle.ts";
|
||||
|
||||
/**
|
||||
* AlignCenterVertical tool state machine
|
||||
* This state machine is responsible for aligning entities on the canvas
|
||||
* It uses the select tool state machine to select entities to align
|
||||
* When the user presses enter, the selected entities are center vertical aligned
|
||||
*/
|
||||
export const alignCenterVerticalToolStateMachine = createMachine(
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_CENTER_VERTICAL),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
const entityBoundingBox = entity.getBoundingBox();
|
||||
const centerBoundingBoxY = middle(boundingBox.minY, boundingBox.maxY);
|
||||
const centerEntityY = middle(entityBoundingBox.ymin, entityBoundingBox.ymax);
|
||||
entity.move(0, centerBoundingBoxY - centerEntityY);
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import {Tool} from '../tools';
|
||||
import {createMachine} from 'xstate';
|
||||
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
|
||||
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
|
||||
/**
|
||||
* AlignRight tool state machine
|
||||
* This state machine is responsible for aligning entities on the canvas
|
||||
* It uses the select tool state machine to select entities to align
|
||||
* When the user presses enter, the selected entities are right aligned
|
||||
*/
|
||||
export const alignRightToolStateMachine = createMachine(
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_RIGHT),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
entity.move(boundingBox.maxX - entity.getBoundingBox().xmax, 0);
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,144 @@
|
||||
import {assign, type MachineContext, sendTo} from 'xstate';
|
||||
import type {Entity} from '../entities/Entity.ts';
|
||||
import {type BoundingBox, getBoundingBoxOfMultipleEntities,} from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import {
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state.ts';
|
||||
import type {Tool} from '../tools.ts';
|
||||
import {selectToolStateMachine} from './select-tool.ts';
|
||||
import type {DrawEvent, KeyboardEnterEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types.ts';
|
||||
|
||||
export interface AlignContext extends ToolContext {}
|
||||
|
||||
export enum AlignState {
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
}
|
||||
|
||||
export enum AlignAction {
|
||||
INIT_ALIGN_TOOL = 'INIT_ALIGN_TOOL',
|
||||
ALIGN_SELECTION = 'ALIGN_SELECTION',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
}
|
||||
|
||||
export function GET_ALIGN_TOOL_STATE(type: Tool): MachineContext {
|
||||
return {
|
||||
types: {} as {
|
||||
context: AlignContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
type,
|
||||
},
|
||||
initial: AlignState.INIT,
|
||||
states: {
|
||||
[AlignState.INIT]: {
|
||||
description: 'Initializing the align tool',
|
||||
always: {
|
||||
actions: AlignAction.INIT_ALIGN_TOOL,
|
||||
target: AlignState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[AlignState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to align',
|
||||
meta: {
|
||||
instructions: 'Select what you want to align, then ENTER',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheAlignTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(() => {
|
||||
return {};
|
||||
}),
|
||||
target: AlignState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo(
|
||||
'selectToolInsideTheAlignTool',
|
||||
({ event }: { event: MouseClickEvent }) => {
|
||||
return event;
|
||||
}
|
||||
),
|
||||
},
|
||||
ESC: {
|
||||
actions: [AlignAction.DESELECT_ENTITIES, AlignAction.INIT_ALIGN_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo(
|
||||
'selectToolInsideTheAlignTool',
|
||||
({ event }: { event: KeyboardEnterEvent }) => {
|
||||
return event;
|
||||
}
|
||||
),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheAlignTool', ({ event }: { event: DrawEvent }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[AlignState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
actions: AlignAction.ALIGN_SELECTION,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: AlignState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function GET_ALIGN_ACTION(alignEntity: (entity: Entity, boundingBox: BoundingBox) => void) {
|
||||
return {
|
||||
actions: {
|
||||
[AlignAction.INIT_ALIGN_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {};
|
||||
}),
|
||||
[AlignAction.ALIGN_SELECTION]: () => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
if (!selectedEntities?.length) {
|
||||
throw new Error('[ALIGN_LEFT] Calling align without entities selected');
|
||||
}
|
||||
|
||||
// Align the entities
|
||||
const boundingBox = getBoundingBoxOfMultipleEntities(selectedEntities);
|
||||
|
||||
for (const entity of selectedEntities) {
|
||||
alignEntity(entity, boundingBox);
|
||||
}
|
||||
},
|
||||
[AlignAction.DESELECT_ENTITIES]: assign(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {};
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {Tool} from '../tools';
|
||||
import {createMachine} from 'xstate';
|
||||
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
|
||||
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
|
||||
/**
|
||||
* AlignTop tool state machine
|
||||
* This state machine is responsible for aligning entities on the canvas
|
||||
* It uses the select tool state machine to select entities to align
|
||||
* When the user presses enter, the selected entities are top aligned
|
||||
*/
|
||||
export const alignTopToolStateMachine = createMachine(
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_TOP),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
entity.move(0, -(entity.getBoundingBox().ymax - boundingBox.maxY));
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,368 @@
|
||||
import {type Point, Vector} from '@flatten-js/core';
|
||||
import {assign, createMachine, sendTo} from 'xstate';
|
||||
import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH, TO_RADIANS} from '../App.consts.ts';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import {LineEntity} from "../entities/LineEntity.ts";
|
||||
import {getPointFromEvent} from "../helpers/get-point-from-event.ts";
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import {Tool} from '../tools';
|
||||
import {CopyAction} from './copy-tool.ts';
|
||||
import {selectToolStateMachine} from './select-tool.ts';
|
||||
import type {
|
||||
AbsolutePointInputEvent,
|
||||
DrawEvent,
|
||||
MouseClickEvent,
|
||||
NumberInputEvent,
|
||||
StateEvent,
|
||||
TextInputEvent,
|
||||
ToolContext,
|
||||
} from './tool.types';
|
||||
|
||||
export enum ArrayState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
ASK_COPY_MODE = 'ASK_COPY_MODE',
|
||||
ASK_NUMBER_OF_COPIES = 'ASK_NUMBER_OF_COPIES',
|
||||
LINEAR_WAITING_FOR_START_POINT = 'LINEAR_WAITING_FOR_START_POINT',
|
||||
LINEAR_WAITING_FOR_END_POINT = 'LINEAR_WAITING_FOR_END_POINT',
|
||||
RADIAL_WAITING_FOR_PIVOT_POINT = 'RADIAL_WAITING_FOR_PIVOT_POINT',
|
||||
RADIAL_WAITING_FOR_ANGLE = 'RADIAL_WAITING_FOR_ANGLE',
|
||||
EXECUTE_COPY = 'EXECUTE_COPY',
|
||||
}
|
||||
|
||||
export enum ArrayAction {
|
||||
INIT_ARRAY_TOOL = 'INIT_ARRAY_TOOL',
|
||||
PERFORM_COPY = 'PERFORM_COPY',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
DRAW_TEMP_DISTANCE_LINE = 'DRAW_TEMP_DISTANCE_LINE',
|
||||
}
|
||||
|
||||
export interface ArrayContext extends ToolContext {
|
||||
copyMode: CopyMode | null;
|
||||
startDistanceVector: Point | null;
|
||||
endDistanceVector: Point | null;
|
||||
pivotPoint: Point | null;
|
||||
angleStep: number | null;
|
||||
numberOfCopies: number;
|
||||
}
|
||||
|
||||
enum CopyMode {
|
||||
LINEAR = 'LINEAR',
|
||||
RADIAL = 'RADIAL',
|
||||
}
|
||||
|
||||
const initialArrayContext: ArrayContext = {
|
||||
copyMode: null,
|
||||
startDistanceVector: null,
|
||||
endDistanceVector: null,
|
||||
pivotPoint: null,
|
||||
angleStep: null,
|
||||
numberOfCopies: 0,
|
||||
type: Tool.ARRAY,
|
||||
};
|
||||
|
||||
export const arrayToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: ArrayContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: initialArrayContext,
|
||||
initial: ArrayState.INIT,
|
||||
states: {
|
||||
[ArrayState.INIT]: {
|
||||
description: 'Initializing the array copy tool',
|
||||
always: {
|
||||
actions: ArrayState.INIT,
|
||||
target: ArrayState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[ArrayState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to copy',
|
||||
meta: {
|
||||
instructions: 'Select what you want to copy, then ENTER',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheCopyTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(() => {
|
||||
return initialArrayContext;
|
||||
}),
|
||||
target: ArrayState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [ArrayAction.DESELECT_ENTITIES, ArrayAction.INIT_ARRAY_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: ArrayState.ASK_COPY_MODE,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: ArrayState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[ArrayState.ASK_COPY_MODE]: {
|
||||
meta: { instructions: 'Choose copy mode: Linear (L) or Radial (R), ENTER for Linear' },
|
||||
on: {
|
||||
TEXT_INPUT: {
|
||||
actions: assign(({ event }) => {
|
||||
return {
|
||||
copyMode:
|
||||
(event as TextInputEvent).value === 'R' ? CopyMode.RADIAL : CopyMode.LINEAR,
|
||||
};
|
||||
}),
|
||||
target: ArrayState.ASK_NUMBER_OF_COPIES,
|
||||
},
|
||||
ENTER: {
|
||||
actions: assign(() => ({
|
||||
copyMode: CopyMode.LINEAR,
|
||||
})),
|
||||
target: ArrayState.ASK_NUMBER_OF_COPIES,
|
||||
},
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.ASK_NUMBER_OF_COPIES]: {
|
||||
meta: { instructions: 'Enter number of copies' },
|
||||
on: {
|
||||
NUMBER_INPUT: [
|
||||
{
|
||||
guard: ({ context }) => {
|
||||
return context.copyMode === CopyMode.LINEAR;
|
||||
},
|
||||
actions: assign(({ event }) => ({
|
||||
numberOfCopies: (event as NumberInputEvent).value,
|
||||
})),
|
||||
target: ArrayState.LINEAR_WAITING_FOR_START_POINT,
|
||||
},
|
||||
{
|
||||
guard: ({ context }) => {
|
||||
return context.copyMode === CopyMode.RADIAL;
|
||||
},
|
||||
actions: assign(({ event }) => ({
|
||||
numberOfCopies: (event as NumberInputEvent).value,
|
||||
})),
|
||||
target: ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT,
|
||||
},
|
||||
],
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.LINEAR_WAITING_FOR_START_POINT]: {
|
||||
meta: { instructions: 'Click to define the start of the distance vector' },
|
||||
on: {
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: assign(({ event }) => ({
|
||||
startDistanceVector: (event as AbsolutePointInputEvent).value,
|
||||
})),
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ event }) => ({
|
||||
startDistanceVector: (event as MouseClickEvent).worldMouseLocation,
|
||||
})),
|
||||
target: ArrayState.LINEAR_WAITING_FOR_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.LINEAR_WAITING_FOR_END_POINT]: {
|
||||
meta: { instructions: 'Click to define the end of the distance vector' },
|
||||
on: {
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: assign(({ event, context }) => ({
|
||||
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
|
||||
})),
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: assign(({ event, context }) => ({
|
||||
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
|
||||
})),
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ event, context }) => ({
|
||||
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
|
||||
})),
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
},
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
DRAW: {
|
||||
actions: ArrayAction.DRAW_TEMP_DISTANCE_LINE,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT]: {
|
||||
meta: { instructions: 'Click to set pivot point' },
|
||||
on: {
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: assign(({ event }) => ({
|
||||
pivotPoint: (event as AbsolutePointInputEvent).value,
|
||||
})),
|
||||
target: ArrayState.RADIAL_WAITING_FOR_ANGLE,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ event }) => ({
|
||||
pivotPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
})),
|
||||
target: ArrayState.RADIAL_WAITING_FOR_ANGLE,
|
||||
},
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.RADIAL_WAITING_FOR_ANGLE]: {
|
||||
meta: { instructions: 'Enter angle per step (in degrees)' },
|
||||
on: {
|
||||
NUMBER_INPUT: {
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
actions: assign(({ event }) => ({
|
||||
angleStep: (event as NumberInputEvent).value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.EXECUTE_COPY]: {
|
||||
entry: ArrayAction.PERFORM_COPY,
|
||||
always: ArrayState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[ArrayAction.INIT_ARRAY_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
copyMode: null,
|
||||
distanceVector: null,
|
||||
pivotPoint: null,
|
||||
angleStep: null,
|
||||
numberOfCopies: 0,
|
||||
selectedEntities: [],
|
||||
};
|
||||
}),
|
||||
|
||||
[ArrayAction.DRAW_TEMP_DISTANCE_LINE]: ({ context, event }) => {
|
||||
if (!context.startDistanceVector) {
|
||||
throw new Error('[ARRAY] Calling draw temp distance line without a start point');
|
||||
}
|
||||
|
||||
const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
|
||||
// Draw the array entities to show the result
|
||||
// Draw all selected entities according to distance vector and number of copies, so the user gets visual feedback of where the entities will be copied;
|
||||
// TODO
|
||||
|
||||
// // Draw a dashed line between the start move point and the current mouse location
|
||||
const activeDistanceLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startDistanceVector,
|
||||
endPointTemp
|
||||
);
|
||||
activeDistanceLine.lineColor = GUIDE_LINE_COLOR;
|
||||
activeDistanceLine.lineWidth = GUIDE_LINE_WIDTH;
|
||||
activeDistanceLine.lineDash = GUIDE_LINE_STYLE;
|
||||
setGhostHelperEntities([activeDistanceLine]);
|
||||
},
|
||||
|
||||
[ArrayAction.PERFORM_COPY]: ({ context }) => {
|
||||
const resultEntities: Entity[] = [];
|
||||
|
||||
for (let i = 1; i <= context.numberOfCopies; i++) {
|
||||
if (
|
||||
context.copyMode === 'LINEAR' &&
|
||||
context.startDistanceVector &&
|
||||
context.endDistanceVector
|
||||
) {
|
||||
const distanceVector = new Vector(
|
||||
context.endDistanceVector.x - context.startDistanceVector.x,
|
||||
context.endDistanceVector.y - context.startDistanceVector.y
|
||||
);
|
||||
const x = distanceVector.x * i;
|
||||
const y = distanceVector.y * i;
|
||||
resultEntities.push(
|
||||
...getSelectedEntities().map((entity) => {
|
||||
const clone = entity.clone();
|
||||
clone.move(x, y);
|
||||
return clone;
|
||||
})
|
||||
);
|
||||
} else if (context.copyMode === 'RADIAL' && context.pivotPoint && context.angleStep) {
|
||||
const angleRad = context.angleStep * i * TO_RADIANS;
|
||||
resultEntities.push(
|
||||
...getSelectedEntities().map((entity) => {
|
||||
const clone = entity.clone();
|
||||
clone.rotate(context.pivotPoint as Point, angleRad);
|
||||
return clone;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
addEntities(resultEntities, true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
[CopyAction.DESELECT_ENTITIES]: assign(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return initialArrayContext;
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,164 @@
|
||||
import { CircleEntity } from '../entities/CircleEntity';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import { Tool } from '../tools';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { pointDistance } from '../helpers/distance-between-points';
|
||||
import { LineState } from './line-tool.ts';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
||||
|
||||
export interface CircleContext extends ToolContext {
|
||||
centerPoint: Point | null;
|
||||
}
|
||||
|
||||
export enum CircleState {
|
||||
WAITING_FOR_CENTER_POINT = 'WAITING_FOR_CENTER_POINT',
|
||||
WAITING_FOR_POINT_ON_CIRCLE = 'WAITING_FOR_POINT_ON_CIRCLE',
|
||||
INIT = 'INIT',
|
||||
}
|
||||
|
||||
export enum CircleAction {
|
||||
INIT_CIRCLE_TOOL = 'INIT_CIRCLE_TOOL',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
DRAW_TEMP_CIRCLE = 'DRAW_TEMP_CIRCLE',
|
||||
DRAW_FINAL_CIRCLE = 'DRAW_FINAL_CIRCLE',
|
||||
}
|
||||
|
||||
export const circleToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: CircleContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
centerPoint: null,
|
||||
type: Tool.CIRCLE,
|
||||
},
|
||||
initial: CircleState.INIT,
|
||||
states: {
|
||||
[CircleState.INIT]: {
|
||||
description: 'Initializing the circle tool',
|
||||
always: {
|
||||
actions: CircleAction.INIT_CIRCLE_TOOL,
|
||||
target: CircleState.WAITING_FOR_CENTER_POINT,
|
||||
},
|
||||
},
|
||||
[CircleState.WAITING_FOR_CENTER_POINT]: {
|
||||
description: 'Select the center point of the circle tool',
|
||||
meta: {
|
||||
instructions: 'Select the center point of the circle',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: CircleAction.RECORD_START_POINT,
|
||||
target: CircleState.WAITING_FOR_POINT_ON_CIRCLE,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: CircleAction.RECORD_START_POINT,
|
||||
target: CircleState.WAITING_FOR_POINT_ON_CIRCLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
[CircleState.WAITING_FOR_POINT_ON_CIRCLE]: {
|
||||
description: 'Select a point on the circle',
|
||||
meta: {
|
||||
instructions: 'Select the point on the circle',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: CircleAction.DRAW_TEMP_CIRCLE,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: CircleAction.DRAW_FINAL_CIRCLE,
|
||||
target: CircleState.INIT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: CircleAction.DRAW_FINAL_CIRCLE,
|
||||
target: LineState.INIT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: CircleAction.DRAW_FINAL_CIRCLE,
|
||||
target: LineState.INIT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: CircleAction.DRAW_FINAL_CIRCLE,
|
||||
target: LineState.INIT,
|
||||
},
|
||||
ESC: {
|
||||
target: CircleState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[CircleAction.INIT_CIRCLE_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
centerPoint: null,
|
||||
};
|
||||
}),
|
||||
[CircleAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
centerPoint: startPoint,
|
||||
};
|
||||
}),
|
||||
[CircleAction.DRAW_TEMP_CIRCLE]: ({ context, event }) => {
|
||||
const activeCircle = new CircleEntity(
|
||||
getActiveLayerId(),
|
||||
context.centerPoint as Point,
|
||||
pointDistance(
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation(),
|
||||
context.centerPoint as Point
|
||||
)
|
||||
);
|
||||
activeCircle.lineColor = getActiveLineColor();
|
||||
activeCircle.lineWidth = getActiveLineWidth();
|
||||
activeCircle.lineDash = getActiveLineDash();
|
||||
setGhostHelperEntities([activeCircle]);
|
||||
},
|
||||
[CircleAction.DRAW_FINAL_CIRCLE]: assign(({ context, event }) => {
|
||||
if (!context.centerPoint) {
|
||||
throw new Error(
|
||||
'Trying to DRAW_FINAL_CIRCLE when centerPoint is not yet defined in circle tool'
|
||||
);
|
||||
}
|
||||
const pointOnCircle: Point = getPointFromEvent(
|
||||
context.centerPoint,
|
||||
event as PointInputEvent
|
||||
);
|
||||
const activeCircle = new CircleEntity(
|
||||
getActiveLayerId(),
|
||||
context.centerPoint as Point,
|
||||
pointDistance(pointOnCircle, context.centerPoint as Point)
|
||||
);
|
||||
activeCircle.lineColor = getActiveLineColor();
|
||||
activeCircle.lineWidth = getActiveLineWidth();
|
||||
activeCircle.lineDash = getActiveLineDash();
|
||||
addEntities([activeCircle], true);
|
||||
|
||||
setGhostHelperEntities([]);
|
||||
return {
|
||||
centerPoint: null,
|
||||
};
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,296 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import {Tool} from '../tools';
|
||||
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
|
||||
import {assign, createMachine, sendTo} from 'xstate';
|
||||
import {selectToolStateMachine} from './select-tool';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import {compact} from 'es-toolkit';
|
||||
import {LineEntity} from '../entities/LineEntity';
|
||||
import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH,} from '../App.consts';
|
||||
import {moveEntities} from './move-tool.helpers';
|
||||
|
||||
export interface CopyContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
originalSelectedEntities: Entity[];
|
||||
copiedEntities: Entity[];
|
||||
lastDrawLocation: Point | null;
|
||||
}
|
||||
|
||||
export enum CopyState {
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
WAITING_FOR_START_COPY_POINT = 'WAITING_FOR_START_COPY_POINT',
|
||||
WAITING_FOR_END_COPY_POINT = 'WAITING_FOR_END_COPY_POINT',
|
||||
}
|
||||
|
||||
export enum CopyAction {
|
||||
INIT_COPY_TOOL = 'INIT_COPY_TOOL',
|
||||
ENABLE_HELPERS = 'ENABLE_HELPERS',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
COPY_SELECTION_BEFORE_COPY = 'COPY_SELECTION_BEFORE_COPY',
|
||||
DRAW_TEMP_COPY_ENTITIES = 'DRAW_TEMP_COPY_ENTITIES',
|
||||
COPY_SELECTION = 'COPY_SELECTION',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy tool state machine
|
||||
* This state machine is responsible for copying entities around the canvas
|
||||
* It uses the select tool state machine to select entities to copy
|
||||
* When the user presses enter, the selected entities are marked for copying
|
||||
* The user can then select a start point
|
||||
* When the user moves their mouse, the selected entities will be updated to move according to the vector: startpoint => mouse location
|
||||
* When the user clicks again, the end point is selected and the entities are copied to the new location
|
||||
*/
|
||||
export const copyToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: CopyContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
copiedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
type: Tool.COPY,
|
||||
},
|
||||
initial: CopyState.INIT,
|
||||
states: {
|
||||
[CopyState.INIT]: {
|
||||
description: 'Initializing the copy tool',
|
||||
always: {
|
||||
actions: CopyAction.INIT_COPY_TOOL,
|
||||
target: CopyState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[CopyState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: CopyState.WAITING_FOR_START_COPY_POINT,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: CopyState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[CopyState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to copy',
|
||||
meta: {
|
||||
instructions: 'Select what you want to copy, then ENTER',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheCopyTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(() => {
|
||||
return {
|
||||
startPoint: null,
|
||||
};
|
||||
}),
|
||||
target: CopyState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [CopyAction.DESELECT_ENTITIES, CopyAction.INIT_COPY_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[CopyState.WAITING_FOR_START_COPY_POINT]: {
|
||||
description: 'Select the start of the copy line',
|
||||
meta: {
|
||||
instructions: 'Select the start of the copy line',
|
||||
},
|
||||
always: {
|
||||
actions: CopyAction.ENABLE_HELPERS,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
CopyAction.RECORD_START_POINT,
|
||||
CopyAction.COPY_SELECTION_BEFORE_COPY,
|
||||
],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: CopyAction.DESELECT_ENTITIES,
|
||||
target: CopyState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[CopyState.WAITING_FOR_END_COPY_POINT]: {
|
||||
description: 'Select the end of the copy line',
|
||||
meta: {
|
||||
instructions: 'Select the end of the copy line',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: [CopyAction.DRAW_TEMP_COPY_ENTITIES],
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [CopyAction.COPY_SELECTION],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: CopyAction.DESELECT_ENTITIES,
|
||||
target: CopyState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[CopyAction.INIT_COPY_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
copiedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
[CopyAction.ENABLE_HELPERS]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[CopyAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
|
||||
return {
|
||||
startPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
}),
|
||||
[CopyAction.COPY_SELECTION_BEFORE_COPY]: assign(({ context }) => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
|
||||
// Copy the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides
|
||||
setGhostHelperEntities(selectedEntities);
|
||||
|
||||
// TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being copied and the original entities also are used for snap points / angle guides
|
||||
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: context.startPoint,
|
||||
// Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action
|
||||
originalSelectedEntities: compact(
|
||||
selectedEntities.map(entity => entity.clone()),
|
||||
),
|
||||
copiedEntities: selectedEntities,
|
||||
};
|
||||
}),
|
||||
[CopyAction.DRAW_TEMP_COPY_ENTITIES]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[COPY] Calling draw temp copy line without a start point',
|
||||
);
|
||||
}
|
||||
|
||||
const endPointTemp = (
|
||||
event as DrawEvent
|
||||
).drawController.getWorldMouseLocation();
|
||||
|
||||
// Copy the entities to the new location
|
||||
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied;
|
||||
const movedEntities = context.originalSelectedEntities.map(entity =>
|
||||
entity.clone(),
|
||||
);
|
||||
moveEntities(
|
||||
movedEntities,
|
||||
endPointTemp.x - context.startPoint.x,
|
||||
endPointTemp.y - context.startPoint.y,
|
||||
);
|
||||
|
||||
// // Draw a dashed line between the start copy point and the current mouse location
|
||||
const activeCopyLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPointTemp,
|
||||
);
|
||||
activeCopyLine.lineColor = GUIDE_LINE_COLOR;
|
||||
activeCopyLine.lineWidth = GUIDE_LINE_WIDTH;
|
||||
activeCopyLine.lineDash = GUIDE_LINE_STYLE;
|
||||
setGhostHelperEntities([activeCopyLine, ...movedEntities]);
|
||||
},
|
||||
[CopyAction.COPY_SELECTION]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[COPY] Calling copy selection without a start point',
|
||||
);
|
||||
}
|
||||
|
||||
// Copy the entities one final time
|
||||
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
|
||||
const copiedEntities = context.originalSelectedEntities.map(entity =>
|
||||
entity.clone(),
|
||||
);
|
||||
moveEntities(
|
||||
copiedEntities,
|
||||
currentEndPoint.x - context.startPoint.x,
|
||||
currentEndPoint.y - context.startPoint.y,
|
||||
);
|
||||
|
||||
// Switch the copied entities back from the ghost helper entities to the real entities
|
||||
addEntities([...context.originalSelectedEntities, ...copiedEntities], true);
|
||||
},
|
||||
[CopyAction.DESELECT_ENTITIES]: assign(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
[CopyAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => {
|
||||
addEntities(context.originalSelectedEntities, false); // This should already be the last state of the undo stack
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,126 @@
|
||||
import {type Circle, Point, type Segment} from '@flatten-js/core';
|
||||
import {compact} from 'es-toolkit';
|
||||
import {ArcEntity} from '../entities/ArcEntity';
|
||||
import type {CircleEntity} from '../entities/CircleEntity';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import type {LineEntity} from '../entities/LineEntity';
|
||||
import {findNeighboringPointsOnArc} from '../helpers/find-neighboring-points-on-arc';
|
||||
import {findNeighboringPointsOnCircle} from '../helpers/find-neighboring-points-on-circle';
|
||||
import {findNeighboringPointsOnLine} from '../helpers/find-neighboring-points-on-line';
|
||||
import {getAngleWithXAxis} from '../helpers/get-angle-with-x-axis.ts';
|
||||
import {isPointEqual} from '../helpers/is-point-equal';
|
||||
import {addEntities, deleteEntities, getActiveLayerId} from '../state';
|
||||
|
||||
export function getAllIntersectionPoints(entity: Entity, entities: Entity[]): Point[] {
|
||||
// TODO see if we need to make this list unique
|
||||
return compact(
|
||||
entities
|
||||
.filter((otherEntity) => otherEntity.id !== entity.id)
|
||||
.flatMap((otherEntity) => {
|
||||
if (entity.id === otherEntity.id) {
|
||||
return null;
|
||||
}
|
||||
return entity.getIntersections(otherEntity);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function eraseLineSegment(
|
||||
line: LineEntity,
|
||||
clickedPointOnShape: Point,
|
||||
intersections: Point[]
|
||||
): void {
|
||||
const segment = line.getShape() as Segment;
|
||||
const [firstCutPoint, secondCutPoint] = findNeighboringPointsOnLine(
|
||||
clickedPointOnShape,
|
||||
segment.start,
|
||||
segment.end,
|
||||
intersections
|
||||
);
|
||||
|
||||
const cutLines: Entity[] = line.cutAtPoints([firstCutPoint, secondCutPoint]);
|
||||
// Remove the segment that has the clickedPointOnShape point on it
|
||||
const remainingLines = cutLines.filter((line) => !line.containsPointOnShape(clickedPointOnShape));
|
||||
deleteEntities([line], false);
|
||||
|
||||
// Helper functions should never trigger an undo state, since they can be called multiple times during one user operation
|
||||
addEntities(remainingLines, false);
|
||||
}
|
||||
|
||||
export function eraseCircleSegment(
|
||||
circle: CircleEntity,
|
||||
clickedPointOnShape: Point,
|
||||
intersections: Point[]
|
||||
): void {
|
||||
if (intersections.length === 0) {
|
||||
deleteEntities([circle], false);
|
||||
return;
|
||||
}
|
||||
|
||||
const [firstCutPoint, secondCutPoint] = findNeighboringPointsOnCircle(
|
||||
clickedPointOnShape,
|
||||
circle,
|
||||
intersections
|
||||
);
|
||||
|
||||
const circleShape = circle.getShape() as Circle;
|
||||
const center = circleShape.center;
|
||||
|
||||
const angles = [firstCutPoint, secondCutPoint, clickedPointOnShape].map((p) =>
|
||||
getAngleWithXAxis(new Point(center.x, center.y), new Point(p.x, p.y))
|
||||
);
|
||||
|
||||
const [startAngle, endAngle] = isAngleBetween(angles[2], angles[0], angles[1])
|
||||
? [angles[1], angles[0]]
|
||||
: [angles[0], angles[1]];
|
||||
|
||||
const newArc = new ArcEntity(
|
||||
getActiveLayerId(),
|
||||
center,
|
||||
circleShape.r,
|
||||
startAngle,
|
||||
endAngle,
|
||||
true
|
||||
);
|
||||
Object.assign(newArc, {
|
||||
lineColor: circle.lineColor,
|
||||
lineWidth: circle.lineWidth,
|
||||
});
|
||||
|
||||
deleteEntities([circle], false);
|
||||
|
||||
// Helper functions should never trigger an undo state, since they can be called multiple times during one user operation
|
||||
addEntities([newArc], false);
|
||||
}
|
||||
|
||||
function isAngleBetween(angle: number, start: number, end: number): boolean {
|
||||
const twoPi = 2 * Math.PI;
|
||||
return (angle - start + twoPi) % twoPi <= (end - start + twoPi) % twoPi;
|
||||
}
|
||||
|
||||
export function eraseArcSegment(
|
||||
arc: ArcEntity,
|
||||
clickedPointOnShape: Point,
|
||||
intersections: Point[]
|
||||
): void {
|
||||
const [first, second] = findNeighboringPointsOnArc(clickedPointOnShape, arc, intersections);
|
||||
|
||||
if (isPointEqual(first, second)) {
|
||||
deleteEntities([arc], true);
|
||||
return;
|
||||
}
|
||||
|
||||
const newArcs = arc
|
||||
.cutAtPoints([first, second])
|
||||
.filter((cutArc) => !cutArc.containsPointOnShape(clickedPointOnShape));
|
||||
for (const newArc of newArcs) {
|
||||
Object.assign(newArc, {
|
||||
lineColor: arc.lineColor,
|
||||
lineWidth: arc.lineWidth,
|
||||
});
|
||||
addEntities([newArc], false);
|
||||
}
|
||||
|
||||
// Helper functions should never trigger an undo state, since they can be called multiple times during one user operation
|
||||
deleteEntities([arc], false);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {type Arc, Point} from '@flatten-js/core';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {TO_DEGREES, TO_RADIANS} from '../App.consts.ts';
|
||||
import type {ArcEntity} from '../entities/ArcEntity.ts';
|
||||
import {CircleEntity} from '../entities/CircleEntity.ts';
|
||||
import {EntityName} from '../entities/Entity.ts';
|
||||
import {RectangleEntity} from '../entities/RectangleEntity.ts';
|
||||
import {getEntities, setEntities} from '../state.ts';
|
||||
import {eraseCircleSegment, getAllIntersectionPoints,} from './eraser-tool.helpers.ts';
|
||||
import {handleMouseClick} from './eraser-tool.ts';
|
||||
|
||||
describe('erase-tool', () => {
|
||||
/**
|
||||
* ---
|
||||
* --- ---
|
||||
* -- --
|
||||
* - -------------------------
|
||||
* -- | |
|
||||
* --- | x |
|
||||
* --| |
|
||||
* | |
|
||||
* | |
|
||||
* | |
|
||||
* | |
|
||||
* -------------------------
|
||||
*/
|
||||
it('should delete part of circle to form an arc', () => {
|
||||
const entities = [
|
||||
new RectangleEntity('layer1', new Point(0, 0), new Point(20, -20)),
|
||||
new CircleEntity('layer1', new Point(0, 0), 10),
|
||||
];
|
||||
setEntities(entities);
|
||||
handleMouseClick(new Point(8, -8));
|
||||
const entitiesAfterErase = getEntities();
|
||||
expect(entitiesAfterErase[0].getType()).toEqual(EntityName.Rectangle);
|
||||
expect(entitiesAfterErase[1].getType()).toEqual(EntityName.Arc);
|
||||
const arc = (entitiesAfterErase[1] as ArcEntity).getShape() as Arc;
|
||||
expect(arc.center).toEqual(new Point(0, 0));
|
||||
expect(arc.r).toEqual(10);
|
||||
expect(arc.startAngle * TO_DEGREES).toEqual(0);
|
||||
expect(arc.endAngle * TO_DEGREES).toEqual(270);
|
||||
});
|
||||
/**
|
||||
* ---
|
||||
* --- ---
|
||||
* -- --
|
||||
* - -------------------------
|
||||
* -- | |
|
||||
* --- | x |
|
||||
* --| |
|
||||
* | |
|
||||
* | |
|
||||
* | |
|
||||
* | |
|
||||
* -------------------------
|
||||
*/
|
||||
it('should delete part of circle to form an arc using inner functions', () => {
|
||||
const entities = [
|
||||
new RectangleEntity('layer1', new Point(0, 0), new Point(20, -20)),
|
||||
new CircleEntity('layer1', new Point(0, 0), 10),
|
||||
];
|
||||
setEntities(entities);
|
||||
|
||||
const intersections = getAllIntersectionPoints(entities[1], getEntities());
|
||||
expect(intersections[0]).toEqual(new Point(0, -10));
|
||||
expect(intersections[1]).toEqual(new Point(10, 0));
|
||||
|
||||
eraseCircleSegment(
|
||||
entities[1] as CircleEntity,
|
||||
new Point(10 * Math.cos(-45 * TO_RADIANS), 10 * Math.sin(-45 * TO_RADIANS)),
|
||||
intersections
|
||||
);
|
||||
|
||||
const entitiesAfterErase = getEntities();
|
||||
expect(entitiesAfterErase[0].getType()).toEqual(EntityName.Rectangle);
|
||||
expect(entitiesAfterErase[1].getType()).toEqual(EntityName.Arc);
|
||||
const arc = (entitiesAfterErase[1] as ArcEntity).getShape() as Arc;
|
||||
expect(arc.center).toEqual(new Point(0, 0));
|
||||
expect(arc.r).toEqual(10);
|
||||
expect(arc.startAngle * TO_DEGREES).toEqual(0);
|
||||
expect(arc.endAngle * TO_DEGREES).toEqual(270);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {Point, Polygon} from '@flatten-js/core';
|
||||
import {assign, createMachine} from 'xstate';
|
||||
import type {ArcEntity} from '../entities/ArcEntity';
|
||||
import type {CircleEntity} from '../entities/CircleEntity';
|
||||
import {EntityName} from '../entities/Entity';
|
||||
import {LineEntity} from '../entities/LineEntity';
|
||||
import type {RectangleEntity} from '../entities/RectangleEntity';
|
||||
import {findClosestEntity} from '../helpers/find-closest-entity';
|
||||
import {polygonToSegments} from '../helpers/polygon-to-segments';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getActiveLayerId,
|
||||
getEntities,
|
||||
setEntities,
|
||||
setGhostHelperEntities,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import {Tool} from '../tools';
|
||||
import {eraseArcSegment, eraseCircleSegment, eraseLineSegment, getAllIntersectionPoints,} from './eraser-tool.helpers';
|
||||
import type {MouseClickEvent, StateEvent, ToolContext} from './tool.types';
|
||||
|
||||
export interface EraserContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
}
|
||||
|
||||
export enum EraserState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_FIRST_CLICK = 'WAITING_FOR_FIRST_CLICK',
|
||||
}
|
||||
|
||||
export enum EraserAction {
|
||||
INIT_ERASER_TOOL = 'INIT_ERASER_TOOL',
|
||||
HANDLE_MOUSE_CLICK = 'HANDLE_MOUSE_CLICK',
|
||||
}
|
||||
|
||||
export const eraserToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: EraserContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
type: Tool.ERASER,
|
||||
},
|
||||
initial: EraserState.INIT,
|
||||
states: {
|
||||
[EraserState.INIT]: {
|
||||
description: 'Initializing the eraser tool',
|
||||
always: {
|
||||
actions: EraserAction.INIT_ERASER_TOOL,
|
||||
target: EraserState.WAITING_FOR_FIRST_CLICK,
|
||||
},
|
||||
},
|
||||
[EraserState.WAITING_FOR_FIRST_CLICK]: {
|
||||
description: 'Select a line segment to delete',
|
||||
meta: {
|
||||
instructions: 'Select a line segment to delete',
|
||||
},
|
||||
on: {
|
||||
// TODO implement a DRAW action to draw the segment that will be erased in dotted line
|
||||
MOUSE_CLICK: {
|
||||
actions: EraserAction.HANDLE_MOUSE_CLICK,
|
||||
target: EraserState.WAITING_FOR_FIRST_CLICK,
|
||||
},
|
||||
},
|
||||
},
|
||||
// TODO implement rectangle selection to delete
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[EraserAction.INIT_ERASER_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
return {};
|
||||
}),
|
||||
[EraserAction.HANDLE_MOUSE_CLICK]: assign(({ context, event }) => {
|
||||
handleMouseClick((event as MouseClickEvent).worldMouseLocation);
|
||||
|
||||
return context;
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export function handleMouseClick(worldMouseLocation: Point) {
|
||||
const closestEntity = findClosestEntity(worldMouseLocation, getEntities());
|
||||
if (!closestEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clickedPointOnShape = closestEntity.segment.start;
|
||||
|
||||
// Find entities that intersect with the closest entity
|
||||
const intersections = getAllIntersectionPoints(closestEntity.entity, getEntities());
|
||||
|
||||
const entityType = closestEntity.entity.getType();
|
||||
switch (entityType) {
|
||||
case EntityName.Line: {
|
||||
const line = closestEntity.entity as LineEntity;
|
||||
eraseLineSegment(line, clickedPointOnShape, intersections);
|
||||
break;
|
||||
}
|
||||
|
||||
case EntityName.Circle: {
|
||||
const circle = closestEntity.entity as CircleEntity;
|
||||
eraseCircleSegment(circle, clickedPointOnShape, intersections);
|
||||
break;
|
||||
}
|
||||
|
||||
case EntityName.Arc: {
|
||||
const arc = closestEntity.entity as ArcEntity;
|
||||
eraseArcSegment(arc, clickedPointOnShape, intersections);
|
||||
break;
|
||||
}
|
||||
|
||||
case EntityName.Rectangle: {
|
||||
const rectangle = closestEntity.entity as RectangleEntity;
|
||||
const segments = polygonToSegments(rectangle.getShape() as Polygon);
|
||||
const segmentEntities = segments.map(
|
||||
(segment) => new LineEntity(getActiveLayerId(), segment)
|
||||
);
|
||||
|
||||
// Find the closest segment to the clicked point
|
||||
const closestSegmentInfo = findClosestEntity(worldMouseLocation, segmentEntities);
|
||||
|
||||
// Remove the rectangle
|
||||
deleteEntities([rectangle], false);
|
||||
|
||||
// Add 4 lines instead of the rectangle
|
||||
addEntities(segmentEntities, false);
|
||||
|
||||
// Remove (a segment) of the 4th line closest to the cursor
|
||||
const lineIntersections = getAllIntersectionPoints(closestSegmentInfo.entity, getEntities());
|
||||
eraseLineSegment(
|
||||
closestSegmentInfo.entity as LineEntity,
|
||||
clickedPointOnShape,
|
||||
lineIntersections
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case EntityName.Image: {
|
||||
// TODO implement image eraser
|
||||
// Switch to rectangle entity and delete the image and the line that was closest to the cursor
|
||||
}
|
||||
}
|
||||
|
||||
setEntities(getEntities(), true); // Force a new undo state entry
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {Box, type Point} from '@flatten-js/core';
|
||||
|
||||
export function getContainRectangleInsideRectangle(
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
rectangleStartPoint: Point,
|
||||
rectangleEndPoint: Point
|
||||
): Box | null {
|
||||
const width = Math.abs(rectangleStartPoint.x - rectangleEndPoint.x);
|
||||
const height = Math.abs(rectangleStartPoint.y - rectangleEndPoint.y);
|
||||
|
||||
if (width === 0 || height === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageAspectRatio = imageWidth / imageHeight;
|
||||
const cursorRectangleAspectRatio = width / height;
|
||||
|
||||
// Draw the image to contain the rectangle created by rectangleStartPoint and rectangleEndPoint
|
||||
if (imageAspectRatio < cursorRectangleAspectRatio) {
|
||||
const newWidth = Math.abs(height * imageAspectRatio);
|
||||
|
||||
const drawX =
|
||||
rectangleStartPoint.x < rectangleEndPoint.x
|
||||
? rectangleStartPoint.x
|
||||
: rectangleStartPoint.x - newWidth;
|
||||
const drawY =
|
||||
rectangleStartPoint.y < rectangleEndPoint.y
|
||||
? rectangleStartPoint.y
|
||||
: rectangleStartPoint.y - height;
|
||||
|
||||
return new Box(drawX, drawY, drawX + newWidth, drawY + height);
|
||||
}
|
||||
const newHeight = Math.abs(width / imageAspectRatio);
|
||||
|
||||
const drawX =
|
||||
rectangleStartPoint.x < rectangleEndPoint.x
|
||||
? rectangleStartPoint.x
|
||||
: rectangleStartPoint.x - width;
|
||||
const drawY =
|
||||
rectangleStartPoint.y < rectangleEndPoint.y
|
||||
? rectangleStartPoint.y
|
||||
: rectangleStartPoint.y - newHeight;
|
||||
|
||||
return new Box(drawX, drawY, drawX + width, drawY + newHeight);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
setActiveToolActor,
|
||||
setAngleGuideEntities,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import {Tool} from '../tools';
|
||||
import {Actor, assign, createMachine} from 'xstate';
|
||||
import {ActorEvent, type DrawEvent, type FileSelectedEvent, type MouseClickEvent, type PointInputEvent, type StateEvent, type ToolContext,} from './tool.types';
|
||||
import {ImageEntity} from '../entities/ImageEntity';
|
||||
import {getContainRectangleInsideRectangle} from './image-import-tool.helpers';
|
||||
import {RectangleEntity} from '../entities/RectangleEntity';
|
||||
import {selectToolStateMachine} from './select-tool';
|
||||
import {boxToPolygon, twoPointBoxToPolygon} from '../helpers/box-to-polygon';
|
||||
import {isPointEqual} from '../helpers/is-point-equal.ts';
|
||||
import {getPointFromEvent} from '../helpers/get-point-from-event.ts';
|
||||
|
||||
export interface ImageImportContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
imageElement: HTMLImageElement | null;
|
||||
}
|
||||
|
||||
export enum ImageImportState {
|
||||
INIT = 'INIT',
|
||||
WAIT_FOR_IMAGE_DATA = 'WAIT_FOR_IMAGE_DATA',
|
||||
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
|
||||
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
|
||||
}
|
||||
|
||||
export enum ImageImportAction {
|
||||
INIT_IMAGE_IMPORT_TOOL = 'INIT_IMAGE_IMPORT_TOOL',
|
||||
STORE_IMAGE_DATA = 'STORE_IMAGE_DATA',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
DRAW_TEMP_IMAGE_IMPORT = 'DRAW_TEMP_IMAGE_IMPORT',
|
||||
DRAW_FINAL_IMAGE_IMPORT = 'DRAW_FINAL_IMAGE_IMPORT',
|
||||
SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL',
|
||||
}
|
||||
|
||||
export const imageImportToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: ImageImportContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
type: Tool.IMAGE_IMPORT,
|
||||
startPoint: null,
|
||||
imageElement: null,
|
||||
},
|
||||
initial: ImageImportState.INIT,
|
||||
states: {
|
||||
[ImageImportState.INIT]: {
|
||||
description: 'Initializing the imageImport tool',
|
||||
always: {
|
||||
actions: ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
target: ImageImportState.WAIT_FOR_IMAGE_DATA,
|
||||
},
|
||||
},
|
||||
[ImageImportState.WAIT_FOR_IMAGE_DATA]: {
|
||||
description: 'Select an image file to import',
|
||||
meta: {
|
||||
instructions: 'Select an image file to import',
|
||||
},
|
||||
on: {
|
||||
[ActorEvent.FILE_SELECTED]: {
|
||||
actions: ImageImportAction.STORE_IMAGE_DATA,
|
||||
target: ImageImportState.WAITING_FOR_START_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ImageImportState.WAITING_FOR_START_POINT]: {
|
||||
description: 'Select the start point of the imageImport',
|
||||
meta: {
|
||||
instructions: 'Select the start point of the imageImport',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: ImageImportAction.RECORD_START_POINT,
|
||||
target: ImageImportState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: ImageImportAction.RECORD_START_POINT,
|
||||
target: ImageImportState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ImageImportState.WAITING_FOR_END_POINT]: {
|
||||
description: 'Select the end point of the imageImport',
|
||||
meta: {
|
||||
instructions: 'Select the end point of the imageImport',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ImageImportAction.DRAW_TEMP_IMAGE_IMPORT,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
|
||||
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
],
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: [
|
||||
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
|
||||
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
],
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: [
|
||||
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
|
||||
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
],
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: [
|
||||
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
|
||||
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
],
|
||||
},
|
||||
ESC: {
|
||||
actions: ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[ImageImportAction.INIT_IMAGE_IMPORT_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
imageElement: null,
|
||||
};
|
||||
}),
|
||||
[ImageImportAction.STORE_IMAGE_DATA]: assign(({ event }) => {
|
||||
return {
|
||||
imageElement: (event as FileSelectedEvent).image,
|
||||
};
|
||||
}),
|
||||
[ImageImportAction.RECORD_START_POINT]: assign(({ context, event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
...context,
|
||||
startPoint: startPoint,
|
||||
};
|
||||
}),
|
||||
[ImageImportAction.DRAW_TEMP_IMAGE_IMPORT]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT',
|
||||
);
|
||||
}
|
||||
if (!context.imageElement) {
|
||||
throw new Error(
|
||||
'[IMAGE_IMPORT] imageElement is not set when calling DRAW_TEMP_IMAGE_IMPORT',
|
||||
);
|
||||
}
|
||||
if (
|
||||
isPointEqual(
|
||||
context.startPoint,
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation(),
|
||||
)
|
||||
) {
|
||||
return; // Can't draw an image that is 0 pixels wide
|
||||
}
|
||||
const endPoint = getPointFromEvent(
|
||||
context.startPoint,
|
||||
event as PointInputEvent,
|
||||
);
|
||||
const containRectangle = getContainRectangleInsideRectangle(
|
||||
context.imageElement.naturalWidth,
|
||||
context.imageElement.naturalHeight,
|
||||
context.startPoint,
|
||||
endPoint,
|
||||
);
|
||||
if (!containRectangle) {
|
||||
return;
|
||||
}
|
||||
const activeImage = new ImageEntity(
|
||||
getActiveLayerId(),
|
||||
context.imageElement,
|
||||
containRectangle.low,
|
||||
containRectangle.high,
|
||||
0,
|
||||
);
|
||||
const draggedRectangle = new RectangleEntity(
|
||||
getActiveLayerId(),
|
||||
twoPointBoxToPolygon(context.startPoint, endPoint),
|
||||
);
|
||||
setGhostHelperEntities([activeImage]);
|
||||
setAngleGuideEntities([draggedRectangle]);
|
||||
},
|
||||
[ImageImportAction.DRAW_FINAL_IMAGE_IMPORT]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT',
|
||||
);
|
||||
}
|
||||
if (!context.imageElement) {
|
||||
throw new Error(
|
||||
'[IMAGE_IMPORT] imageArrayBuffer is not set when calling DRAW_TEMP_IMAGE_IMPORT',
|
||||
);
|
||||
}
|
||||
|
||||
const containRectangle = getContainRectangleInsideRectangle(
|
||||
context.imageElement.naturalWidth,
|
||||
context.imageElement.naturalHeight,
|
||||
context.startPoint,
|
||||
(event as MouseClickEvent).worldMouseLocation,
|
||||
);
|
||||
|
||||
if (!containRectangle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeImage = new ImageEntity(
|
||||
getActiveLayerId(),
|
||||
context.imageElement,
|
||||
boxToPolygon(containRectangle),
|
||||
);
|
||||
addEntities([activeImage], true);
|
||||
},
|
||||
[ImageImportAction.SWITCH_TO_SELECT_TOOL]: () => {
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
setActiveToolActor,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { Actor, assign, createMachine } from 'xstate';
|
||||
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import { selectToolStateMachine } from './select-tool.ts';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
||||
|
||||
export interface LineContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
}
|
||||
|
||||
export enum LineState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
|
||||
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
|
||||
}
|
||||
|
||||
export enum LineAction {
|
||||
INIT_LINE_TOOL = 'INIT_LINE_TOOL',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
DRAW_TEMP_LINE = 'DRAW_TEMP_LINE',
|
||||
DRAW_FINAL_LINE = 'DRAW_FINAL_LINE',
|
||||
SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL',
|
||||
}
|
||||
|
||||
export const lineToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: LineContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
type: Tool.LINE,
|
||||
},
|
||||
initial: LineState.INIT,
|
||||
states: {
|
||||
[LineState.INIT]: {
|
||||
description: 'Initializing the line tool',
|
||||
always: {
|
||||
actions: LineAction.INIT_LINE_TOOL,
|
||||
target: LineState.WAITING_FOR_START_POINT,
|
||||
},
|
||||
},
|
||||
[LineState.WAITING_FOR_START_POINT]: {
|
||||
description: 'Select the start point of the line',
|
||||
meta: {
|
||||
instructions: 'Select the start point of the line',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: LineAction.RECORD_START_POINT,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: LineAction.RECORD_START_POINT,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: LineAction.SWITCH_TO_SELECT_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[LineState.WAITING_FOR_END_POINT]: {
|
||||
description: 'Select the end point of the line',
|
||||
meta: {
|
||||
instructions: 'Select the end point of the line',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: LineAction.DRAW_TEMP_LINE,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: LineAction.DRAW_FINAL_LINE,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: LineAction.DRAW_FINAL_LINE,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: LineAction.DRAW_FINAL_LINE,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: LineAction.DRAW_FINAL_LINE,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
target: LineState.INIT,
|
||||
},
|
||||
ENTER: {
|
||||
target: LineState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[LineAction.INIT_LINE_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setSelectedEntityIds([]);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
};
|
||||
}),
|
||||
[LineAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
startPoint,
|
||||
};
|
||||
}),
|
||||
[LineAction.DRAW_TEMP_LINE]: ({ context, event }) => {
|
||||
const activeLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation()
|
||||
);
|
||||
activeLine.lineColor = getActiveLineColor();
|
||||
activeLine.lineWidth = getActiveLineWidth();
|
||||
activeLine.lineDash = getActiveLineDash();
|
||||
setGhostHelperEntities([activeLine]);
|
||||
},
|
||||
[LineAction.DRAW_FINAL_LINE]: assign(({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error('Start point is not set during DRAW_FINAL_LINE in LineEntity');
|
||||
}
|
||||
|
||||
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
|
||||
const activeLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPoint
|
||||
);
|
||||
activeLine.lineColor = getActiveLineColor();
|
||||
activeLine.lineWidth = getActiveLineWidth();
|
||||
activeLine.lineDash = getActiveLineDash();
|
||||
addEntities([activeLine], true);
|
||||
|
||||
// Keep drawing from the last point
|
||||
setGhostHelperEntities([new LineEntity(getActiveLayerId(), endPoint, endPoint)]);
|
||||
setAngleGuideOriginPoint(endPoint);
|
||||
return {
|
||||
startPoint: endPoint,
|
||||
};
|
||||
}),
|
||||
[LineAction.SWITCH_TO_SELECT_TOOL]: () => {
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,221 @@
|
||||
import { type Point, Vector } from '@flatten-js/core';
|
||||
import { MeasurementEntity } from '../entities/MeasurementEntity';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import { MEASUREMENT_DEFAULT_OFFSET, TO_RADIANS } from '../App.consts';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
||||
import { isPointEqual } from '../helpers/is-point-equal.ts';
|
||||
|
||||
export interface MeasurementContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
endPoint: Point | null;
|
||||
}
|
||||
|
||||
export enum MeasurementState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
|
||||
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
|
||||
WAITING_FOR_OFFSET = 'WAITING_FOR_OFFSET',
|
||||
}
|
||||
|
||||
export enum MeasurementAction {
|
||||
INIT_MEASUREMENT_TOOL = 'INIT_MEASUREMENT_TOOL',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
RECORD_END_POINT = 'RECORD_END_POINT',
|
||||
DRAW_TEMP_MEASUREMENT = 'DRAW_TEMP_MEASUREMENT',
|
||||
DRAW_FINAL_MEASUREMENT = 'DRAW_FINAL_MEASUREMENT',
|
||||
}
|
||||
|
||||
export const measurementToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: MeasurementContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
endPoint: null,
|
||||
type: Tool.MEASUREMENT,
|
||||
},
|
||||
initial: MeasurementState.INIT,
|
||||
states: {
|
||||
[MeasurementState.INIT]: {
|
||||
description: 'Initializing the line tool',
|
||||
always: {
|
||||
actions: MeasurementAction.INIT_MEASUREMENT_TOOL,
|
||||
target: MeasurementState.WAITING_FOR_START_POINT,
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_START_POINT]: {
|
||||
description: 'Select the start point of the measurement',
|
||||
meta: {
|
||||
instructions: 'Select the start point of the measurement',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.RECORD_START_POINT,
|
||||
target: MeasurementState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_START_POINT,
|
||||
target: MeasurementState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_END_POINT]: {
|
||||
description: 'Select the end point of the measurement',
|
||||
meta: {
|
||||
instructions: 'Select the end point of the measurement',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: MeasurementAction.DRAW_TEMP_MEASUREMENT,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
ESC: {
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_OFFSET]: {
|
||||
description: 'Select the offset to display the measurement at',
|
||||
meta: {
|
||||
instructions: 'Select the offset to display the measurement at',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: MeasurementAction.DRAW_TEMP_MEASUREMENT,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
ESC: {
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[MeasurementAction.INIT_MEASUREMENT_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setSelectedEntityIds([]);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
endPoint: null,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
startPoint,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.RECORD_END_POINT]: assign(({ context, event }) => {
|
||||
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(endPoint);
|
||||
return {
|
||||
...context,
|
||||
endPoint,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.DRAW_TEMP_MEASUREMENT]: ({ context, event }) => {
|
||||
const startPoint = context.startPoint as Point;
|
||||
|
||||
let endPoint: Point;
|
||||
let offsetPoint: Point;
|
||||
if (!context.endPoint) {
|
||||
// User has drawn startPoint, but not yet endPoint
|
||||
// Endpoint should be the mouse location and offset should be MEASUREMENT_DEFAULT_OFFSET to either direction
|
||||
endPoint = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
|
||||
if (isPointEqual(startPoint, endPoint)) {
|
||||
return; // Cannot draw temp measurement when start and endpoint are equal
|
||||
}
|
||||
|
||||
const normalVector = new Vector(startPoint, endPoint)
|
||||
.rotate(-90 * TO_RADIANS)
|
||||
.normalize();
|
||||
// Pixel constant → world units so the default offset is zoom-independent
|
||||
const worldFactor = (event as DrawEvent).drawController.getScreenScale() || 1;
|
||||
offsetPoint = startPoint
|
||||
.clone()
|
||||
.translate(normalVector.multiply(MEASUREMENT_DEFAULT_OFFSET / worldFactor));
|
||||
} else {
|
||||
// User has already selected a startPoint and endPoint
|
||||
// The offsetPoint should be set to the mouse location
|
||||
endPoint = context.endPoint as Point;
|
||||
offsetPoint = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
}
|
||||
|
||||
const activeMeasurement = new MeasurementEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPoint,
|
||||
offsetPoint
|
||||
);
|
||||
activeMeasurement.lineColor = getActiveLineColor();
|
||||
activeMeasurement.lineWidth = getActiveLineWidth();
|
||||
activeMeasurement.lineDash = getActiveLineDash();
|
||||
setGhostHelperEntities([activeMeasurement]);
|
||||
},
|
||||
[MeasurementAction.DRAW_FINAL_MEASUREMENT]: ({ context, event }) => {
|
||||
const offsetPoint = getPointFromEvent(context.endPoint, event as PointInputEvent);
|
||||
const activeMeasurement = new MeasurementEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
context.endPoint as Point,
|
||||
offsetPoint
|
||||
);
|
||||
activeMeasurement.lineColor = getActiveLineColor();
|
||||
activeMeasurement.lineWidth = getActiveLineWidth();
|
||||
activeMeasurement.lineDash = getActiveLineDash();
|
||||
addEntities([activeMeasurement], true);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {Entity} from '../entities/Entity';
|
||||
|
||||
/**
|
||||
* Move entities by the difference between the start and end points
|
||||
* @param entities
|
||||
* @param deltaX
|
||||
* @param deltaY
|
||||
*/
|
||||
export function moveEntities(entities: Entity[], deltaX: number, deltaY: number) {
|
||||
for (const entity of entities) {
|
||||
entity.move(deltaX, deltaY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getActiveLayerId,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import {Tool} from '../tools';
|
||||
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
|
||||
import {assign, createMachine, sendTo} from 'xstate';
|
||||
import {selectToolStateMachine} from './select-tool';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import {compact} from 'es-toolkit';
|
||||
import {moveEntities} from './move-tool.helpers';
|
||||
import {LineEntity} from '../entities/LineEntity';
|
||||
import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH,} from '../App.consts';
|
||||
|
||||
export interface MoveContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
originalSelectedEntities: Entity[];
|
||||
movedEntities: Entity[];
|
||||
lastDrawLocation: Point | null;
|
||||
}
|
||||
|
||||
export enum MoveState {
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
WAITING_FOR_START_MOVE_POINT = 'WAITING_FOR_START_MOVE_POINT',
|
||||
WAITING_FOR_END_MOVE_POINT = 'WAITING_FOR_END_MOVE_POINT',
|
||||
}
|
||||
|
||||
export enum MoveAction {
|
||||
INIT_MOVE_TOOL = 'INIT_MOVE_TOOL',
|
||||
ENABLE_HELPERS = 'ENABLE_HELPERS',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
COPY_SELECTION_BEFORE_MOVE = 'COPY_SELECTION_BEFORE_MOVE',
|
||||
DRAW_TEMP_MOVE_ENTITIES = 'DRAW_TEMP_MOVE_ENTITIES',
|
||||
MOVE_SELECTION = 'MOVE_SELECTION',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
|
||||
}
|
||||
|
||||
/**
|
||||
* Move tool state machine
|
||||
* This state machine is responsible for moving entities around the canvas
|
||||
* It uses the select tool state machine to select entities to move
|
||||
* When the user presses enter, the selected entities are marked for moving
|
||||
* The user can then select a start point
|
||||
* When the user moves their mouse, the selected entities will be updated to move according to the vector: startpoint => mouse location
|
||||
* When the user clicks again, the end point is selected and the entities are moved to the new location
|
||||
*/
|
||||
export const moveToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: MoveContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
movedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
type: Tool.MOVE,
|
||||
},
|
||||
initial: MoveState.INIT,
|
||||
states: {
|
||||
[MoveState.INIT]: {
|
||||
description: 'Initializing the move tool',
|
||||
always: {
|
||||
actions: MoveAction.INIT_MOVE_TOOL,
|
||||
target: MoveState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[MoveState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: MoveState.WAITING_FOR_START_MOVE_POINT,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: MoveState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[MoveState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to move',
|
||||
meta: {
|
||||
instructions: 'Select what you want to move, then ENTER',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheMoveTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(() => {
|
||||
return {
|
||||
startPoint: null,
|
||||
};
|
||||
}),
|
||||
target: MoveState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [MoveAction.DESELECT_ENTITIES, MoveAction.INIT_MOVE_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[MoveState.WAITING_FOR_START_MOVE_POINT]: {
|
||||
description: 'Select the start of the move line',
|
||||
meta: {
|
||||
instructions: 'Select the start of the move line',
|
||||
},
|
||||
always: {
|
||||
actions: MoveAction.ENABLE_HELPERS,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
MoveAction.RECORD_START_POINT,
|
||||
MoveAction.COPY_SELECTION_BEFORE_MOVE,
|
||||
],
|
||||
target: MoveState.WAITING_FOR_END_MOVE_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: MoveAction.DESELECT_ENTITIES,
|
||||
target: MoveState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[MoveState.WAITING_FOR_END_MOVE_POINT]: {
|
||||
description: 'Select the end of the move line',
|
||||
meta: {
|
||||
instructions: 'Select the end of the move line',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: [MoveAction.DRAW_TEMP_MOVE_ENTITIES],
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES],
|
||||
target: MoveState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
ESC: {
|
||||
actions: MoveAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: MoveState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[MoveAction.INIT_MOVE_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
movedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
[MoveAction.ENABLE_HELPERS]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[MoveAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
|
||||
return {
|
||||
startPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
}),
|
||||
[MoveAction.COPY_SELECTION_BEFORE_MOVE]: assign(({ context }) => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
|
||||
// Move the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides
|
||||
setGhostHelperEntities(selectedEntities);
|
||||
// Remove the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides
|
||||
deleteEntities(selectedEntities, false);
|
||||
|
||||
// TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being moved and the original entities also are used for snap points / angle guides
|
||||
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: context.startPoint,
|
||||
// Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action
|
||||
originalSelectedEntities: compact(
|
||||
selectedEntities.map(entity => entity.clone()),
|
||||
),
|
||||
movedEntities: selectedEntities,
|
||||
};
|
||||
}),
|
||||
[MoveAction.DRAW_TEMP_MOVE_ENTITIES]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[MOVE] Calling draw temp move line without a start point',
|
||||
);
|
||||
}
|
||||
|
||||
const endPointTemp = (
|
||||
event as DrawEvent
|
||||
).drawController.getWorldMouseLocation();
|
||||
|
||||
// Move the entities to the new location
|
||||
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved;
|
||||
const movedEntities = context.originalSelectedEntities.map(entity =>
|
||||
entity.clone(),
|
||||
);
|
||||
moveEntities(
|
||||
movedEntities,
|
||||
endPointTemp.x - context.startPoint.x,
|
||||
endPointTemp.y - context.startPoint.y,
|
||||
);
|
||||
|
||||
// // Draw a dashed line between the start move point and the current mouse location
|
||||
const activeMoveLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPointTemp,
|
||||
);
|
||||
activeMoveLine.lineColor = GUIDE_LINE_COLOR;
|
||||
activeMoveLine.lineWidth = GUIDE_LINE_WIDTH;
|
||||
activeMoveLine.lineDash = GUIDE_LINE_STYLE;
|
||||
setGhostHelperEntities([activeMoveLine, ...movedEntities]);
|
||||
},
|
||||
[MoveAction.MOVE_SELECTION]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[MOVE] Calling move selection without a start point',
|
||||
);
|
||||
}
|
||||
|
||||
// Move the entities one final time
|
||||
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
|
||||
moveEntities(
|
||||
context.originalSelectedEntities,
|
||||
currentEndPoint.x - context.startPoint.x,
|
||||
currentEndPoint.y - context.startPoint.y,
|
||||
);
|
||||
|
||||
// Switch the moved entities back from the ghost helper entities to the real entities
|
||||
addEntities(context.originalSelectedEntities, true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
[MoveAction.DESELECT_ENTITIES]: assign(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
[MoveAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => {
|
||||
addEntities(context.originalSelectedEntities, false); // This should already be the last state of the undo stack
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,138 @@
|
||||
import {toast} from 'react-toastify';
|
||||
import {assign, createMachine, sendTo} from 'xstate';
|
||||
import {PolyLineEntity} from '../entities/PolyLineEntity.ts';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getNotSelectedEntities,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setEntities,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import {Tool} from '../tools';
|
||||
import {selectToolStateMachine} from './select-tool';
|
||||
import type {StateEvent, ToolContext} from './tool.types';
|
||||
|
||||
export interface PeditContext extends ToolContext {}
|
||||
|
||||
export enum PeditState {
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
CONVERT_SELECTION_TO_POLYLINE = 'CONVERT_SELECTION_TO_POLYLINE',
|
||||
}
|
||||
|
||||
export enum PeditAction {
|
||||
INIT_PEDIT_TOOL = 'INIT_PEDIT_TOOL',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
CONVERT_SELECTION_TO_POLYLINE = 'CONVERT_SELECTION_TO_POLYLINE',
|
||||
}
|
||||
|
||||
/**
|
||||
* Pedit tool state machine
|
||||
* This state machine is responsible for combining entities into a polyline
|
||||
* It uses the select tool state machine to select entities to combine
|
||||
* When the user presses enter, the selected entities are combined into one polyline
|
||||
*/
|
||||
export const peditToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: PeditContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
type: Tool.PEDIT,
|
||||
},
|
||||
initial: PeditState.INIT,
|
||||
states: {
|
||||
[PeditState.INIT]: {
|
||||
description: 'Initializing the pedit tool',
|
||||
always: {
|
||||
actions: PeditAction.INIT_PEDIT_TOOL,
|
||||
target: PeditState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[PeditState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: PeditState.CONVERT_SELECTION_TO_POLYLINE,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: PeditState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[PeditState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select the entities that you want to combine into a polyline',
|
||||
meta: {
|
||||
instructions: 'Select what you want to combine into a polyline, then ENTER',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideThePeditTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
target: PeditState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideThePeditTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [PeditAction.DESELECT_ENTITIES, PeditAction.INIT_PEDIT_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideThePeditTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideThePeditTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[PeditState.CONVERT_SELECTION_TO_POLYLINE]: {
|
||||
always: {
|
||||
actions: PeditAction.CONVERT_SELECTION_TO_POLYLINE,
|
||||
target: PeditState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[PeditAction.INIT_PEDIT_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {};
|
||||
}),
|
||||
[PeditAction.CONVERT_SELECTION_TO_POLYLINE]: assign(() => {
|
||||
const newPolyLine = new PolyLineEntity(getActiveLayerId(), getSelectedEntities());
|
||||
const newEntities = [...getNotSelectedEntities(), newPolyLine];
|
||||
setEntities(newEntities, true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`Created polyline with ${newPolyLine.numberOfSegments()} segments`);
|
||||
return {};
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { RectangleEntity } from '../entities/RectangleEntity';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import { Tool } from '../tools';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
||||
|
||||
export interface RectangleContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
}
|
||||
|
||||
export enum RectangleState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
|
||||
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
|
||||
}
|
||||
|
||||
export enum RectangleAction {
|
||||
INIT_RECTANGLE_TOOL = 'INIT_RECTANGLE_TOOL',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
DRAW_TEMP_RECTANGLE = 'DRAW_TEMP_RECTANGLE',
|
||||
DRAW_FINAL_RECTANGLE = 'DRAW_FINAL_RECTANGLE',
|
||||
}
|
||||
|
||||
export const rectangleToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: RectangleContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
type: Tool.RECTANGLE,
|
||||
},
|
||||
initial: RectangleState.INIT,
|
||||
states: {
|
||||
[RectangleState.INIT]: {
|
||||
description: 'Initializing the rectangle tool',
|
||||
always: {
|
||||
actions: RectangleAction.INIT_RECTANGLE_TOOL,
|
||||
target: RectangleState.WAITING_FOR_START_POINT,
|
||||
},
|
||||
},
|
||||
[RectangleState.WAITING_FOR_START_POINT]: {
|
||||
description: 'Select the start point of the rectangle',
|
||||
meta: {
|
||||
instructions: 'Select the start point of the rectangle',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: RectangleAction.RECORD_START_POINT,
|
||||
target: RectangleState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: RectangleAction.RECORD_START_POINT,
|
||||
target: RectangleState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[RectangleState.WAITING_FOR_END_POINT]: {
|
||||
description: 'Select the end point of the rectangle',
|
||||
meta: {
|
||||
instructions: 'Select the end point of the rectangle',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: RectangleAction.DRAW_TEMP_RECTANGLE,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: RectangleAction.DRAW_FINAL_RECTANGLE, // TODO see if we want to add a flow where you enter the width and then the height if one of the dimensions of the "direction + distance" comes out to 0
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
ESC: {
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[RectangleAction.INIT_RECTANGLE_TOOL]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
},
|
||||
[RectangleAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
startPoint,
|
||||
};
|
||||
}),
|
||||
[RectangleAction.DRAW_TEMP_RECTANGLE]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error('[RECTANGLE]: calling draw without start point being set');
|
||||
}
|
||||
const activeRectangle = new RectangleEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation()
|
||||
);
|
||||
activeRectangle.lineColor = getActiveLineColor();
|
||||
activeRectangle.lineWidth = getActiveLineWidth();
|
||||
activeRectangle.lineDash = getActiveLineDash();
|
||||
setGhostHelperEntities([activeRectangle]);
|
||||
},
|
||||
[RectangleAction.DRAW_FINAL_RECTANGLE]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw Error(
|
||||
'Trying to DRAW_FINAL_RECTANGLE when startPoint is not defined in rectangle-tool'
|
||||
);
|
||||
}
|
||||
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
|
||||
|
||||
const activeRectangle = new RectangleEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPoint
|
||||
);
|
||||
activeRectangle.lineColor = getActiveLineColor();
|
||||
activeRectangle.lineWidth = getActiveLineWidth();
|
||||
activeRectangle.lineDash = getActiveLineDash();
|
||||
addEntities([activeRectangle], true);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import {Line, type Point} from '@flatten-js/core';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
|
||||
/**
|
||||
* Rotate entities round a base point by a certain angle
|
||||
* @param entities
|
||||
* @param rotateOrigin
|
||||
* @param startAnglePoint
|
||||
* @param endAnglePoint
|
||||
*/
|
||||
export function rotateEntities(
|
||||
entities: Entity[],
|
||||
rotateOrigin: Point,
|
||||
startAnglePoint: Point,
|
||||
endAnglePoint: Point
|
||||
) {
|
||||
const rotationAngle =
|
||||
new Line(rotateOrigin, endAnglePoint).slope - new Line(rotateOrigin, startAnglePoint).slope;
|
||||
for (const entity of entities) {
|
||||
entity.rotate(rotateOrigin, rotationAngle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import {Tool} from '../tools';
|
||||
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
|
||||
import {assign, createMachine, sendTo} from 'xstate';
|
||||
import {selectToolStateMachine} from './select-tool';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import {compact} from 'es-toolkit';
|
||||
import {rotateEntities} from './rotate-tool.helpers';
|
||||
|
||||
export interface RotateContext extends ToolContext {
|
||||
rotationOrigin: Point | null;
|
||||
angleStartPoint: Point | null;
|
||||
originalSelectedEntities: Entity[];
|
||||
}
|
||||
|
||||
export enum RotateState {
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
WAITING_FOR_ROTATION_ORIGIN = 'WAITING_FOR_ROTATION_ORIGIN',
|
||||
WAITING_FOR_ANGLE_START_POINT = 'WAITING_FOR_ANGLE_START_POINT',
|
||||
WAITING_FOR_ANGLE_END_POINT = 'WAITING_FOR_ANGLE_END_POINT',
|
||||
}
|
||||
|
||||
export enum RotateAction {
|
||||
INIT_ROTATE_TOOL = 'INIT_ROTATE_TOOL',
|
||||
ENABLE_HELPERS = 'ENABLE_HELPERS',
|
||||
RECORD_ROTATION_ORIGIN = 'RECORD_ROTATION_ORIGIN',
|
||||
RECORD_ROTATION_ANGLE_START_POINT = 'RECORD_ROTATION_ANGLE_START_POINT',
|
||||
COPY_SELECTION_BEFORE_ROTATE = 'COPY_SELECTION_BEFORE_ROTATE',
|
||||
DRAW_TEMP_ROTATE_ENTITIES = 'DRAW_TEMP_ROTATE_ENTITIES',
|
||||
ROTATE_SELECTION = 'ROTATE_SELECTION',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate tool state machine
|
||||
* This state machine is responsible for rotating entities around a point (rotation origin) by a certain angle
|
||||
* It uses the select tool state machine to select entities to rotate
|
||||
* When the user presses enter, the selected entities are marked for rotating
|
||||
* The user can then select a rotation origin point
|
||||
* The user can then select the start point of the rotation angle
|
||||
* When the user moves their mouse, the selected entities will be updated to rotate according to the angle: start angle point => rotation origin => mouse location
|
||||
* When the user clicks again, the angle is locked in and the entities are rotated around the rotation origin
|
||||
*/
|
||||
export const rotateToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: RotateContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
rotationOrigin: null,
|
||||
angleStartPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
type: Tool.ROTATE,
|
||||
},
|
||||
initial: RotateState.INIT,
|
||||
states: {
|
||||
[RotateState.INIT]: {
|
||||
description: 'Initializing the rotate tool',
|
||||
always: {
|
||||
actions: RotateAction.INIT_ROTATE_TOOL,
|
||||
target: RotateState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[RotateState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: RotateState.WAITING_FOR_ROTATION_ORIGIN,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: RotateState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[RotateState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to rotate',
|
||||
meta: {
|
||||
instructions: 'Select what you want to rotate, then ENTER',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheRotateTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(({ context }) => {
|
||||
return {
|
||||
...context,
|
||||
};
|
||||
}),
|
||||
target: RotateState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [
|
||||
RotateAction.DESELECT_ENTITIES,
|
||||
RotateAction.INIT_ROTATE_TOOL,
|
||||
],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[RotateState.WAITING_FOR_ROTATION_ORIGIN]: {
|
||||
description: 'Select the origin of the rotate operation',
|
||||
meta: {
|
||||
instructions: 'Select the origin of the rotate operation',
|
||||
},
|
||||
always: {
|
||||
actions: RotateAction.ENABLE_HELPERS,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [RotateAction.RECORD_ROTATION_ORIGIN],
|
||||
target: RotateState.WAITING_FOR_ANGLE_START_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: RotateAction.DESELECT_ENTITIES,
|
||||
target: RotateState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[RotateState.WAITING_FOR_ANGLE_START_POINT]: {
|
||||
description: 'Select the end of the base rotate line',
|
||||
meta: {
|
||||
instructions: 'Select the end of the base rotate line',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
RotateAction.RECORD_ROTATION_ANGLE_START_POINT,
|
||||
RotateAction.COPY_SELECTION_BEFORE_ROTATE,
|
||||
],
|
||||
target: RotateState.WAITING_FOR_ANGLE_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: RotateAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: RotateState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[RotateState.WAITING_FOR_ANGLE_END_POINT]: {
|
||||
description: 'Select the end of the rotate line',
|
||||
meta: {
|
||||
instructions: 'Select the end of the rotate line',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: [RotateAction.DRAW_TEMP_ROTATE_ENTITIES],
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
RotateAction.ROTATE_SELECTION,
|
||||
RotateAction.DESELECT_ENTITIES,
|
||||
],
|
||||
target: RotateState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
ESC: {
|
||||
actions: RotateAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: RotateState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[RotateAction.INIT_ROTATE_TOOL]: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
},
|
||||
[RotateAction.ENABLE_HELPERS]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[RotateAction.RECORD_ROTATION_ORIGIN]: assign(
|
||||
({ context, event }): RotateContext => {
|
||||
setAngleGuideOriginPoint(
|
||||
(event as MouseClickEvent).worldMouseLocation,
|
||||
);
|
||||
return {
|
||||
...context,
|
||||
rotationOrigin: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
},
|
||||
),
|
||||
[RotateAction.RECORD_ROTATION_ANGLE_START_POINT]: assign(
|
||||
({ context, event }): RotateContext => {
|
||||
return {
|
||||
...context,
|
||||
angleStartPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
},
|
||||
),
|
||||
[RotateAction.COPY_SELECTION_BEFORE_ROTATE]: assign(
|
||||
({ context }): RotateContext => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
|
||||
// Rotate the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides
|
||||
setGhostHelperEntities(selectedEntities);
|
||||
// Re-rotate the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides
|
||||
deleteEntities(selectedEntities, false);
|
||||
|
||||
// TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being rotated and the original entities also are used for snap points / angle guides
|
||||
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
...context,
|
||||
// Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action
|
||||
originalSelectedEntities: compact(
|
||||
selectedEntities.map(entity => entity.clone()),
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
[RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => {
|
||||
if (!context.rotationOrigin || !context.angleStartPoint) {
|
||||
throw new Error(
|
||||
'[ROTATE] Calling draw temp rotate entities without a base start point or base end point',
|
||||
);
|
||||
}
|
||||
|
||||
const angleEndpoint = (
|
||||
event as DrawEvent
|
||||
).drawController.getWorldMouseLocation();
|
||||
|
||||
// Draw all selected entities according to rotate vector, so the user gets visual feedback of where the entities will be end up after rotating
|
||||
const rotatedEntities = compact(
|
||||
context.originalSelectedEntities.map(entity => entity.clone()),
|
||||
);
|
||||
rotateEntities(
|
||||
rotatedEntities,
|
||||
context.rotationOrigin,
|
||||
context.angleStartPoint,
|
||||
angleEndpoint,
|
||||
);
|
||||
|
||||
setGhostHelperEntities(rotatedEntities);
|
||||
},
|
||||
[RotateAction.ROTATE_SELECTION]: ({ context, event }) => {
|
||||
if (!context.rotationOrigin || !context.angleStartPoint) {
|
||||
throw new Error(
|
||||
'[ROTATE] Calling rotate selection without some rotate vector endpoints',
|
||||
);
|
||||
}
|
||||
const angleEndpoint = (event as MouseClickEvent).worldMouseLocation;
|
||||
|
||||
// Rotate the entities one final time
|
||||
const rotatedEntities = compact(
|
||||
context.originalSelectedEntities.map(entity => entity.clone()),
|
||||
);
|
||||
rotateEntities(
|
||||
rotatedEntities,
|
||||
context.rotationOrigin,
|
||||
context.angleStartPoint,
|
||||
angleEndpoint,
|
||||
);
|
||||
|
||||
// Switch the rotated entities back from the ghost helper entities to the real entities
|
||||
addEntities(rotatedEntities, true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
[RotateAction.DESELECT_ENTITIES]: assign(({ context }): RotateContext => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
...context,
|
||||
rotationOrigin: null,
|
||||
angleStartPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
};
|
||||
}),
|
||||
[RotateAction.RESTORE_ORIGINAL_ENTITIES]: assign(
|
||||
({ context }): RotateContext => {
|
||||
addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
...context,
|
||||
rotationOrigin: null,
|
||||
angleStartPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
};
|
||||
},
|
||||
),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import {pointDistance} from '../helpers/distance-between-points';
|
||||
|
||||
/**
|
||||
* Scale entities by base vector to destination scale vector
|
||||
* @param entities
|
||||
* @param baseVectorStartPoint
|
||||
* @param baseVectorEndPoint
|
||||
* @param scaleVectorEndPoint
|
||||
*/
|
||||
export function scaleEntities(
|
||||
entities: Entity[],
|
||||
baseVectorStartPoint: Point,
|
||||
baseVectorEndPoint: Point,
|
||||
scaleVectorEndPoint: Point
|
||||
) {
|
||||
const scaleFactor =
|
||||
pointDistance(baseVectorStartPoint, scaleVectorEndPoint) /
|
||||
pointDistance(baseVectorStartPoint, baseVectorEndPoint);
|
||||
for (const entity of entities) {
|
||||
entity.scale(baseVectorStartPoint, scaleFactor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import {Tool} from '../tools';
|
||||
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
|
||||
import {assign, createMachine, sendTo} from 'xstate';
|
||||
import {selectToolStateMachine} from './select-tool';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import {compact} from 'es-toolkit';
|
||||
import {scaleEntities} from './scale-tool.helpers';
|
||||
|
||||
export interface ScaleContext extends ToolContext {
|
||||
baseVectorStartPoint: Point | null;
|
||||
baseVectorEndPoint: Point | null;
|
||||
scaleVectorEndPoint: Point | null;
|
||||
originalSelectedEntities: Entity[];
|
||||
}
|
||||
|
||||
export enum ScaleState {
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
WAITING_FOR_BASE_VECTOR_START_POINT = 'WAITING_FOR_BASE_VECTOR_START_POINT',
|
||||
WAITING_FOR_BASE_VECTOR_END_POINT = 'WAITING_FOR_BASE_VECTOR_END_POINT',
|
||||
WAITING_FOR_SCALE_VECTOR_END_POINT = 'WAITING_FOR_SCALE_VECTOR_END_POINT',
|
||||
}
|
||||
|
||||
export enum ScaleAction {
|
||||
INIT_SCALE_TOOL = 'INIT_SCALE_TOOL',
|
||||
ENABLE_HELPERS = 'ENABLE_HELPERS',
|
||||
RECORD_BASE_VECTOR_START_POINT = 'RECORD_BASE_VECTOR_START_POINT',
|
||||
RECORD_BASE_VECTOR_END_POINT = 'RECORD_BASE_VECTOR_END_POINT',
|
||||
COPY_SELECTION_BEFORE_SCALE = 'COPY_SELECTION_BEFORE_SCALE',
|
||||
DRAW_TEMP_SCALE_ENTITIES = 'DRAW_TEMP_SCALE_ENTITIES',
|
||||
SCALE_SELECTION = 'SCALE_SELECTION',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale tool state machine
|
||||
* This state machine is responsible for scaling entities from a base vector to a certain scale vector
|
||||
* It uses the select tool state machine to select entities to scale
|
||||
* When the user presses enter, the selected entities are marked for scaling
|
||||
* The user can then select a base scale vector start point
|
||||
* The user can then select the base scale vector end point
|
||||
* When the user moves their mouse, the selected entities will be updated to scale according to the vector: base vector => scale vector from start point to mouse location
|
||||
* When the user clicks again, the scale vector end point is selected and the entities are scaled according to the scale vector
|
||||
*/
|
||||
export const scaleToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: ScaleContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
baseVectorStartPoint: null,
|
||||
baseVectorEndPoint: null,
|
||||
scaleVectorEndPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
type: Tool.SCALE,
|
||||
},
|
||||
initial: ScaleState.INIT,
|
||||
states: {
|
||||
[ScaleState.INIT]: {
|
||||
description: 'Initializing the scale tool',
|
||||
always: {
|
||||
actions: ScaleAction.INIT_SCALE_TOOL,
|
||||
target: ScaleState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[ScaleState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: ScaleState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[ScaleState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to scale',
|
||||
meta: {
|
||||
instructions: 'Select what you want to scale, then ENTER',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheScaleTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(() => {
|
||||
return {
|
||||
baseVectorStartPoint: null,
|
||||
baseVectorEndPoint: null,
|
||||
scaleVectorEndPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
};
|
||||
}),
|
||||
target: ScaleState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [
|
||||
ScaleAction.DESELECT_ENTITIES,
|
||||
ScaleAction.INIT_SCALE_TOOL,
|
||||
],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT]: {
|
||||
description: 'Select the origin of the scale operation',
|
||||
meta: {
|
||||
instructions: 'Select the origin of the scale operation',
|
||||
},
|
||||
always: {
|
||||
actions: ScaleAction.ENABLE_HELPERS,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [ScaleAction.RECORD_BASE_VECTOR_START_POINT],
|
||||
target: ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: ScaleAction.DESELECT_ENTITIES,
|
||||
target: ScaleState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT]: {
|
||||
description: 'Select the end of the base scale line',
|
||||
meta: {
|
||||
instructions: 'Select the end of the base scale line',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
ScaleAction.RECORD_BASE_VECTOR_END_POINT,
|
||||
ScaleAction.COPY_SELECTION_BEFORE_SCALE,
|
||||
],
|
||||
target: ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: ScaleState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT]: {
|
||||
description: 'Select the end of the scale line',
|
||||
meta: {
|
||||
instructions: 'Select the end of the scale line',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: [ScaleAction.DRAW_TEMP_SCALE_ENTITIES],
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
ScaleAction.SCALE_SELECTION,
|
||||
ScaleAction.DESELECT_ENTITIES,
|
||||
],
|
||||
target: ScaleState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
ESC: {
|
||||
actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: ScaleState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[ScaleAction.INIT_SCALE_TOOL]: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
},
|
||||
[ScaleAction.ENABLE_HELPERS]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[ScaleAction.RECORD_BASE_VECTOR_START_POINT]: assign(
|
||||
({ context, event }) => {
|
||||
setAngleGuideOriginPoint(
|
||||
(event as MouseClickEvent).worldMouseLocation,
|
||||
);
|
||||
return {
|
||||
...context,
|
||||
baseVectorStartPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
},
|
||||
),
|
||||
[ScaleAction.RECORD_BASE_VECTOR_END_POINT]: assign(
|
||||
({ context, event }) => {
|
||||
return {
|
||||
...context,
|
||||
baseVectorEndPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
},
|
||||
),
|
||||
[ScaleAction.COPY_SELECTION_BEFORE_SCALE]: assign(({ context }) => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
|
||||
// Scale the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides
|
||||
setGhostHelperEntities(selectedEntities);
|
||||
// Rescale the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides
|
||||
deleteEntities(selectedEntities, false);
|
||||
|
||||
// TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being scaled and the original entities also are used for snap points / angle guides
|
||||
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
...context,
|
||||
// Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action
|
||||
originalSelectedEntities: compact(
|
||||
selectedEntities.map(entity => entity.clone()),
|
||||
),
|
||||
};
|
||||
}),
|
||||
[ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => {
|
||||
if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) {
|
||||
throw new Error(
|
||||
'[SCALE] Calling draw temp scale entities without a base start point or base end point',
|
||||
);
|
||||
}
|
||||
|
||||
const scaleVectorEndPointTemp = (
|
||||
event as DrawEvent
|
||||
).drawController.getWorldMouseLocation();
|
||||
|
||||
// Draw all selected entities according to scale vector, so the user gets visual feedback of where the entities will be end up after scaling
|
||||
const scaledEntities = compact(
|
||||
context.originalSelectedEntities.map(entity => entity.clone()),
|
||||
);
|
||||
scaleEntities(
|
||||
scaledEntities,
|
||||
context.baseVectorStartPoint,
|
||||
context.baseVectorEndPoint,
|
||||
scaleVectorEndPointTemp,
|
||||
);
|
||||
|
||||
setGhostHelperEntities(scaledEntities);
|
||||
},
|
||||
[ScaleAction.SCALE_SELECTION]: ({ context, event }) => {
|
||||
if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) {
|
||||
throw new Error(
|
||||
'[SCALE] Calling scale selection without some scale vector endpoints',
|
||||
);
|
||||
}
|
||||
const scaleVectorEndPoint = (event as MouseClickEvent)
|
||||
.worldMouseLocation;
|
||||
|
||||
// Scale the entities one final time
|
||||
const scaledEntities = compact(
|
||||
context.originalSelectedEntities.map(entity => entity.clone()),
|
||||
);
|
||||
scaleEntities(
|
||||
scaledEntities,
|
||||
context.baseVectorStartPoint,
|
||||
context.baseVectorEndPoint,
|
||||
scaleVectorEndPoint,
|
||||
);
|
||||
|
||||
// Switch the scaled entities back from the ghost helper entities to the real entities
|
||||
addEntities(scaledEntities, true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
[ScaleAction.DESELECT_ENTITIES]: assign(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
[ScaleAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => {
|
||||
addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack
|
||||
setGhostHelperEntities([]);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,151 @@
|
||||
import type {Box, Point, Polygon} from '@flatten-js/core';
|
||||
import {compact} from 'es-toolkit';
|
||||
import {
|
||||
EPSILON,
|
||||
HIGHLIGHT_ENTITY_DISTANCE,
|
||||
SELECTION_RECTANGLE_COLOR_CONTAINS,
|
||||
SELECTION_RECTANGLE_COLOR_INTERSECTION,
|
||||
SELECTION_RECTANGLE_STYLE,
|
||||
SELECTION_RECTANGLE_WIDTH,
|
||||
} from '../App.consts';
|
||||
import {RectangleEntity} from '../entities/RectangleEntity';
|
||||
import {toast} from 'react-toastify';
|
||||
import {findClosestEntity} from '../helpers/find-closest-entity';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getEntities,
|
||||
getLayers,
|
||||
getSelectedEntityIds,
|
||||
isEntitySelected,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
} from '../state';
|
||||
import type {SelectContext} from './select-tool';
|
||||
import type {MouseClickEvent} from './tool.types';
|
||||
|
||||
export function handleFirstSelectionPoint(
|
||||
context: SelectContext,
|
||||
event: MouseClickEvent
|
||||
): SelectContext {
|
||||
const closestEntityInfo = findClosestEntity(event.worldMouseLocation, getEntities());
|
||||
|
||||
// Mouse is close to entity and is not dragging a rectangle
|
||||
if (closestEntityInfo && closestEntityInfo.distance < HIGHLIGHT_ENTITY_DISTANCE) {
|
||||
// Select the entity close to the mouse
|
||||
const closestEntity = closestEntityInfo.entity;
|
||||
if (!event.holdingCtrl && !event.holdingShift) {
|
||||
setSelectedEntityIds([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([...getSelectedEntityIds(), closestEntity.id]);
|
||||
}
|
||||
} else {
|
||||
// shift => add to selection
|
||||
setSelectedEntityIds([...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(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;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import {getNotSelectedEntities, setEntities, setGhostHelperEntities, setSelectedEntityIds, setShouldDrawHelpers,} from '../state';
|
||||
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
|
||||
import {Tool} from '../tools';
|
||||
import {assign, createMachine} from 'xstate';
|
||||
import {drawTempSelectionRectangle, handleFirstSelectionPoint, selectEntitiesInsideRectangle,} from './select-tool.helpers';
|
||||
|
||||
export interface SelectContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
}
|
||||
|
||||
export enum SelectState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_FIRST_SELECT_POINT = 'WAITING_FOR_FIRST_SELECT_POINT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SECOND_SELECT_POINT = 'WAITING_FOR_SECOND_SELECT_POINT',
|
||||
SELECTION_COMPLETED = 'SELECTION_COMPLETED',
|
||||
}
|
||||
|
||||
export enum SelectAction {
|
||||
INIT_SELECT_TOOL = 'INIT_SELECT_TOOL',
|
||||
HANDLE_FIRST_SELECT_POINT = 'HANDLE_FIRST_SELECT_POINT',
|
||||
SELECT_ENTITIES_INSIDE_RECTANGLE = 'SELECT_ENTITIES_INSIDE_RECTANGLE',
|
||||
DRAW_TEMP_SELECTION_RECTANGLE = 'DRAW_TEMP_SELECTION_RECTANGLE',
|
||||
DELETE_SELECTED_ENTITIES = 'DELETE_SELECTED_ENTITIES',
|
||||
}
|
||||
|
||||
export const selectToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: SelectContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
type: Tool.SELECT,
|
||||
},
|
||||
initial: SelectState.INIT,
|
||||
states: {
|
||||
[SelectState.INIT]: {
|
||||
description: 'Initializing the select tool',
|
||||
always: {
|
||||
actions: SelectAction.INIT_SELECT_TOOL,
|
||||
target: SelectState.WAITING_FOR_FIRST_SELECT_POINT,
|
||||
},
|
||||
},
|
||||
[SelectState.WAITING_FOR_FIRST_SELECT_POINT]: {
|
||||
description:
|
||||
'Select a line or select the first point of a selection rectangle',
|
||||
meta: {
|
||||
instructions: 'Select a line or start drawing a selection rectangle',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: SelectAction.HANDLE_FIRST_SELECT_POINT,
|
||||
target: SelectState.CHECK_SELECTION,
|
||||
},
|
||||
ESC: {
|
||||
actions: SelectAction.INIT_SELECT_TOOL,
|
||||
},
|
||||
ENTER: {
|
||||
target: SelectState.SELECTION_COMPLETED,
|
||||
},
|
||||
DELETE: {
|
||||
actions: SelectAction.DELETE_SELECTED_ENTITIES,
|
||||
target: SelectState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[SelectState.CHECK_SELECTION]: {
|
||||
description:
|
||||
'Checking to select one line or start drawing a selection rectangle',
|
||||
meta: {
|
||||
instructions:
|
||||
'Select one line or start drawing a selection rectangle',
|
||||
},
|
||||
always: [
|
||||
{
|
||||
// User started drawing a selection rectangle
|
||||
guard: ({ context }: { context: SelectContext }) =>
|
||||
!!context.startPoint,
|
||||
target: SelectState.WAITING_FOR_SECOND_SELECT_POINT,
|
||||
},
|
||||
{
|
||||
// User clicked on an entity
|
||||
guard: ({ context }: { context: SelectContext }) =>
|
||||
!context.startPoint,
|
||||
target: SelectState.WAITING_FOR_FIRST_SELECT_POINT,
|
||||
},
|
||||
],
|
||||
},
|
||||
[SelectState.WAITING_FOR_SECOND_SELECT_POINT]: {
|
||||
description: 'Select the second point of a selection rectangle',
|
||||
meta: {
|
||||
instructions: 'Select the second point of a selection rectangle',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: SelectAction.DRAW_TEMP_SELECTION_RECTANGLE,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: SelectAction.SELECT_ENTITIES_INSIDE_RECTANGLE,
|
||||
target: SelectState.WAITING_FOR_FIRST_SELECT_POINT,
|
||||
},
|
||||
ESC: {
|
||||
target: SelectState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[SelectState.SELECTION_COMPLETED]: {
|
||||
description: 'Selection completed',
|
||||
type: 'final',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
INIT_SELECT_TOOL: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
},
|
||||
HANDLE_FIRST_SELECT_POINT: assign(
|
||||
({ context, event }: { context: SelectContext; event: StateEvent }) => {
|
||||
return handleFirstSelectionPoint(context, event as MouseClickEvent);
|
||||
},
|
||||
),
|
||||
DRAW_TEMP_SELECTION_RECTANGLE: ({
|
||||
context,
|
||||
event,
|
||||
}: {
|
||||
context: SelectContext;
|
||||
event: StateEvent;
|
||||
}) => {
|
||||
if (!context.startPoint) {
|
||||
// assert
|
||||
throw new Error(
|
||||
'[SELECT] Calling drawTempSelectionRectangle without startPoint set',
|
||||
);
|
||||
}
|
||||
drawTempSelectionRectangle(
|
||||
context.startPoint as Point,
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation(),
|
||||
);
|
||||
},
|
||||
SELECT_ENTITIES_INSIDE_RECTANGLE: ({
|
||||
context,
|
||||
event,
|
||||
}: {
|
||||
context: SelectContext;
|
||||
event: StateEvent;
|
||||
}) => {
|
||||
if (!context.startPoint) {
|
||||
//
|
||||
throw new Error(
|
||||
'[SELECT] calling SELECT_ENTITIES_INSIDE_RECTANGLE without start point',
|
||||
);
|
||||
}
|
||||
selectEntitiesInsideRectangle(
|
||||
context.startPoint,
|
||||
(event as MouseClickEvent).worldMouseLocation,
|
||||
(event as MouseClickEvent).holdingCtrl,
|
||||
// (event as MouseClickEvent).holdingShift,
|
||||
);
|
||||
setGhostHelperEntities([]);
|
||||
},
|
||||
DELETE_SELECTED_ENTITIES: () => {
|
||||
setEntities(getNotSelectedEntities(), true);
|
||||
setSelectedEntityIds([]);
|
||||
setGhostHelperEntities([]);
|
||||
},
|
||||
RESET_SELECTION: assign(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: null,
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
import type {StateMachine} from 'xstate'; /* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {Tool} from '../tools';
|
||||
import {alignBottomToolStateMachine} from './align-bottom-tool.ts';
|
||||
import {alignCenterHorizontalToolStateMachine} from './align-center-horizontal-tool.ts';
|
||||
import {alignLeftToolStateMachine} from './align-left-tool.ts';
|
||||
import {alignCenterVerticalToolStateMachine} from './align-middle-vertical-tool.ts';
|
||||
import {alignRightToolStateMachine} from './align-right-tool.ts';
|
||||
import {alignTopToolStateMachine} from './align-top-tool.ts';
|
||||
import {arrayToolStateMachine} from './array-tool.ts';
|
||||
import {circleToolStateMachine} from './circle-tool';
|
||||
import {copyToolStateMachine} from './copy-tool.ts';
|
||||
import {eraserToolStateMachine} from './eraser-tool';
|
||||
import {imageImportToolStateMachine} from './image-import-tool';
|
||||
import {lineToolStateMachine} from './line-tool';
|
||||
import {measurementToolStateMachine} from './measurement-tool';
|
||||
import {moveToolStateMachine} from './move-tool';
|
||||
import {rectangleToolStateMachine} from './rectangle-tool';
|
||||
import {rotateToolStateMachine} from './rotate-tool';
|
||||
import {scaleToolStateMachine} from './scale-tool';
|
||||
import {selectToolStateMachine} from './select-tool';
|
||||
import {peditToolStateMachine} from "./pedit-tool.ts";
|
||||
|
||||
export const TOOL_STATE_MACHINES: Record<
|
||||
Partial<Tool>,
|
||||
StateMachine<
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any
|
||||
>
|
||||
> = {
|
||||
[Tool.LINE]: lineToolStateMachine,
|
||||
[Tool.RECTANGLE]: rectangleToolStateMachine,
|
||||
[Tool.CIRCLE]: circleToolStateMachine,
|
||||
[Tool.SELECT]: selectToolStateMachine,
|
||||
[Tool.ERASER]: eraserToolStateMachine,
|
||||
[Tool.MOVE]: moveToolStateMachine,
|
||||
[Tool.COPY]: copyToolStateMachine,
|
||||
[Tool.SCALE]: scaleToolStateMachine,
|
||||
[Tool.ROTATE]: rotateToolStateMachine,
|
||||
[Tool.IMAGE_IMPORT]: imageImportToolStateMachine,
|
||||
[Tool.MEASUREMENT]: measurementToolStateMachine,
|
||||
[Tool.ALIGN_LEFT]: alignLeftToolStateMachine,
|
||||
[Tool.ALIGN_CENTER_HORIZONTAL]: alignCenterHorizontalToolStateMachine,
|
||||
[Tool.ALIGN_RIGHT]: alignRightToolStateMachine,
|
||||
[Tool.ALIGN_TOP]: alignTopToolStateMachine,
|
||||
[Tool.ALIGN_CENTER_VERTICAL]: alignCenterVerticalToolStateMachine,
|
||||
[Tool.ALIGN_BOTTOM]: alignBottomToolStateMachine,
|
||||
[Tool.ARRAY]: arrayToolStateMachine,
|
||||
[Tool.PEDIT]: peditToolStateMachine,
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import type { Tool } from '../tools';
|
||||
import type { EventObject } from 'xstate';
|
||||
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
|
||||
|
||||
export enum ActionType {
|
||||
Click = 'Click',
|
||||
TypedCommand = 'TypedCommand',
|
||||
ActivateTool = 'ActivateTool',
|
||||
}
|
||||
|
||||
export interface ClickEvent {
|
||||
worldMouseLocation: Point;
|
||||
holdingCtrl: boolean;
|
||||
holdingShift: boolean;
|
||||
}
|
||||
|
||||
export interface TypedCommandEvent {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ToolHandler {
|
||||
handleToolActivate(): void;
|
||||
handleToolClick(
|
||||
worldMouseLocation: Point,
|
||||
holdingCtrl: boolean,
|
||||
holdingShift: boolean,
|
||||
): void;
|
||||
handleToolTypedCommand(command: string): void;
|
||||
}
|
||||
|
||||
export enum ActorEvent {
|
||||
MOUSE_CLICK = 'MOUSE_CLICK',
|
||||
ESC = 'ESC',
|
||||
ENTER = 'ENTER',
|
||||
DELETE = 'DELETE',
|
||||
DRAW = 'DRAW',
|
||||
FILE_SELECTED = 'FILE_SELECTED',
|
||||
NUMBER_INPUT = 'NUMBER_INPUT',
|
||||
TEXT_INPUT = 'TEXT_INPUT',
|
||||
ABSOLUTE_POINT_INPUT = 'ABSOLUTE_POINT_INPUT',
|
||||
RELATIVE_POINT_INPUT = 'RELATIVE_POINT_INPUT',
|
||||
}
|
||||
|
||||
export interface MouseClickEvent extends EventObject {
|
||||
type: ActorEvent.MOUSE_CLICK;
|
||||
worldMouseLocation: Point;
|
||||
screenMouseLocation: Point;
|
||||
holdingCtrl: boolean;
|
||||
holdingShift: boolean;
|
||||
}
|
||||
|
||||
export interface KeyboardEscEvent extends EventObject {
|
||||
type: ActorEvent.ESC;
|
||||
}
|
||||
|
||||
export interface KeyboardEnterEvent extends EventObject {
|
||||
type: ActorEvent.ENTER;
|
||||
}
|
||||
|
||||
export interface KeyboardDeleteEvent extends EventObject {
|
||||
type: ActorEvent.DELETE;
|
||||
}
|
||||
|
||||
export interface NumberInputEvent extends EventObject {
|
||||
type: ActorEvent.NUMBER_INPUT;
|
||||
value: number;
|
||||
worldMouseLocation: Point;
|
||||
}
|
||||
|
||||
export interface TextInputEvent extends EventObject {
|
||||
type: ActorEvent.TEXT_INPUT;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AbsolutePointInputEvent extends EventObject {
|
||||
type: ActorEvent.ABSOLUTE_POINT_INPUT;
|
||||
value: Point;
|
||||
}
|
||||
|
||||
export interface RelativePointInputEvent extends EventObject {
|
||||
type: ActorEvent.RELATIVE_POINT_INPUT;
|
||||
value: Point;
|
||||
}
|
||||
|
||||
export interface FileSelectedEvent extends EventObject {
|
||||
type: ActorEvent.FILE_SELECTED;
|
||||
image: HTMLImageElement;
|
||||
}
|
||||
|
||||
export interface DrawEvent extends EventObject {
|
||||
type: ActorEvent.DRAW;
|
||||
drawController: ScreenCanvasDrawController;
|
||||
}
|
||||
|
||||
export type PointInputEvent =
|
||||
| DrawEvent
|
||||
| MouseClickEvent
|
||||
| NumberInputEvent
|
||||
| AbsolutePointInputEvent
|
||||
| RelativePointInputEvent;
|
||||
|
||||
export type StateEvent =
|
||||
| MouseClickEvent
|
||||
| KeyboardEscEvent
|
||||
| KeyboardEnterEvent
|
||||
| KeyboardDeleteEvent
|
||||
| NumberInputEvent
|
||||
| TextInputEvent
|
||||
| AbsolutePointInputEvent
|
||||
| RelativePointInputEvent
|
||||
| FileSelectedEvent
|
||||
| DrawEvent;
|
||||
|
||||
export interface ToolContext {
|
||||
type: Tool;
|
||||
}
|
||||
Reference in New Issue
Block a user