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); }