feat(B07,B08): 워크플로 순서 교환 — 상세설계를 수량산출 앞으로

횡단설계(B06) 다음을 상세설계 → 수량산출 → 설계도서 순으로 재배열하고,
폴더 번호가 흐름과 일치하도록 이름을 맞바꾼다.

- B08_DesignDetail → B07_DesignDetail, B07_Quantity → B08_Quantity
  (파일 접두어·식별자·라우트·locale 키 전량 스왑)
- STAGE_KEYS 4=DESIGN_DETAIL, 5=QUANTITY 스왑 + 라우터 stage 리터럴 교체
- CAD 마운트 /b08-cad → /b07-cad (main.py·vite proxy·iframe URL),
  openwebcad Toolbar 라벨 B07로 수정 후 재빌드
- 유지: openwebcad postMessage 프로토콜 aislo:b08:*·패키지명(내부 식별자)
- 기존 프로젝트 storage 폴더 rename + project_manifest 갱신,
  DB project_workflow_stages stage_no 4↔5 행 스왑 완료

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 16:16:46 +09:00
co-authored by Claude Fable 5
parent f99f1f68d3
commit 6e195afb69
199 changed files with 227 additions and 227 deletions
@@ -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;
}