diff --git a/README.md b/README.md
index cf8f1603..ad0e40ba 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,9 @@ Therefore, to use this, an understanding of the following two libraries is essen
- [focus(ids)](#focusids)
- [fit(ids)](#fitids)
- [selector(path)](#selectorpath)
- - [select(options)](#selectoptions)
+ - [stateManager](#statemanager)
+ - [SelectionState](#selectionstate)
+ - [Transformer](#transformer)
- [undoRedoManager](#undoredomanager)
- [execute(command, options)](#executecommand-options)
- [undo()](#undo)
@@ -400,43 +402,166 @@ Object explorer following [jsonpath](https://github.com/JSONPath-Plus/JSONPath)
const result = patchmap.selector('$..[?(@.label=="group-label-1")]')
```
+### `stateManager`
+A `StateManager` instance that manages the event state of the `patchmap` instance. You can define your own states by extending the `State` class and register them with the `stateManager`. This allows for systematic management of complex user interactions.
+
+When `patchmap.draw()` is executed, a `SelectionState` named `selection` is registered by default.
+
+```js
+// Activates the 'selection' state to use object selection and drag-selection features.
+patchmap.stateManager.setState('selection', {
+ draggable: true,
+ selectUnit: 'grid',
+ filter: (obj) => obj.type !== 'relations',
+ onSelect: (obj, event) => {
+ console.log('Selected:', obj);
+ // Assign the selected object to the transformer
+ if (patchmap.transformer) {
+ patchmap.transformer.elements = obj;
+ }
+ },
+ onDragSelect: (objs, event) => {
+ console.log('Drag Selected:', objs);
+ if (patchmap.transformer) {
+ patchmap.transformer.elements = objs;
+ }
+ },
+});
+```
+
+#### Creating Custom States
+
+You can create a new state class by extending `State` and use it by registering it with the `stateManager`.
+
+```js
+import { State, PROPAGATE_EVENT } from '@conalog/patch-map';
+
+// 1. Define a new state class
+class CustomState extends State {
+ // Define the events this state will handle as a static property.
+ static handledEvents = ['onpointerdown', 'onkeydown'];
+
+ enter(context, customOptions) {
+ super.enter(context);
+ console.log('CustomState has started.', customOptions);
+ }
+
+ exit() {
+ console.log('CustomState has ended.');
+ super.exit();
+ }
+
+ onpointerdown(event) {
+ console.log('Pointer down in CustomState');
+ // Handle the event here and stop its propagation.
+ }
+
+ onkeydown(event) {
+ if (event.key === 'Escape') {
+ // Switch to the 'selection' state (the default state).
+ this.context.stateManager.setState('selection');
+ }
+ // Return PROPAGATE_EVENT to propagate the event to the next state in the stack.
+ return PROPAGATE_EVENT;
+ }
+}
+
+// 2. Register with the StateManager
+patchmap.stateManager.register('custom', CustomState);
+
+// 3. Switch states when needed
+patchmap.stateManager.setState('custom', { message: 'Hello World' });
+```
+
-### `select(options)`
-The selection event is activated to detect objects that the user selects on the screen and pass them to a callback function.
-This should be executed after the `draw` method.
-- `enabled` (optional, boolean): Determines whether the selection event is enabled.
-- `draggable` (optional, boolean): Determines whether dragging is enabled.
-- `selectUnit` (optional, string): Specifies the logical unit to return when selecting. The default is `'entity'`.
- - `'entity'`: Selects individual objects.
- - `'closestGroup'`: Selects the closest parent group of the selected object.
- - `'highestGroup'`: Selects the highest-level group of the selected object.
+### `SelectionState`
+
+The default state that handles user selection and drag events. It is automatically registered with the `stateManager` under the name 'selection' when `patchmap.draw()` is executed. You can activate it and pass configuration by calling `stateManager.setState('selection', options)`.
+
+- `draggable` (optional, boolean): Determines whether to enable multi-selection via dragging.
+- `selectUnit` (optional, string): Specifies the logical unit to be returned upon selection. The default is `'entity'`.
+ - `'entity'`: Selects the individual object.
+ - `'closestGroup'`: Selects the nearest parent group of the selected object.
+ - `'highestGroup'`: Selects the topmost parent group of the selected object.
- `'grid'`: Selects the grid to which the selected object belongs.
-- `filter` (optional, function): A function that filters the target objects based on specific conditions.
-- `onSelect` (optional, function): The callback function that is called when a selection occurs.
-- `onOver` (optional, function): The callback function that is called when a pointer-over event occurs.
-- `onDragSelect` (optional, function): The callback function that is called when a drag event occurs.
+- `filter` (optional, function): A function to filter selectable objects based on a condition.
+- `onSelect` (optional, function): A callback function invoked when an object is selected via a single click. It receives the selected object and the event object as arguments.
+- `onDragSelect` (optional, function): A callback function invoked when multiple objects are selected via dragging. It receives an array of selected objects and the event object as arguments.
+- `selectionBoxStyle` (optional, object): Specifies the style of the selection box displayed during drag-selection.
+ - `fill` (object): The fill style. Default: `{ color: '#9FD6FF', alpha: 0.2 }`.
+ - `stroke` (object): The stroke style. Default: `{ width: 2, color: '#1099FF' }`.
```js
-patchmap.select({
- enabled: true,
+patchmap.stateManager.setState('selection', {
draggable: true,
selectUnit: 'grid',
filter: (obj) => obj.type !== 'relations',
- onSelect: (obj) => {
- console.log(obj);
+ onSelect: (obj, event) => {
+ console.log('Selected:', obj);
+ // Assign the selected object to the transformer
+ if (patchmap.transformer) {
+ patchmap.transformer.elements = obj;
+ }
},
- onOver: (obj) => {
- console.log(obj);
+ onDragSelect: (objs, event) => {
+ console.log('Drag Selected:', objs);
+ if (patchmap.transformer) {
+ patchmap.transformer.elements = objs;
+ }
},
- onDragSelect: (objs) => {
- console.log(objs);
- }
});
```
+### `Transformer`
+
+A visual tool for displaying an outline around selected elements and performing transformations such as resizing or rotating. It is activated by creating a `Transformer` instance and assigning it to `patchmap.transformer`.
+
+#### new Transformer(options)
+
+You can control the behavior by passing the following options when creating a `Transformer` instance.
+
+ - `elements` (optional, Array): An array of elements to display an outline for initially.
+ - `wireframeStyle` (optional, object): Specifies the style of the outline.
+ - `thickness` (number): The thickness of the line (default: `1.5`).
+ - `color` (string): The color of the line (default: `'#1099FF'`).
+ - `boundsDisplayMode` (optional, string): Determines the unit for displaying the outline (default: `'all'`).
+ - `'all'`: Displays both the overall outline of a group and the outlines of individual elements within it.
+ - `'groupOnly'`: Displays only the overall outline of the group.
+ - `'elementOnly'`: Displays only the outlines of individual elements within the group.
+ - `'none'`: Does not display any outline.
+
+
+
+```js
+import { Patchmap, Transformer } from '@conalog/patch-map';
+
+const patchmap = new Patchmap();
+await patchmap.init(element);
+patchmap.draw(data);
+
+// 1. Create and assign a Transformer instance
+const transformer = new Transformer({
+ wireframeStyle: {
+ thickness: 2,
+ color: '#FF00FF',
+ },
+ boundsDisplayMode: 'groupOnly',
+});
+patchmap.transformer = transformer;
+
+// 2. Assign the selected object to the transformer's elements property to display the outline
+const selectedObject = patchmap.selector('$..[?(@.id=="group-id-1")]')[0];
+patchmap.transformer.elements = [selectedObject];
+
+// To deselect
+patchmap.transformer.elements = [];
+```
+
+
+
## undoRedoManager
An instance of the `UndoRedoManager` class. This manager records executed commands, allowing for undo and redo functionality.
diff --git a/README_KR.md b/README_KR.md
index 07122590..a784830a 100644
--- a/README_KR.md
+++ b/README_KR.md
@@ -27,7 +27,9 @@ PATCH MAP은 PATCH 서비스의 요구 사항을 충족시키기 위해 `pixi.js
- [focus(ids)](#focusids)
- [fit(ids)](#fitids)
- [selector(path)](#selectorpath)
- - [select(options)](#selectoptions)
+ - [stateManager](#statemanager)
+ - [SelectionState](#selectionstate)
+ - [Transformer](#transformer)
- [undoRedoManager](#undoredomanager)
- [execute(command, options)](#executecommand-options)
- [undo()](#undo)
@@ -401,41 +403,164 @@ const result = patchmap.selector('$..[?(@.label=="group-label-1")]')
-### `select(options)`
-선택 이벤트를 활성화하여, 사용자가 화면에서 선택한 객체들을 감지하고 콜백 함수에 전달합니다.
-`draw` 메소드 이후에 실행되어야 합니다.
-- `enabled` (optional, boolean): 선택 이벤트의 활성화 여부를 결정합니다.
-- `draggable` (optional, boolean): 드래그 활성화 여부를 결정합니다.
-- `selectUnit` (optional, string): 선택 시 반환될 논리적 단위를 지정합니다. 기본값은 'entity' 입니다.
+### `stateManager`
+
+`patchmap` 인스턴스의 이벤트 상태를 관리하는 `StateManager` 인스턴스입니다. `State` 클래스를 상속받아 자신만의 상태를 정의하고, `stateManager`에 등록하여 사용할 수 있습니다. 이를 통해 사용자의 복잡한 인터랙션을 체계적으로 관리할 수 있습니다.
+
+`patchmap.draw()`가 실행되면 기본적으로 `selection`이라는 이름의 `SelectionState`가 등록됩니다.
+
+```js
+// selection 상태를 활성화하여 객체 선택 및 드래그 선택 기능을 사용합니다.
+patchmap.stateManager.setState('selection', {
+ draggable: true,
+ selectUnit: 'grid',
+ filter: (obj) => obj.type !== 'relations',
+ onSelect: (obj, event) => {
+ console.log('Selected:', obj);
+ // 선택된 객체를 transformer에 할당
+ if (patchmap.transformer) {
+ patchmap.transformer.elements = obj;
+ }
+ },
+ onDragSelect: (objs, event) => {
+ console.log('Drag Selected:', objs);
+ if (patchmap.transformer) {
+ patchmap.transformer.elements = objs;
+ }
+ },
+});
+```
+
+#### 사용자 정의 상태 만들기
+
+`State`를 상속하여 새로운 상태 클래스를 만들고, `stateManager`에 등록하여 사용할 수 있습니다.
+
+```js
+import { State, PROPAGATE_EVENT } from '@conalog/patch-map';
+
+// 1. 새로운 상태 클래스 정의
+class CustomState extends State {
+ // 이 상태가 처리할 이벤트를 static 속성으로 정의합니다.
+ static handledEvents = ['onpointerdown', 'onkeydown'];
+
+ enter(context, customOptions) {
+ super.enter(context);
+ console.log('CustomState가 시작되었습니다.', customOptions);
+ }
+
+ exit() {
+ console.log('CustomState가 종료되었습니다.');
+ super.exit();
+ }
+
+ onpointerdown(event) {
+ console.log('Pointer down in CustomState');
+ // 이벤트를 여기서 처리하고 전파를 중지합니다.
+ }
+
+ onkeydown(event) {
+ if (event.key === 'Escape') {
+ // 'selection' 상태(기본 상태)로 전환합니다.
+ this.context.stateManager.setState('selection');
+ }
+ // 이벤트를 스택의 다음 상태로 전파하려면 PROPAGATE_EVENT를 반환합니다.
+ return PROPAGATE_EVENT;
+ }
+}
+
+// 2. StateManager에 등록
+patchmap.stateManager.register('custom', CustomState);
+
+// 3. 필요할 때 상태 전환
+patchmap.stateManager.setState('custom', { message: 'Hello World' });
+```
+
+
+
+### `SelectionState`
+사용자의 선택 및 드래그 이벤트를 처리하는 기본 상태(State)입니다. `patchmap.draw()`가 실행되면 'selection'이라는 이름으로 `stateManager`에 자동으로 등록됩니다. `stateManager.setState('selection', options)`를 호출하여 활성화하고 설정을 전달할 수 있습니다.
+
+- `draggable` (optional, boolean): 드래그를 통한 다중 선택 활성화 여부를 결정합니다.
+- `selectUnit` (optional, string): 선택 시 반환될 논리적 단위를 지정합니다. 기본값은 `'entity'` 입니다.
- `'entity'`: 개별 객체를 선택합니다.
- `'closestGroup'`: 선택된 객체에서 가장 가까운 상위 그룹을 선택합니다.
- `'highestGroup'`: 선택된 객체에서 가장 최상위 그룹을 선택합니다.
- `'grid'`: 선택된 객체가 속한 그리드를 선택합니다.
- `filter` (optional, function): 선택 대상 객체를 조건에 따라 필터링할 수 있는 함수입니다.
-- `onSelect` (optional, function): 선택이 발생할 때 호출될 콜백 함수입니다.
-- `onOver` (optional, function): 포인터 오버가 발생할 때 호출될 콜백 함수입니다.
-- `onDragSelect` (optional, function): 드래그가 발생할 때 호출될 콜백 함수입니다.
+- `onSelect` (optional, function): 단일 클릭으로 객체 선택이 발생했을 때 호출될 콜백 함수입니다. 선택된 객체와 이벤트 객체를 인자로 받습니다.
+- `onDragSelect` (optional, function): 드래그를 통해 다수의 객체가 선택되었을 때 호출될 콜백 함수입니다. 선택된 객체 배열과 이벤트 객체를 인자로 받습니다.
+- `selectionBoxStyle` (optional, object): 드래그 선택 시 표시되는 사각형의 스타일을 지정합니다.
+ - `fill` (object): 채우기 스타일. 기본값: `{ color: '#9FD6FF', alpha: 0.2 }`.
+ - `stroke` (object): 테두리 스타일. 기본값: `{ width: 2, color: '#1099FF' }`.
```js
-patchmap.select({
- enabled: true,
+patchmap.stateManager.setState('selection', {
draggable: true,
selectUnit: 'grid',
filter: (obj) => obj.type !== 'relations',
- onSelect: (obj) => {
- console.log(obj);
+ onSelect: (obj, event) => {
+ console.log('Selected:', obj);
+ // 선택된 객체를 transformer에 할당
+ if (patchmap.transformer) {
+ patchmap.transformer.elements = obj;
+ }
},
- onOver: (obj) => {
- console.log(obj);
+ onDragSelect: (objs, event) => {
+ console.log('Drag Selected:', objs);
+ if (patchmap.transformer) {
+ patchmap.transformer.elements = objs;
+ }
},
- onDragSelect: (objs) => {
- console.log(objs);
- }
});
```
+### `Transformer`
+
+선택된 요소의 외곽선을 시각적으로 표시하고, 크기 조절이나 회전과 같은 변형 작업을 수행하기 위한 시각적 도구입니다. `Transformer` 인스턴스를 생성하여 `patchmap.transformer`에 할당하면 활성화됩니다.
+
+#### new Transformer(options)
+
+`Transformer` 인스턴스를 생성할 때 다음과 같은 옵션을 전달하여 동작을 제어할 수 있습니다.
+
+ - `elements` (optional, Array): 초기에 외곽선을 표시할 요소들의 배열입니다.
+ - `wireframeStyle` (optional, object): 외곽선의 스타일을 지정합니다.
+ - `thickness` (number): 선의 두께 (기본값: `1.5`).
+ - `color` (string): 선의 색상 (기본값: `'#1099FF'`).
+ - `boundsDisplayMode` (optional, string): 외곽선을 표시할 단위를 결정합니다 (기본값: `'all'`).
+ - `'all'`: 그룹의 전체 외곽선과 그룹 내 개별 요소의 외곽선을 모두 표시합니다.
+ - `'groupOnly'`: 그룹의 전체 외곽선만 표시합니다.
+ - `'elementOnly'`: 그룹 내 개별 요소의 외곽선만 표시합니다.
+ - `'none'`: 외곽선을 표시하지 않습니다.
+
+```js
+import { Patchmap, Transformer } from '@conalog/patch-map';
+
+const patchmap = new Patchmap();
+await patchmap.init(element);
+patchmap.draw(data);
+
+// 1. Transformer 인스턴스 생성 및 할당
+const transformer = new Transformer({
+ wireframeStyle: {
+ thickness: 2,
+ color: '#FF00FF',
+ },
+ boundsDisplayMode: 'groupOnly',
+});
+patchmap.transformer = transformer;
+
+// 2. 선택된 객체를 transformer의 elements 속성에 할당하여 외곽선 표시
+const selectedObject = patchmap.selector('$..[?(@.id=="group-id-1")]')[0];
+patchmap.transformer.elements = [selectedObject];
+
+// 선택 해제 시
+patchmap.transformer.elements = [];
+```
+
+
+
## undoRedoManager
`UndoRedoManager` 클래스의 인스턴스입니다. 이 매니저는 실행된 명령을 기록하고, 이를 통해 실행 취소(undo) 및 재실행(redo) 기능을 제공합니다.
diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js
index 06e18217..c07a12f3 100644
--- a/src/display/elements/Relations.js
+++ b/src/display/elements/Relations.js
@@ -25,7 +25,7 @@ export class Relations extends ComposedRelations {
initPath() {
const path = new Graphics();
- Object.assign(path, { type: 'path', links: [] });
+ Object.assign(path, { type: 'path', links: [], allowContainsPoint: true });
this.addChild(path);
return path;
}
diff --git a/src/display/update.js b/src/display/update.js
index 9d9cae48..6f654266 100644
--- a/src/display/update.js
+++ b/src/display/update.js
@@ -43,18 +43,12 @@ export const update = (viewport, opts) => {
const applyRelativeTransform = (element, changes) => {
const { x, y, rotation, angle } = changes;
- if (x) {
- changes.x = element.x + x;
- }
- if (y) {
- changes.y = element.y + y;
- }
- if (rotation) {
- changes.rotation = element.rotation + rotation;
- }
- if (angle) {
- changes.angle = element.angle + angle;
- }
+ Object.assign(changes, {
+ x: element.x + (typeof x === 'number' ? x : 0),
+ y: element.y + (typeof y === 'number' ? y : 0),
+ rotation: element.rotation + (typeof rotation === 'number' ? rotation : 0),
+ angle: element.angle + (typeof angle === 'number' ? angle : 0),
+ });
return changes;
};
diff --git a/src/events/StateManager.js b/src/events/StateManager.js
new file mode 100644
index 00000000..9863542f
--- /dev/null
+++ b/src/events/StateManager.js
@@ -0,0 +1,284 @@
+import { PROPAGATE_EVENT } from './states/State';
+
+/**
+ * Manages the state of the application, including the registration, transition, and management of states.
+ * This class implements a stack-based state machine, allowing for nested states and complex interaction flows.
+ */
+export default class StateManager {
+ /** @private */
+ #context;
+ /** @private */
+ #stateRegistry = new Map();
+ /** @private */
+ #stateStack = [];
+ /** @private */
+ #modifierState = null;
+ /** @private */
+ #boundEvents = new Set();
+ /** @private */
+ #eventListeners = {};
+
+ /**
+ * Initializes the StateManager with a context.
+ * @param {object} context - The context in which the StateManager operates, typically containing the viewport and other global instances.
+ */
+ constructor(context) {
+ this.#context = context;
+ }
+
+ /**
+ * Gets the current modifier state. A modifier state is a temporary, high-priority state
+ * (e.g., holding a key for panning) that overrides the main state stack without altering it.
+ * @returns {import('./states/State').default | null} The current modifier state or null if none is active.
+ */
+ get modifierState() {
+ return this.#modifierState;
+ }
+
+ /**
+ * Gets the registry of all known state definitions.
+ * @returns {Map} A map where keys are state names and values are their definitions.
+ */
+ get stateRegistry() {
+ return this.#stateRegistry;
+ }
+
+ /**
+ * Registers a state class or a singleton instance with a unique name.
+ * Also ensures that the necessary event listeners for the state are bound.
+ * @param {string} name - The unique name of the state.
+ * @param {typeof import('./states/State').default | import('./states/State').default} StateClassOrObject - The state class or singleton instance.
+ * @param {boolean} [isSingleton=true] - If true, the instance is created once and reused.
+ */
+ register(name, StateClassOrObject, isSingleton = true) {
+ if (this.#stateRegistry.has(name)) {
+ console.warn(`State "${name}" is already registered. Overwriting.`);
+ }
+ this.#stateRegistry.set(name, {
+ Class: StateClassOrObject,
+ instance:
+ isSingleton && typeof StateClassOrObject !== 'function'
+ ? StateClassOrObject
+ : null,
+ isSingleton,
+ });
+
+ const events =
+ typeof StateClassOrObject === 'function'
+ ? StateClassOrObject.handledEvents
+ : StateClassOrObject.constructor.handledEvents;
+ this._ensureEventListeners(events);
+ }
+
+ /**
+ * Transitions to a new state by clearing the entire state stack and pushing the new state.
+ * @param {string} name - The name of the state to transition to.
+ * @param {...*} args - Additional arguments to pass to the state's `enter` method.
+ */
+ setState(name, ...args) {
+ this.resetState();
+ this.pushState(name, ...args);
+ }
+
+ /**
+ * Clears the entire state stack, calling `exit` on all active states.
+ */
+ resetState() {
+ this.exitAll();
+ this.#stateStack.length = 0;
+ }
+
+ /**
+ * Pushes a new state onto the stack, pausing the previous state.
+ * @param {string} name - The name of the state to push.
+ * @param {...*} args - Additional arguments to pass to the new state's `enter` method.
+ */
+ pushState(name, ...args) {
+ const currentState = this.getCurrentState();
+ currentState?.pause?.();
+
+ const stateDef = this.#stateRegistry.get(name);
+ if (!stateDef) {
+ console.warn(`State "${name}" is not registered.`);
+ return;
+ }
+
+ let instance = stateDef.instance;
+ if (!instance || !stateDef.isSingleton) {
+ const StateClass = stateDef.Class;
+ instance = new StateClass();
+ if (stateDef.isSingleton) {
+ stateDef.instance = instance;
+ }
+ }
+
+ this.#stateStack.push(instance);
+ instance.enter?.(this.#context, ...args);
+ }
+
+ /**
+ * Pops the top state from the stack, exiting it and resuming the state below it.
+ * @param {*} [payload] - Optional payload to pass to the previous state's `resume` method.
+ * @returns {import('./states/State').default | null} The popped state or null if the stack is empty.
+ */
+ popState(payload) {
+ if (this.#stateStack.length === 0) return null;
+
+ const currentState = this.#stateStack.pop();
+ currentState?.exit?.();
+
+ const previousState = this.getCurrentState();
+ previousState?.resume?.(payload);
+ return currentState;
+ }
+
+ /**
+ * Calls the `exit` method on all states currently in the stack.
+ * Used for a hard reset of the state machine.
+ * @private
+ */
+ exitAll() {
+ this.#stateStack.forEach((state) => {
+ state?.exit?.();
+ });
+ }
+
+ /**
+ * Gets the current active state from the top of the stack.
+ * @returns {import('./states/State').default | null} The current active state or null if the stack is empty.
+ */
+ getCurrentState() {
+ return this.#stateStack.length > 0
+ ? this.#stateStack[this.#stateStack.length - 1]
+ : null;
+ }
+
+ /**
+ * Activates a temporary, high-priority modifier state.
+ * This state intercepts all events without affecting the main state stack.
+ * If the same modifier state is already active, this method does nothing.
+ * @param {string} name - The name of the modifier state to activate.
+ * @param {...*} args - Additional arguments to pass to the modifier state's `enter` method.
+ */
+ activateModifier(name, ...args) {
+ const stateDef = this.#stateRegistry.get(name);
+ if (!stateDef) {
+ console.warn(`State "${name}" is not registered.`);
+ return;
+ }
+
+ const prospectiveClassOrObject = stateDef.Class;
+ if (this.modifierState) {
+ if (typeof prospectiveClassOrObject === 'function') {
+ if (this.#modifierState instanceof prospectiveClassOrObject) {
+ return;
+ }
+ } else {
+ if (this.#modifierState === prospectiveClassOrObject) {
+ return;
+ }
+ }
+ }
+
+ if (this.#modifierState) {
+ this.#modifierState.exit?.();
+ }
+
+ let instance;
+ if (typeof prospectiveClassOrObject === 'function') {
+ instance =
+ stateDef.isSingleton && stateDef.instance
+ ? stateDef.instance
+ : new prospectiveClassOrObject();
+ if (stateDef.isSingleton) {
+ stateDef.instance = instance;
+ }
+ } else {
+ instance = prospectiveClassOrObject;
+ }
+
+ this.#modifierState = instance;
+ this.#modifierState.enter?.(this.#context, ...args);
+ }
+
+ /**
+ * Deactivates the current modifier state, restoring event handling to the main state stack.
+ */
+ deactivateModifier() {
+ this.#modifierState?.exit?.();
+ this.#modifierState = null;
+ }
+
+ /**
+ * Ensures event listeners for the given event names are attached to the viewport or window.
+ * It creates a single dispatcher for each event type that directs the event to the
+ * appropriate state(s) (modifier or stack).
+ * @private
+ * @param {string[]} [eventNames=[]] - The names of the events to ensure listeners for (e.g., 'onpointerdown').
+ */
+ _ensureEventListeners(eventNames = []) {
+ const viewport = this.#context.viewport;
+ const dispatch = (eventName, event) => {
+ if (this.#modifierState) {
+ this.#modifierState[eventName]?.(event);
+ return;
+ }
+
+ for (let i = this.#stateStack.length - 1; i >= 0; i--) {
+ const state = this.#stateStack[i];
+ if (!state || typeof state[eventName] !== 'function') {
+ continue;
+ }
+
+ const result = state[eventName](event);
+ if (result !== PROPAGATE_EVENT) {
+ break;
+ }
+ }
+ };
+
+ for (const eventName of eventNames) {
+ if (this.#boundEvents.has(eventName)) continue;
+
+ const pixiEventName = eventName.replace('on', '').toLowerCase();
+ const listener = (e) => dispatch(eventName, e);
+ this.#eventListeners[eventName] = listener;
+
+ if (pixiEventName.startsWith('key')) {
+ window.addEventListener(pixiEventName, listener);
+ } else {
+ viewport.on(pixiEventName, listener);
+ }
+ this.#boundEvents.add(eventName);
+ }
+ }
+
+ /**
+ * Destroys the StateManager, cleaning up all event listeners,
+ * destroying state instances, and clearing all internal references.
+ */
+ destroy() {
+ for (const eventName of this.#boundEvents) {
+ const pixiEventName = eventName.replace('on', '').toLowerCase();
+ const listener = this.#eventListeners[eventName];
+ if (pixiEventName.startsWith('key')) {
+ window.removeEventListener(pixiEventName, listener);
+ } else {
+ this.#context.viewport.off(pixiEventName, listener);
+ }
+ }
+ this.#stateRegistry.forEach((stateDef) => {
+ if (
+ stateDef.instance &&
+ typeof stateDef.instance.destroy === 'function'
+ ) {
+ stateDef.instance.destroy();
+ }
+ });
+ this.#stateRegistry.clear();
+ this.#stateStack = [];
+ this.#modifierState = null;
+ this.#boundEvents.clear();
+ this.#eventListeners = {};
+ }
+}
diff --git a/src/events/drag-select.js b/src/events/drag-select.js
deleted file mode 100644
index f6f58056..00000000
--- a/src/events/drag-select.js
+++ /dev/null
@@ -1,162 +0,0 @@
-import { isValidationError } from 'zod-validation-error';
-import { deepMerge } from '../utils/deepmerge/deepmerge';
-import { event } from '../utils/event/canvas';
-import { validate } from '../utils/validator';
-import { findIntersectObjects } from './find';
-import { dragSelectEventSchema } from './schema';
-import { checkEvents, isMoved } from './utils';
-
-const DRAG_SELECT_EVENT_ID = 'drag-select-down drag-select-move drag-select-up';
-const DEBOUNCE_FN_INTERVAL = 25; // ms
-
-export const dragSelect = (viewport, state, opts) => {
- const options = validate(
- deepMerge(state.config, opts),
- dragSelectEventSchema,
- );
- if (isValidationError(options)) throw options;
-
- if (!checkEvents(viewport, DRAG_SELECT_EVENT_ID)) {
- addEvents(viewport, state);
- }
-
- changeDraggableState(
- viewport,
- state,
- state.config.enabled && state.config.draggable,
- options.enabled && options.draggable,
- );
- state.config = options;
-};
-
-const addEvents = (viewport, state) => {
- event.removeEvent(viewport, DRAG_SELECT_EVENT_ID);
- registerDownEvent();
- registerMoveEvent();
- registerUpEvent();
-
- function registerDownEvent() {
- event.addEvent(viewport, {
- id: 'drag-select-down',
- action: 'mousedown touchstart',
- fn: (e) => {
- resetState(state);
-
- const point = viewport.toWorld({ ...e.global });
- state.isDragging = true;
- state.box.renderable = true;
- state.point.start = { ...point };
- state.point.move = { ...point };
- },
- });
- }
-
- function registerMoveEvent() {
- event.addEvent(viewport, {
- id: 'drag-select-move',
- action: 'mousemove touchmove moved',
- fn: (e) => {
- if (!state.isDragging || !e.global) return;
-
- state.point.end = viewport.toWorld({ ...e.global });
- drawSelectionBox(state);
-
- if (isMoved(viewport, state.point.move, state.point.end)) {
- viewport.plugin.start('mouse-edges');
- triggerFn(viewport, e, state);
- state.point.move = JSON.parse(JSON.stringify(state.point.end));
- }
- },
- });
- }
-
- function registerUpEvent() {
- event.addEvent(viewport, {
- id: 'drag-select-up',
- action: 'mouseup touchend mouseleave',
- fn: (e) => {
- if (
- state.point.start &&
- state.point.end &&
- isMoved(viewport, state.point.start, state.point.end)
- ) {
- triggerFn(viewport, e, state);
- viewport.plugin.stop('mouse-edges');
- }
- resetState(state);
- },
- });
- }
-};
-
-const drawSelectionBox = (state) => {
- const { box, point } = state;
- if (!point.start || !point.end) return;
-
- box.clear();
- box
- .rect(
- Math.min(point.start.x, point.end.x),
- Math.min(point.start.y, point.end.y),
- Math.abs(point.start.x - point.end.x),
- Math.abs(point.start.y - point.end.y),
- )
- .fill({ color: '#9FD6FF', alpha: 0.2 })
- .stroke({ width: 2, color: '#1099FF', pixelLine: true });
-};
-
-const triggerFn = (viewport, e, state) => {
- const now = performance.now();
- if (
- e.type === 'pointermove' &&
- now - state.lastMoveTime < DEBOUNCE_FN_INTERVAL
- ) {
- return;
- }
- state.lastMoveTime = now;
-
- const intersectObjs =
- state.point.start && state.point.end
- ? findIntersectObjects(viewport, state, state.config)
- : [];
- if ('onDragSelect' in state.config) {
- state.config.onDragSelect(intersectObjs, e);
- }
-};
-
-const changeDraggableState = (viewport, state, wasDraggable, isDraggable) => {
- if (wasDraggable === isDraggable) return;
-
- if (isDraggable) {
- viewport.plugin.add({
- mouseEdges: { speed: 16, distance: 20, allowButtons: true },
- });
- viewport.plugin.stop('mouse-edges');
- event.onEvent(viewport, DRAG_SELECT_EVENT_ID);
- addChildBox(viewport, state);
- } else {
- viewport.plugin.remove('mouse-edges');
- event.offEvent(viewport, DRAG_SELECT_EVENT_ID);
- resetState(state);
- removeChildBox(viewport, state);
- }
-};
-
-const resetState = (state) => {
- state.isDragging = false;
- state.point = { start: null, end: null, move: null };
- state.box.clear();
- state.box.renderable = false;
-};
-
-const addChildBox = (viewport, state) => {
- if (!state.box.parent) {
- viewport.addChild(state.box);
- }
-};
-
-const removeChildBox = (viewport, state) => {
- if (state.box.parent) {
- viewport.removeChild(state.box);
- }
-};
diff --git a/src/events/find.js b/src/events/find.js
index d6967223..327b54a7 100644
--- a/src/events/find.js
+++ b/src/events/find.js
@@ -3,7 +3,11 @@ import { intersect } from '../utils/intersects/intersect';
import { intersectPoint } from '../utils/intersects/intersect-point';
import { getSelectObject } from './utils';
-export const findIntersectObject = (viewport, state, options) => {
+export const findIntersectObject = (
+ viewport,
+ point,
+ { filter, selectUnit } = {},
+) => {
const allCandidates = collectCandidates(
viewport,
(child) => child.constructor.isSelectable,
@@ -36,10 +40,10 @@ export const findIntersectObject = (viewport, state, options) => {
: [candidate];
for (const target of targets) {
- const isIntersecting = intersectPoint(target, state.point);
+ const isIntersecting = intersectPoint(target, point);
if (isIntersecting) {
- const selectObject = getSelectObject(candidate, options);
- if (selectObject && (!options.filter || options.filter(selectObject))) {
+ const selectObject = getSelectObject(candidate, selectUnit);
+ if (selectObject && (!filter || filter(selectObject))) {
return selectObject;
}
}
@@ -49,7 +53,11 @@ export const findIntersectObject = (viewport, state, options) => {
return null;
};
-export const findIntersectObjects = (viewport, state, options) => {
+export const findIntersectObjects = (
+ viewport,
+ selectionBox,
+ { filter, selectUnit } = {},
+) => {
const allCandidates = collectCandidates(
viewport,
(child) => child.constructor.isSelectable,
@@ -63,10 +71,10 @@ export const findIntersectObjects = (viewport, state, options) => {
: [candidate];
for (const target of targets) {
- const isIntersecting = intersect(state.box, target);
+ const isIntersecting = intersect(selectionBox, target);
if (isIntersecting) {
- const selectObject = getSelectObject(candidate, options);
- if (selectObject && (!options.filter || options.filter(selectObject))) {
+ const selectObject = getSelectObject(candidate, selectUnit);
+ if (selectObject && (!filter || filter(selectObject))) {
found.push(selectObject);
break;
}
diff --git a/src/events/schema.js b/src/events/schema.js
index ad261f82..4bd0d6c9 100644
--- a/src/events/schema.js
+++ b/src/events/schema.js
@@ -1,23 +1,5 @@
import { z } from 'zod';
-const selectDefaultSchema = z.object({
- enabled: z.boolean().default(false),
- filter: z.nullable(z.function()).default(null),
- selectUnit: z
- .enum(['entity', 'closestGroup', 'highestGroup', 'grid'])
- .default('entity'),
-});
-
-export const selectEventSchema = selectDefaultSchema.extend({
- onSelect: z.function().optional(),
- onOver: z.function().optional(),
-});
-
-export const dragSelectEventSchema = selectDefaultSchema.extend({
- draggable: z.boolean().default(false),
- onDragSelect: z.function().optional(),
-});
-
export const focusFitIdsSchema = z
.union([z.string(), z.array(z.string())])
.nullish();
diff --git a/src/events/single-select.js b/src/events/single-select.js
deleted file mode 100644
index 0418be40..00000000
--- a/src/events/single-select.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import { isValidationError } from 'zod-validation-error';
-import { deepMerge } from '../utils/deepmerge/deepmerge';
-import { event } from '../utils/event/canvas';
-import { validate } from '../utils/validator';
-import { findIntersectObject } from './find';
-import { selectEventSchema } from './schema';
-import { checkEvents, isMoved } from './utils';
-
-const SELECT_EVENT_ID = 'select-down select-up select-over';
-
-export const select = (viewport, state, opts) => {
- const options = validate(deepMerge(state.config, opts), selectEventSchema);
- if (isValidationError(options)) throw options;
-
- if (!checkEvents(viewport, SELECT_EVENT_ID)) {
- addEvents(viewport, state);
- }
-
- changeEnableState(viewport, state.config.enabled, options.enabled);
- state.config = options;
-};
-
-const addEvents = (viewport, state) => {
- event.removeEvent(viewport, SELECT_EVENT_ID);
- registerDownEvent();
- registerUpEvent();
- registerOverEvent();
-
- function registerDownEvent() {
- event.addEvent(viewport, {
- id: 'select-down',
- action: 'mousedown touchstart',
- fn: (e) => {
- state.position.start = viewport.toWorld({ ...e.global });
- state.viewportPosStart = { x: viewport.x, y: viewport.y };
- },
- });
- }
-
- function registerUpEvent() {
- event.addEvent(viewport, {
- id: 'select-up',
- action: 'mouseup touchend',
- fn: (e) => {
- state.position.end = viewport.toWorld({ ...e.global });
- const viewportPosEnd = { x: viewport.x, y: viewport.y };
-
- const mouseHasMoved = isMoved(
- state.position.start,
- state.position.end,
- viewport.scale,
- );
- const viewportHasMoved = isMoved(
- state.viewportPosStart,
- viewportPosEnd,
- );
-
- if (state.position.start && !mouseHasMoved && !viewportHasMoved) {
- executeFn('onSelect', e);
- }
-
- state.position = { start: null, end: null };
- state.viewportPosStart = null;
- executeFn('onOver', e);
- },
- });
- }
-
- function registerOverEvent() {
- event.addEvent(viewport, {
- id: 'select-over',
- action: 'mouseover',
- fn: (e) => {
- executeFn('onOver', e);
- },
- });
- }
-
- function executeFn(fnName, e) {
- const point = viewport.toWorld({ ...e.global });
- if (fnName in state.config) {
- state.config[fnName](
- findIntersectObject(viewport, { point }, state.config),
- e,
- );
- }
- }
-};
-
-const changeEnableState = (viewport, wasEnabled, isEnabled) => {
- if (wasEnabled === isEnabled) return;
-
- if (isEnabled) {
- event.onEvent(viewport, SELECT_EVENT_ID);
- } else {
- event.offEvent(viewport, SELECT_EVENT_ID);
- }
-};
diff --git a/src/events/states/SelectionState.js b/src/events/states/SelectionState.js
new file mode 100644
index 00000000..5f97c561
--- /dev/null
+++ b/src/events/states/SelectionState.js
@@ -0,0 +1,180 @@
+import { Graphics } from 'pixi.js';
+import { deepMerge } from '../../utils/deepmerge/deepmerge';
+import { findIntersectObject, findIntersectObjects } from '../find';
+import { isMoved } from '../utils';
+import State from './State';
+
+const InteractionState = {
+ IDLE: 'idle',
+ PRESSING: 'pressing',
+ DRAGGING: 'dragging',
+};
+
+/**
+ * @typedef {object} SelectionStateConfig
+ * @property {boolean} [draggable=false] - Enables drag-to-select functionality.
+ * @property {(obj: PIXI.DisplayObject) => boolean} [filter=() => true] - A function to filter which objects can be selected.
+ * @property {'entity' | 'closestGroup' | 'highestGroup' | 'grid'} [selectUnit='entity'] - The logical unit of selection.
+ * @property {(selected: PIXI.DisplayObject | null, event: PIXI.FederatedPointerEvent) => void} [onSelect=() => {}] - Callback for a single-object selection (click).
+ * @property {(selected: PIXI.DisplayObject[], event: PIXI.FederatedPointerEvent) => void} [onDragSelect=() => {}] - Callback for a multi-object selection (drag).
+ * @property {(hovered: PIXI.DisplayObject | null, event: PIXI.FederatedPointerEvent) => void} [onOver=() => {}] - Callback for hover events.
+ * @property {object} [selectionBoxStyle] - Style options for the drag selection box.
+ * @property {object} [selectionBoxStyle.fill={ color: '#9FD6FF', alpha: 0.2 }] - Fill style.
+ * @property {object} [selectionBoxStyle.stroke={ width: 2, color: '#1099FF' }] - Stroke style.
+ */
+
+export default class SelectionState extends State {
+ static handledEvents = [
+ 'onpointerdown',
+ 'onpointermove',
+ 'onpointerup',
+ 'onpointerover',
+ ];
+
+ /** @type {SelectionStateConfig} */
+ config = {};
+ interactionState = InteractionState.IDLE;
+ dragStartPoint = null;
+ _selectionBox = new Graphics();
+
+ /**
+ * Enters the selection state with a given context and configuration.
+ * @param {object} context - The application context, containing the viewport.
+ * @param {SelectionStateConfig} config - Configuration for the selection behavior.
+ */
+ enter(context, config) {
+ super.enter(context);
+ const defaultConfig = {
+ draggable: false,
+ filter: () => true,
+ selectUnit: 'entity',
+ onOver: () => {},
+ onSelect: () => {},
+ onDragSelect: () => {},
+ selectionBoxStyle: {
+ fill: { color: '#9FD6FF', alpha: 0.2 },
+ stroke: { width: 2, color: '#1099FF' },
+ },
+ };
+ this.config = deepMerge(defaultConfig, config || {});
+
+ this.viewport = this.context.viewport;
+ this.viewport.addChild(this._selectionBox);
+ }
+
+ exit() {
+ super.exit();
+ this.#clear();
+ if (this._selectionBox.parent) {
+ this._selectionBox.parent.removeChild(this._selectionBox);
+ }
+ }
+
+ pause() {
+ this.dragStartPoint = null;
+ this._selectionBox.clear();
+ }
+
+ destroy() {
+ this._selectionBox.destroy(true);
+ super.destroy();
+ }
+
+ onpointerdown(e) {
+ this.interactionState = InteractionState.PRESSING;
+ this.dragStartPoint = this.viewport.toWorld(e.global);
+ this.select(e);
+ }
+
+ onpointermove(e) {
+ if (this.interactionState === InteractionState.IDLE) return;
+ const currentPoint = this.viewport.toWorld(e.global);
+
+ if (
+ this.interactionState === InteractionState.PRESSING &&
+ isMoved(this.dragStartPoint, currentPoint, this.viewport.scale)
+ ) {
+ this.interactionState = InteractionState.DRAGGING;
+ this.viewport.plugin.start('mouse-edges');
+ }
+
+ if (this.interactionState === InteractionState.DRAGGING) {
+ this.#drawSelectionBox(this.dragStartPoint, currentPoint);
+ this.dragSelect(e);
+ }
+ }
+
+ onpointerup(e) {
+ if (this.interactionState === InteractionState.PRESSING) {
+ this.select(e);
+ } else if (this.interactionState === InteractionState.DRAGGING) {
+ this.dragSelect(e);
+ this.viewport.plugin.stop('mouse-edges');
+ }
+ this.#clear();
+ }
+
+ onpointerover(e) {
+ if (this.interactionState !== InteractionState.IDLE) return;
+ this.hover(e);
+ }
+
+ /**
+ * Draws the selection rectangle on the screen.
+ * @private
+ * @param {PIXI.Point} p1 - The starting point of the drag.
+ * @param {PIXI.Point} p2 - The current pointer position.
+ */
+
+ #drawSelectionBox(p1, p2) {
+ if (!p1 || !p2) return;
+
+ const { fill, stroke } = this.config.selectionBoxStyle;
+ this._selectionBox.clear();
+ this._selectionBox
+ .rect(
+ Math.min(p1.x, p2.x),
+ Math.min(p1.y, p2.y),
+ Math.abs(p1.x - p2.x),
+ Math.abs(p1.y - p2.y),
+ )
+ .fill(fill)
+ .stroke({ ...stroke, pixelLine: true });
+ }
+
+ /**
+ * Resets the internal state of the selection handler.
+ * @private
+ */
+ #clear() {
+ this.interactionState = InteractionState.IDLE;
+ this._selectionBox.clear();
+ this.dragStartPoint = null;
+ }
+
+ /** Finalizes a single object selection. */
+ select(e) {
+ const selected = this.findPoint(this.viewport.toWorld(e.global));
+ this.config.onSelect(selected, e);
+ }
+
+ /** Finalizes a multi-object drag selection. */
+ dragSelect(e) {
+ const selected = this.findPolygon(this._selectionBox);
+ this.config.onDragSelect(selected, e);
+ }
+
+ /** Handles hover-over objects. */
+ hover(e) {
+ const selected = this.findPoint(this.viewport.toWorld(e.global));
+ this.config.onOver(selected, e);
+ }
+
+ findPoint(point) {
+ return findIntersectObject(this.viewport, point, this.config);
+ }
+
+ findPolygon(polygon) {
+ return findIntersectObjects(this.viewport, polygon, this.config);
+ }
+}
diff --git a/src/events/states/State.js b/src/events/states/State.js
new file mode 100644
index 00000000..d10e2e67
--- /dev/null
+++ b/src/events/states/State.js
@@ -0,0 +1,88 @@
+/**
+ * A unique symbol used by event handlers within a state to indicate
+ * that the event should be propagated to the next state in the state stack.
+ * This allows for creating event bubbling-like behavior through different active states.
+ */
+export const PROPAGATE_EVENT = Symbol('propagate_event');
+
+/**
+ * Represents an abstract base class for all states in a state machine.
+ * It defines the lifecycle methods (`enter`, `exit`, `pause`, `resume`)
+ * and provides a mechanism for handling events and managing resources.
+ *
+ * Each concrete state class should extend this class and implement its own logic
+ * for the lifecycle methods and event handlers.
+ */
+export default class State {
+ /**
+ * An array of strings defining which events this state class handles (e.g., 'onpointerdown').
+ * The StateManager uses this static property to attach the necessary event listeners
+ * when a state of this type becomes active.
+ * @static
+ * @type {string[]}
+ */
+ static handledEvents = [];
+
+ /**
+ * An AbortController instance to manage the lifecycle of asynchronous operations
+ * and event listeners within this state. A new controller is created upon entering the state.
+ * Its signal can be used to cancel any pending operations when the state exits.
+ * @type {AbortController}
+ */
+ abortController = new AbortController();
+
+ constructor() {
+ /**
+ * A reference to the shared context object provided by the StateManager.
+ * This context typically contains references to global objects like the viewport,
+ * the application instance, etc. It is null until `enter()` is called.
+ * @type {object | null}
+ */
+ this.context = null;
+ }
+
+ /**
+ * Called by the StateManager when this state becomes the active state.
+ * This method should be used for setup logic, like initializing variables or
+ * adding temporary scene elements.
+ * A new AbortController is created here for the state's lifecycle.
+ *
+ * @param {object} context - The shared application context from the StateManager.
+ */
+ enter(context) {
+ this.context = context;
+ this.abortController = new AbortController();
+ }
+
+ /**
+ * Called by the StateManager when this state is being deactivated or removed.
+ * This method should be used for cleanup logic, such as removing event listeners
+ * or stopping asynchronous tasks. It automatically calls `abort()` on the
+ * `abortController`.
+ */
+ exit() {
+ this.abortController.abort();
+ }
+
+ /**
+ * Called by the StateManager when another state is pushed on top of this one in the stack.
+ * This state is not exited but becomes inactive. Use this for temporarily
+ * hiding UI elements or pausing animations.
+ */
+ pause() {}
+
+ /**
+ * Called by the StateManager when this state becomes active again after the state
+ * on top of it has been popped. Use this to resume activities that were
+ * paused in the `pause()` method.
+ */
+ resume() {}
+
+ /**
+ * Cleans up the state completely. It's an alias for `exit()` and ensures
+ * that all resources are released when the state is no longer needed.
+ */
+ destroy() {
+ this.exit();
+ }
+}
diff --git a/src/events/utils.js b/src/events/utils.js
index d4e6b53a..467d06f3 100644
--- a/src/events/utils.js
+++ b/src/events/utils.js
@@ -4,7 +4,7 @@ export const checkEvents = (viewport, eventId) => {
return eventId.split(' ').every((id) => event.getEvent(viewport, id));
};
-export const getSelectObject = (obj, { selectUnit }) => {
+export const getSelectObject = (obj, selectUnit) => {
if (!obj || !obj.constructor.isSelectable) {
return null;
}
diff --git a/src/patch-map.ts b/src/patch-map.ts
index a483a7aa..f04787a0 100644
--- a/src/patch-map.ts
+++ b/src/patch-map.ts
@@ -1,3 +1,6 @@
export { Patchmap } from './patchmap';
export { UndoRedoManager } from './command/undo-redo-manager';
export { Command } from './command/commands/base';
+export { default as Transformer } from './transformer/Transformer';
+export { default as State, PROPAGATE_EVENT } from './events/states/State';
+export * from './utils';
diff --git a/src/patchmap.js b/src/patchmap.js
index 6b063276..4b90d455 100644
--- a/src/patchmap.js
+++ b/src/patchmap.js
@@ -1,12 +1,10 @@
import gsap from 'gsap';
-import { Application, Graphics, UPDATE_PRIORITY } from 'pixi.js';
+import { Application, UPDATE_PRIORITY } from 'pixi.js';
import { isValidationError } from 'zod-validation-error';
import { UndoRedoManager } from './command/undo-redo-manager';
import { draw } from './display/draw';
import { update } from './display/update';
-import { dragSelect } from './events/drag-select';
import { fit, focus } from './events/focus-fit';
-import { select } from './events/single-select';
import {
initApp,
initAsset,
@@ -21,20 +19,20 @@ import { themeStore } from './utils/theme';
import { validateMapData } from './utils/validator';
import './display/elements/registry';
import './display/components/registry';
+import StateManager from './events/StateManager';
+import SelectionState from './events/states/SelectionState';
+import Transformer from './transformer/Transformer';
class Patchmap {
- constructor() {
- this._app = null;
- this._viewport = null;
- this._resizeObserver = null;
- this._isInit = false;
- this._theme = themeStore();
- this._undoRedoManager = new UndoRedoManager();
- this._animationContext = gsap.context(() => {});
-
- this._singleSelectState = null;
- this._dragSelectState = null;
- }
+ _app = null;
+ _viewport = null;
+ _resizeObserver = null;
+ _isInit = false;
+ _theme = themeStore();
+ _undoRedoManager = new UndoRedoManager();
+ _animationContext = gsap.context(() => {});
+ _transformer = null;
+ _stateManager = null;
get app() {
return this._app;
@@ -60,6 +58,35 @@ class Patchmap {
return this._undoRedoManager;
}
+ get transformer() {
+ return this._transformer;
+ }
+
+ set transformer(value) {
+ if (this._transformer && !this._transformer.destroyed) {
+ this.viewport.off('object_transformed', this.transformer.update);
+ this._transformer.destroy(true);
+ }
+
+ if (value && !(value instanceof Transformer)) {
+ console.error(
+ 'Transformer must be an instance of the Transformer class.',
+ );
+ this._transformer = null;
+ return;
+ }
+
+ this._transformer = value;
+ if (this._transformer) {
+ this.viewport.addChild(this._transformer);
+ this.viewport.on('object_transformed', this.transformer.update);
+ }
+ }
+
+ get stateManager() {
+ return this._stateManager;
+ }
+
get animationContext() {
return this._animationContext;
}
@@ -99,6 +126,7 @@ class Patchmap {
initCanvas(element, this.app);
this._resizeObserver = initResizeObserver(element, this.app, this.viewport);
+ this._stateManager = new StateManager(this);
this.isInit = true;
}
@@ -121,8 +149,6 @@ class Patchmap {
this._theme = themeStore();
this._undoRedoManager = new UndoRedoManager();
this._animationContext = gsap.context(() => {});
- this._singleSelectState = null;
- this._dragSelectState = null;
}
draw(data) {
@@ -143,8 +169,8 @@ class Patchmap {
this.undoRedoManager.clear();
this.animationContext.revert();
event.removeAllEvent(this.viewport);
- this.initSelectState();
draw(context, validatedData);
+ this._stateManager.register('selection', SelectionState, true);
// Force a refresh of all relation elements after the initial draw. This ensures
// that all link targets exist in the scene graph before the relations
@@ -186,26 +212,6 @@ class Patchmap {
selector(path, opts) {
return selector(this.viewport, path, opts);
}
-
- select(opts) {
- select(this.viewport, this._singleSelectState, opts);
- dragSelect(this.viewport, this._dragSelectState, opts);
- }
-
- initSelectState() {
- this._singleSelectState = {
- config: {},
- position: { start: null, end: null },
- viewportPosStart: null,
- };
- this._dragSelectState = {
- config: {},
- lastMoveTime: 0,
- isDragging: false,
- point: { start: null, end: null, move: null },
- box: new Graphics(),
- };
- }
}
export { Patchmap };
diff --git a/src/tests/Transformer.test.js b/src/tests/Transformer.test.js
new file mode 100644
index 00000000..0c04b04c
--- /dev/null
+++ b/src/tests/Transformer.test.js
@@ -0,0 +1,193 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { Transformer } from '../patch-map';
+import { setupPatchmapTests } from '../tests/render/patchmap.setup';
+
+const sampleData = [
+ {
+ type: 'group',
+ id: 'group-1',
+ attrs: { x: 100, y: 100 },
+ children: [
+ { type: 'item', id: 'item-1', size: 50, attrs: { x: 0, y: 0 } },
+ { type: 'item', id: 'item-2', size: 60, attrs: { x: 100, y: 50 } },
+ ],
+ },
+ {
+ type: 'item',
+ id: 'item-3',
+ size: 80,
+ attrs: { x: 300, y: 200 },
+ },
+];
+
+describe('Transformer', () => {
+ const { getPatchmap } = setupPatchmapTests();
+
+ describe('Initialization', () => {
+ it('should instantiate with default options', () => {
+ const transformer = new Transformer();
+ expect(transformer.elements).toEqual([]);
+ expect(transformer.boundsDisplayMode).toBe('all');
+ expect(transformer.wireframeStyle.thickness).toBe(1.5);
+ expect(transformer.wireframeStyle.color).toBe('#1099FF');
+ expect(transformer.children.length).toBe(1); // wireframe
+ });
+
+ it('should instantiate with custom options', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(sampleData);
+ const elements = patchmap.selector('$..children');
+ const transformer = new Transformer({
+ elements: elements,
+ wireframeStyle: { thickness: 3, color: '#FF0000' },
+ boundsDisplayMode: 'groupOnly',
+ });
+
+ expect(transformer.elements).toEqual(elements);
+ expect(transformer.boundsDisplayMode).toBe('groupOnly');
+ expect(transformer.wireframeStyle.thickness).toBe(3);
+ expect(transformer.wireframeStyle.color).toBe('#FF0000');
+ });
+
+ it('should be added to the patchmap viewport', () => {
+ const patchmap = getPatchmap();
+ const transformer = new Transformer();
+ patchmap.transformer = transformer;
+ expect(patchmap.viewport.children).toContain(transformer);
+ });
+ });
+
+ describe('elements property', () => {
+ it('should accept a single element and wrap it in an array', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(sampleData);
+ const transformer = new Transformer();
+ patchmap.transformer = transformer;
+
+ const item = patchmap.selector('$..[?(@.id=="item-1")]')[0];
+ transformer.elements = item;
+
+ expect(transformer.elements).toEqual([item]);
+ });
+
+ it('should accept an array of elements', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(sampleData);
+ const transformer = new Transformer();
+ patchmap.transformer = transformer;
+
+ const group = patchmap.selector('$..[?(@.id=="group-1")]')[0];
+ const item3 = patchmap.selector('$..[?(@.id=="item-3")]')[0];
+ transformer.elements = [group, item3];
+
+ expect(transformer.elements).toEqual([group, item3]);
+ });
+
+ it('should trigger a redraw by setting _renderDirty to true', () => {
+ const patchmap = getPatchmap();
+ const transformer = new Transformer();
+ patchmap.transformer = transformer;
+
+ transformer._renderDirty = false;
+ transformer.elements = [];
+ expect(transformer._renderDirty).toBe(true);
+ });
+ });
+
+ describe('Drawing Logic and boundsDisplayMode', () => {
+ let patchmap;
+ let transformer;
+ let group;
+ let item1;
+ let item2;
+ let item3;
+
+ beforeEach(() => {
+ patchmap = getPatchmap();
+ patchmap.draw(sampleData);
+ transformer = new Transformer();
+ patchmap.transformer = transformer;
+ group = patchmap.selector('$..[?(@.id=="group-1")]')[0];
+ item1 = patchmap.selector('$..[?(@.id=="item-1")]')[0];
+ item2 = patchmap.selector('$..[?(@.id=="item-2")]')[0];
+ item3 = patchmap.selector('$..[?(@.id=="item-3")]')[0];
+ });
+
+ it('should clear the wireframe when elements array is empty', () => {
+ const wireframeClearSpy = vi.spyOn(transformer.wireframe, 'clear');
+ transformer.elements = [item1];
+ transformer.draw(); // Draw something first
+ expect(wireframeClearSpy).toHaveBeenCalledTimes(1);
+
+ transformer.elements = [];
+ transformer.draw();
+ expect(wireframeClearSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should draw both group and element bounds when mode is "all"', () => {
+ const drawBoundsSpy = vi.spyOn(transformer.wireframe, 'drawBounds');
+ transformer.elements = [group, item3];
+ transformer.draw();
+ // Called for group, item3, and then the combined group bounds
+ expect(drawBoundsSpy).toHaveBeenCalledTimes(3);
+ });
+
+ it('should draw only the encompassing group bounds when mode is "groupOnly"', () => {
+ const drawBoundsSpy = vi.spyOn(transformer.wireframe, 'drawBounds');
+ transformer.boundsDisplayMode = 'groupOnly';
+ transformer.elements = [item1, item2]; // Two elements
+ transformer.draw();
+ // Should be called only once for the combined bounds
+ expect(drawBoundsSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should draw only individual element bounds when mode is "elementOnly"', () => {
+ const drawBoundsSpy = vi.spyOn(transformer.wireframe, 'drawBounds');
+ transformer.boundsDisplayMode = 'elementOnly';
+ transformer.elements = [item1, item2];
+ transformer.draw();
+ // Called once for each element
+ expect(drawBoundsSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should not draw anything when mode is "none"', () => {
+ const drawBoundsSpy = vi.spyOn(transformer.wireframe, 'drawBounds');
+ transformer.boundsDisplayMode = 'none';
+ transformer.elements = [item1, item2];
+ transformer.draw();
+ expect(drawBoundsSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Viewport Interaction', () => {
+ it('should adjust wireframe thickness on viewport zoom', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(sampleData);
+
+ const transformer = new Transformer({ wireframeStyle: { thickness: 2 } });
+ patchmap.transformer = transformer;
+ transformer.elements = patchmap.selector('$..[?(@.id=="group-1")]')[0];
+
+ patchmap.viewport.setZoom(2, true); // Zoom in
+ patchmap.viewport.emit('zoomed'); // Manually emit for test reliability
+ transformer.draw();
+ expect(transformer.wireframe.strokeStyle.width).toBe(1); // 2 / 2 = 1
+
+ patchmap.viewport.setZoom(0.5, true); // Zoom out
+ patchmap.viewport.emit('zoomed');
+ transformer.draw();
+ expect(transformer.wireframe.strokeStyle.width).toBe(4); // 2 / 0.5 = 4
+ });
+
+ it('should remove "zoomed" listener on destroy', () => {
+ const patchmap = getPatchmap();
+ const transformer = new Transformer();
+ patchmap.transformer = transformer;
+
+ const offSpy = vi.spyOn(patchmap.viewport, 'off');
+ transformer.destroy();
+
+ expect(offSpy).toHaveBeenCalledWith('zoomed', transformer.update);
+ });
+ });
+});
diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js
index bc01990a..77b0b293 100644
--- a/src/tests/render/patchmap.test.js
+++ b/src/tests/render/patchmap.test.js
@@ -239,7 +239,7 @@ describe('patchmap test', () => {
describe('when draggable is false', () => {
beforeEach(() => {
- patchmap.select({
+ patchmap.stateManager.setState('selection', {
enabled: true,
draggable: false,
selectUnit: 'grid',
@@ -337,16 +337,16 @@ describe('patchmap test', () => {
transform(viewport);
await vi.advanceTimersByTimeAsync(100);
- viewport.emit('mousedown', {
+ viewport.emit('pointerdown', {
global: viewport.toGlobal(position),
stopPropagation: () => {},
});
- viewport.emit('mouseup', {
+ viewport.emit('pointerup', {
global: viewport.toGlobal(position),
stopPropagation: () => {},
});
- expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledTimes(2);
const receivedElement = onSelect.mock.calls[0][0];
if (expectedId === null) {
@@ -410,23 +410,23 @@ describe('patchmap test', () => {
const onSelect = vi.fn();
- patchmap.select({
+ patchmap.stateManager.setState('selection', {
enabled: true,
selectUnit: selectUnit,
onSelect: onSelect,
});
const viewport = patchmap.viewport;
- viewport.emit('mousedown', {
+ viewport.emit('pointerdown', {
global: viewport.toGlobal(clickPosition),
stopPropagation: () => {},
});
- viewport.emit('mouseup', {
+ viewport.emit('pointerup', {
global: viewport.toGlobal(clickPosition),
stopPropagation: () => {},
});
- expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledTimes(2);
const selectedObject = onSelect.mock.calls[0][0];
if (expectedId) {
diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js
new file mode 100644
index 00000000..61ec63ab
--- /dev/null
+++ b/src/transformer/Transformer.js
@@ -0,0 +1,240 @@
+import { Container } from 'pixi.js';
+import { z } from 'zod';
+import { isValidationError } from 'zod-validation-error';
+import { calcGroupOrientedBounds, calcOrientedBounds } from '../utils/bounds';
+import { getViewport } from '../utils/get';
+import { validate } from '../utils/validator';
+import { Wireframe } from './Wireframe';
+
+const DEFAULT_WIREFRAME_STYLE = {
+ thickness: 1.5,
+ color: '#1099FF',
+};
+
+/**
+ * @typedef {'all' | 'groupOnly' | 'elementOnly' | 'none'} BoundsDisplayMode
+ */
+
+/**
+ * @typedef {object} WireframeStyle
+ * @property {number} [thickness=1.5] - The thickness of the wireframe lines.
+ * @property {string | number} [color='#1099FF'] - The color of the wireframe lines.
+ */
+
+/**
+ * @typedef {object} TransformerOptions
+ * @property {PIXI.DisplayObject[]} [elements] - The initial elements to be transformed.
+ * @property {WireframeStyle} [wireframeStyle] - The style of the wireframe.
+ * @property {BoundsDisplayMode} [boundsDisplayMode='all'] - The mode for displaying bounds.
+ */
+
+const TransformerSchema = z
+ .object({
+ elements: z.array(z.any()),
+ wireframeStyle: z.record(z.string(), z.unknown()),
+ boundsDisplayMode: z.enum(['all', 'groupOnly', 'elementOnly', 'none']),
+ })
+ .partial();
+
+/**
+ * A visual tool to display and manipulate the bounds of selected elements.
+ * It draws a wireframe around the elements and can be configured to show bounds
+ * for individual elements, the entire group, or both.
+ * @extends PIXI.Container
+ */
+export default class Transformer extends Container {
+ /** @private */
+ #wireframe;
+
+ /**
+ * The mode for displaying the wireframe bounds.
+ * - 'all': Show bounds for both the group and individual elements.
+ * - 'groupOnly': Show only the encompassing bounds of all elements.
+ * - 'elementOnly': Show bounds for each individual element.
+ * - 'none': Do not show any bounds.
+ * @private
+ * @type {BoundsDisplayMode}
+ */
+ _boundsDisplayMode = 'all';
+
+ /**
+ * The array of elements currently being transformed.
+ * @private
+ * @type {PIXI.DisplayObject[]}
+ */
+ _elements = [];
+
+ /**
+ * A flag to indicate that the wireframe needs to be redrawn.
+ * @private
+ * @type {boolean}
+ */
+ _renderDirty = true;
+
+ /**
+ * The style configuration for the wireframe.
+ * @private
+ * @type {WireframeStyle}
+ */
+ _wireframeStyle = DEFAULT_WIREFRAME_STYLE;
+
+ /**
+ * A reference to the viewport, obtained when this container is added to the stage.
+ * @private
+ * @type {import('pixi-viewport').Viewport | null}
+ */
+ _viewport = null;
+
+ /**
+ * @param {TransformerOptions} [opts] - The options for the transformer.
+ */
+ constructor(opts = {}) {
+ super({ zIndex: 999, isRenderGroup: true, id: 'transformer' });
+
+ const options = validate(opts, TransformerSchema);
+ if (isValidationError(options)) throw options;
+
+ this.#wireframe = this.addChild(new Wireframe({ label: 'wireframe' }));
+ this.onRender = this.#refresh.bind(this);
+ for (const key in options) {
+ if (key === 'wireframeStyle') {
+ this[key] = Object.assign(this[key], options[key]);
+ } else {
+ this[key] = options[key];
+ }
+ }
+
+ this.on('added', () => {
+ this._viewport = getViewport(this);
+ if (this._viewport) {
+ this._viewport.on('zoomed', this.update);
+ }
+ });
+ }
+
+ /**
+ * The wireframe graphics instance.
+ * @returns {Wireframe}
+ */
+ get wireframe() {
+ return this.#wireframe;
+ }
+
+ /**
+ * The current bounds display mode.
+ * @returns {BoundsDisplayMode}
+ */
+ get boundsDisplayMode() {
+ return this._boundsDisplayMode;
+ }
+
+ /**
+ * @param {BoundsDisplayMode} value
+ */
+ set boundsDisplayMode(value) {
+ this._boundsDisplayMode = value;
+ this.update();
+ }
+
+ /**
+ * The array of elements to be transformed.
+ * @returns {PIXI.DisplayObject[]}
+ */
+ get elements() {
+ return this._elements;
+ }
+
+ /**
+ * @param {PIXI.DisplayObject | PIXI.DisplayObject[]} value
+ */
+ set elements(value) {
+ this._elements = value ? (Array.isArray(value) ? value : [value]) : [];
+ this.update();
+ }
+
+ /**
+ * The style of the wireframe.
+ * @returns {WireframeStyle}
+ */
+ get wireframeStyle() {
+ return this._wireframeStyle;
+ }
+
+ /**
+ * @param {Partial} value
+ */
+ set wireframeStyle(value) {
+ this._wireframeStyle = Object.assign(this._wireframeStyle, value);
+ this.wireframe.setStrokeStyle(this.wireframeStyle);
+ this.update();
+ }
+
+ /**
+ * Destroys the transformer, removing listeners and cleaning up resources.
+ * @override
+ * @param {import('pixi.js').DestroyOptions} [options]
+ */
+ destroy(options) {
+ this.onRender = null;
+ if (this._viewport) {
+ this._viewport.off('zoomed', this.update);
+ }
+ super.destroy(options);
+ }
+
+ /**
+ * Called on every render frame. Redraws the wireframe if it's dirty.
+ * @private
+ */
+ #refresh() {
+ if (this.renderable && this.visible && this._renderDirty) {
+ this.draw();
+ }
+ }
+
+ /**
+ * Clears and redraws the wireframe based on the current elements and display mode.
+ * Adjusts line thickness based on the viewport scale to maintain a consistent appearance.
+ */
+ draw() {
+ const elements = this.elements;
+ let groupBounds = null;
+ this.wireframe.clear();
+
+ if (!elements || elements.length === 0) {
+ this._renderDirty = false;
+ return;
+ }
+
+ if (this.boundsDisplayMode !== 'none') {
+ this.wireframe.strokeStyle.width =
+ this.wireframeStyle.thickness / (this._viewport?.scale?.x ?? 1);
+ }
+
+ if (
+ this.boundsDisplayMode === 'all' ||
+ this.boundsDisplayMode === 'elementOnly'
+ ) {
+ elements.forEach((element) => {
+ this.wireframe.drawBounds(calcOrientedBounds(element));
+ });
+ }
+
+ if (
+ this.boundsDisplayMode === 'all' ||
+ this.boundsDisplayMode === 'groupOnly'
+ ) {
+ groupBounds = calcGroupOrientedBounds(elements);
+ this.wireframe.drawBounds(groupBounds);
+ }
+ this._renderDirty = false;
+ }
+
+ /**
+ * Marks the transformer as dirty, scheduling a redraw on the next frame.
+ * This method is an arrow function to preserve `this` context when used as an event listener.
+ */
+ update = () => {
+ this._renderDirty = true;
+ };
+}
diff --git a/src/transformer/Wireframe.js b/src/transformer/Wireframe.js
new file mode 100644
index 00000000..742af3a2
--- /dev/null
+++ b/src/transformer/Wireframe.js
@@ -0,0 +1,33 @@
+import { Graphics } from 'pixi.js';
+
+/**
+ * A specialized Graphics class for drawing the wireframe outlines of transformed objects.
+ * It extends PIXI.Graphics to provide a dedicated method for rendering bounds.
+ * @extends PIXI.Graphics
+ */
+export class Wireframe extends Graphics {
+ /**
+ * A static flag to indicate that this object can be targeted by selection logic.
+ * @type {boolean}
+ * @static
+ */
+ static isSelectable = true;
+
+ /**
+ * Draws the polygonal hull of a given bounds object.
+ * The hull points are expected to be in world coordinates and will be
+ * transformed into the local coordinate system of this Wireframe instance before drawing.
+ *
+ * @param {import('@pixi-essentials/bounds').OrientedBounds | object} bounds - The bounds object containing the hull to draw.
+ * It should have a `hull` property which is an array of points.
+ * @returns {void}
+ */
+ drawBounds(bounds) {
+ if (bounds) {
+ const hull = bounds.hull.map((worldPoint) => {
+ return this.toLocal(worldPoint);
+ });
+ this.poly(hull).stroke();
+ }
+ }
+}
diff --git a/src/utils/bounds.js b/src/utils/bounds.js
index d71091b4..a866f498 100644
--- a/src/utils/bounds.js
+++ b/src/utils/bounds.js
@@ -38,7 +38,7 @@ export const calcOrientedBounds = (object, bounds = tempBounds) => {
export const calcGroupOrientedBounds = (group, bounds = tempBounds) => {
if (!group || group.length === 0) {
- return;
+ return null;
}
const allWorldCorners = group.flatMap((element) => {
diff --git a/src/utils/event/canvas.js b/src/utils/event/canvas.js
index d78193ec..9e516a45 100644
--- a/src/utils/event/canvas.js
+++ b/src/utils/event/canvas.js
@@ -98,7 +98,7 @@ export const offEvent = (viewport, id) => {
}
};
-export const getEvent = (viewport, id) => viewport.events[id] ?? null;
+export const getEvent = (viewport, id) => viewport.events?.[id] ?? null;
export const getAllEvent = (viewport) => viewport.events;
diff --git a/src/utils/get.js b/src/utils/get.js
index 8344071b..969f5d91 100644
--- a/src/utils/get.js
+++ b/src/utils/get.js
@@ -1,3 +1,5 @@
+import { Viewport } from 'pixi-viewport';
+
export const getNestedValue = (object, path) => {
if (typeof path !== 'string' || !path) {
return null;
@@ -18,7 +20,14 @@ export const getColor = (theme, color) => {
export const getViewport = (displayObject) => {
if (!displayObject) return null;
- return displayObject?.context?.viewport ?? getViewport(displayObject.parent);
+
+ if (displayObject?.context?.viewport) {
+ return displayObject.context.viewport;
+ }
+ if (displayObject instanceof Viewport) {
+ return displayObject;
+ }
+ return getViewport(displayObject.parent);
};
export const collectCandidates = (parent, filterFn = () => true) => {
diff --git a/src/utils/index.js b/src/utils/index.js
new file mode 100644
index 00000000..d75e0331
--- /dev/null
+++ b/src/utils/index.js
@@ -0,0 +1,4 @@
+export { uid } from './uuid';
+export { intersectPoint } from './intersects/intersect-point';
+export { isMoved } from '../events/utils';
+export { findIntersectObject } from '../events/find';
diff --git a/src/utils/intersects/intersect-point.js b/src/utils/intersects/intersect-point.js
index 62f1a211..e6659381 100644
--- a/src/utils/intersects/intersect-point.js
+++ b/src/utils/intersects/intersect-point.js
@@ -1,4 +1,4 @@
-import { Graphics, Polygon } from 'pixi.js';
+import { Polygon } from 'pixi.js';
import { getViewport } from '../get';
import { getObjectLocalCorners } from '../transform';
@@ -6,7 +6,7 @@ export const intersectPoint = (obj, point) => {
const viewport = getViewport(obj);
if (!viewport) return false;
- if (obj instanceof Graphics) {
+ if (obj.allowContainsPoint) {
return obj.containsPoint(point);
}