diff --git a/README.md b/README.md
index ad0e40ba..148e6424 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ Therefore, to use this, an understanding of the following two libraries is essen
- [canUndo()](#canundo)
- [canRedo()](#canredo)
- [clear()](#clear)
- - [subscribe(listener)](#subscribelistener)
+- [π’ Full List of Available Events](#-full-list-of-available-events)
- [π§βπ» Development](#-development)
- [Setting up the development environment](#setting-up-the-development-environment)
- [VSCode Integration](#vscode-integration)
@@ -560,6 +560,22 @@ patchmap.transformer.elements = [selectedObject];
patchmap.transformer.elements = [];
```
+#### transformer.selection
+
+An instance of `SelectionModel` for dedicated management of the `Transformer`'s selection state. This allows you to programmatically control the selected elements.
+
+```js
+// Add, remove, and replace selected elements
+transformer.selection.add(item1);
+transformer.selection.remove(item1);
+transformer.selection.set([item2]);
+
+// Subscribe to selection change events
+transformer.on('update_elements', ({ current, added, removed }) => {
+ console.log('Current selection:', current);
+});
+```
+
## undoRedoManager
@@ -591,17 +607,43 @@ Returns whether redo is possible.
#### `clear()`
Clears all command history.
-#### `subscribe(listener)`
-Subscribes a listener that will be called when command-related changes occur. You can call the returned function to unsubscribe.
-```js
-let canUndo = false;
-let canRedo = false;
+
-const unsubscribe = undoRedoManager.subscribe((manager) => {
- canUndo = manager.canUndo();
- canRedo = manager.canRedo();
-});
-```
+## π’ Full List of Available Events
+
+This is the list of events that can be subscribed to with this update. You can subscribe using `.on(eventName, callback)`.
+
+#### `Patchmap`
+
+ * `patchmap:initialized`: Fired when `patchmap.init()` completes successfully.
+ * `patchmap:draw`: Fired when new data is rendered via `patchmap.draw()`.
+ * `patchmap:updated`: Fired when elements are updated via `patchmap.update()`.
+ * `patchmap:destroyed`: Fired when the instance is destroyed by calling `patchmap.destroy()`.
+
+#### `UndoRedoManager`
+
+ * `history:executed`: Fired when a new command is added to the execution stack.
+ * `history:undone`: Fired when `undo()` is executed.
+ * `history:redone`: Fired when `redo()` is executed.
+ * `history:cleared`: Fired when all history is deleted with `clear()`.
+ * `history:destroyed`: Fired when `destroy()` is called.
+ * `history:*`: Subscribes to all of the above `history:` namespace events.
+
+#### `StateManager`
+
+ * `state:pushed`: Fired when a new state is added to the stack.
+ * `state:popped`: Fired when the current state is removed from the stack.
+ * `state:set`: Fired when the state stack is reset and a new state is set via `setState()`.
+ * `state:reset`: Fired when all states are removed with `resetState()`.
+ * `state:destroyed`: Fired when `destroy()` is called.
+ * `modifier:activated`: Fired when a modifier state is activated.
+ * `modifier:deactivated`: Fired when a modifier state is deactivated.
+ * `state:*`: Subscribes to all of the above `state:` namespace events.
+ * `modifier:*`: Subscribes to all of the above `modifier:` namespace events.
+
+#### `Transformer`
+
+ * `update_elements`: Fired when the content of `transformer.elements` or `transformer.selection` changes.
diff --git a/README_KR.md b/README_KR.md
index a784830a..18867613 100644
--- a/README_KR.md
+++ b/README_KR.md
@@ -37,7 +37,7 @@ PATCH MAPμ PATCH μλΉμ€μ μꡬ μ¬νμ μΆ©μ‘±μν€κΈ° μν΄ `pixi.js
- [canUndo()](#canundo)
- [canRedo()](#canredo)
- [clear()](#clear)
- - [subscribe(listener)](#subscribelistener)
+- [π’ μ¬μ© κ°λ₯ν μ 체 μ΄λ²€νΈ λͺ©λ‘](#-μ¬μ©-κ°λ₯ν-μ 체-μ΄λ²€νΈ-λͺ©λ‘)
- [π§βπ» κ°λ°](#-κ°λ°)
- [κ°λ° νκ²½ μΈν
](#κ°λ°-νκ²½-μΈν
)
- [VSCode ν΅ν©](#vscode-ν΅ν©)
@@ -48,19 +48,24 @@ PATCH MAPμ PATCH μλΉμ€μ μꡬ μ¬νμ μΆ©μ‘±μν€κΈ° μν΄ `pixi.js
## π μμνκΈ°
### μ€μΉ
+
#### NPM
+
```sh
npm install @conalog/patch-map
```
#### CDN
+
```html
```
### κΈ°λ³Έ μμ
+
μμνλ λ° λμμ΄ λλ κ°λ¨ν μμ μ
λλ€: [μμ ](https://codesandbox.io/p/sandbox/yvjrpx)
+
```js
import { Patchmap } from '@conalog/patch-map';
@@ -109,6 +114,7 @@ patchmap.draw(data);
## Patchmap
### `init(el, options)`
+
PATCH MAPμ μ΄κΈ°ννλ κ²μΌλ‘, 1λ²λ§ μ€νλμ΄μΌ ν©λλ€.
```js
@@ -124,6 +130,7 @@ await patchmap.init(el, {
```
#### **Options**
+
λ λλ§ λμμ μ¬μ©μ μ μνλ €λ©΄ λ€μ μ΅μ
μ μ¬μ©νμΈμ:
- `app`
@@ -395,6 +402,7 @@ patchmap.fit(['item-1', 'item-2'])
### `selector(path)`
+
[jsonpath](https://github.com/JSONPath-Plus/JSONPath) λ¬Έλ²μ λ°λ₯Έ κ°μ²΄ νμκΈ°μ
λλ€.
```js
@@ -559,6 +567,22 @@ patchmap.transformer.elements = [selectedObject];
patchmap.transformer.elements = [];
```
+#### transformer.selection
+
+`Transformer`μ μ ν μνλ₯Ό μ λ¬Έμ μΌλ‘ κ΄λ¦¬νλ `SelectionModel` μΈμ€ν΄μ€μ
λλ€. μ΄λ₯Ό ν΅ν΄ μ νλ μμλ₯Ό νλ‘κ·Έλλ° λ°©μμΌλ‘ μ μ΄ν μ μμ΅λλ€.
+
+```js
+// μ ν μμ μΆκ°, μ κ±°, κ΅μ²΄
+transformer.selection.add(item1);
+transformer.selection.remove(item1);
+transformer.selection.set([item2]);
+
+// μ ν λ³κ²½ μ΄λ²€νΈ ꡬλ
+transformer.on('update_elements', ({ current, added, removed }) => {
+ console.log('νμ¬ μ ν:', current);
+});
+```
+
## undoRedoManager
@@ -582,31 +606,61 @@ undoRedoManager.redo();
```
#### `canUndo()`
+
μ€ν μ·¨μκ° κ°λ₯νμ§ μ¬λΆλ₯Ό λ°νν©λλ€.
#### `canRedo()`
+
μ¬μ€νμ΄ κ°λ₯νμ§ μ¬λΆλ₯Ό λ°νν©λλ€.
#### `clear()`
+
λͺ¨λ λͺ
λ Ή κΈ°λ‘μ μ΄κΈ°νν©λλ€.
-#### `subscribe(listener)`
-리μ€λλ₯Ό ꡬλ
νμ¬ λͺ
λ Ή κ΄λ ¨ λ³κ²½ μ¬νμ΄ μ΄λ£¨μ΄μ‘μ λ, ν΄λΉ 리μ€λκ° νΈμΆλ©λλ€. λ°νλ ν¨μλ₯Ό νΈμΆνμ¬ κ΅¬λ
μ μ·¨μν μ μμ΅λλ€.
-```js
-let canUndo = false;
-let canRedo = false;
+
-const unsubscribe = undoRedoManager.subscribe((manager) => {
- canUndo = manager.canUndo();
- canRedo = manager.canRedo();
-});
-```
+## π’ μ¬μ© κ°λ₯ν μ 체 μ΄λ²€νΈ λͺ©λ‘
+
+μ΄λ² μ
λ°μ΄νΈλ‘ μΈν΄ ꡬλ
κ°λ₯ν μ΄λ²€νΈ λͺ©λ‘μ
λλ€. `.on(eventName, callback)`μ μ¬μ©νμ¬ κ΅¬λ
ν μ μμ΅λλ€.
+
+#### `Patchmap`
+
+ * `patchmap:initialized`: `patchmap.init()`μ΄ μ±κ³΅μ μΌλ‘ μλ£λμμ λ λ°μν©λλ€.
+ * `patchmap:draw`: `patchmap.draw()`λ₯Ό ν΅ν΄ μλ‘μ΄ λ°μ΄ν°κ° λ λλ§λμμ λ λ°μν©λλ€.
+ * `patchmap:updated`: `patchmap.update()`λ₯Ό ν΅ν΄ μμκ° μ
λ°μ΄νΈλμμ λ λ°μν©λλ€.
+ * `patchmap:destroyed`: `patchmap.destroy()`κ° νΈμΆλμ΄ μΈμ€ν΄μ€κ° νκ΄΄λ λ λ°μν©λλ€.
+
+#### `UndoRedoManager`
+
+ * `history:executed`: μλ‘μ΄ μ»€λ§¨λκ° μ€ν μ€νμ μΆκ°λμμ λ λ°μν©λλ€.
+ * `history:undone`: `undo()`κ° μ€νλμμ λ λ°μν©λλ€.
+ * `history:redone`: `redo()`κ° μ€νλμμ λ λ°μν©λλ€.
+ * `history:cleared`: `clear()`λ‘ λͺ¨λ νμ€ν λ¦¬κ° μμ λμμ λ λ°μν©λλ€.
+ * `history:destroyed`: `destroy()`κ° νΈμΆλμμ λ λ°μν©λλ€.
+ * `history:*`: μμ λͺ¨λ `history:` λ€μμ€νμ΄μ€ μ΄λ²€νΈλ₯Ό ꡬλ
ν©λλ€.
+
+#### `StateManager`
+
+ * `state:pushed`: μλ‘μ΄ μνκ° μ€νμ μΆκ°λμμ λ λ°μν©λλ€.
+ * `state:popped`: νμ¬ μνκ° μ€νμμ μ κ±°λμμ λ λ°μν©λλ€.
+ * `state:set`: `setState()`λ₯Ό ν΅ν΄ μν μ€νμ΄ λ¦¬μ
λκ³ μλ‘μ΄ μνκ° μ€μ λμμ λ λ°μν©λλ€.
+ * `state:reset`: `resetState()`λ‘ λͺ¨λ μνκ° μ κ±°λμμ λ λ°μν©λλ€.
+ * `state:destroyed`: `destroy()`κ° νΈμΆλμμ λ λ°μν©λλ€.
+ * `modifier:activated`: μμ μ(Modifier) μνκ° νμ±νλμμ λ λ°μν©λλ€.
+ * `modifier:deactivated`: μμ μ(Modifier) μνκ° λΉνμ±νλμμ λ λ°μν©λλ€.
+ * `state:*`: μμ λͺ¨λ `state:` λ€μμ€νμ΄μ€ μ΄λ²€νΈλ₯Ό ꡬλ
ν©λλ€.
+ * `modifier:*`: μμ λͺ¨λ `modifier:` λ€μμ€νμ΄μ€ μ΄λ²€νΈλ₯Ό ꡬλ
ν©λλ€.
+
+#### `Transformer`
+
+ * `update_elements`: `transformer.elements` λλ `transformer.selection`μ λ΄μ©μ΄ λ³κ²½λ λ λ°μν©λλ€.
## π§βπ» κ°λ°
### κ°λ° νκ²½ μΈν
+
```sh
npm install # μμ‘΄μ± μ€μΉ
npm run dev # κ°λ° μλ² μμ
@@ -615,9 +669,12 @@ npm run lint:fix # μ½λ ν¬λ§·ν
μμ
```
### VSCode ν΅ν©
+
μΌκ΄λ μ½λ ν¬λ§·ν
μ μν΄ Biomeμ μ€μ νμΈμ.
-1. [Biome νμ₯](https://biomejs.dev/reference/vscode/)μ μ€μΉνμΈμ.
-2. VSCode μ€μ μ μ
λ°μ΄νΈνμΈμ:
+
+1. [Biome νμ₯](https://biomejs.dev/reference/vscode/)μ μ€μΉνμΈμ.
+2. VSCode μ€μ μ μ
λ°μ΄νΈνμΈμ:
+
```json
{
"editor.formatOnSave": true,
diff --git a/src/command/UndoRedoManager.js b/src/command/UndoRedoManager.js
new file mode 100644
index 00000000..f5ee1670
--- /dev/null
+++ b/src/command/UndoRedoManager.js
@@ -0,0 +1,195 @@
+import { WildcardEventEmitter } from '../utils/event/WildcardEventEmitter';
+import { BundleCommand } from './commands';
+import { isInput } from './utils';
+
+/**
+ * Manages the command history for undo and redo operations.
+ * It records executed commands, allowing for sequential undoing and redoing.
+ * This class extends WildcardEventEmitter to notify about state changes.
+ *
+ * @extends WildcardEventEmitter
+ * @fires UndoRedoManager#history:executed
+ * @fires UndoRedoManager#history:undone
+ * @fires UndoRedoManager#history:redone
+ * @fires UndoRedoManager#history:cleared
+ * @fires UndoRedoManager#history:destroyed
+ */
+export class UndoRedoManager extends WildcardEventEmitter {
+ /**
+ * @private
+ * @type {import('./commands/base').Command[]}
+ */
+ #commands = [];
+
+ /**
+ * @private
+ * @type {number}
+ */
+ #index = -1;
+
+ /**
+ * @private
+ * @type {number}
+ */
+ #maxCommands;
+
+ /**
+ * @private
+ * @type {((e: KeyboardEvent) => void) | null}
+ */
+ #hotkeyListener;
+
+ /**
+ * @param {number} [maxCommands=50] - The maximum number of commands to store in history.
+ */
+ constructor(maxCommands = 50) {
+ super();
+ this.#maxCommands = maxCommands;
+ }
+
+ /**
+ * The list of commands in the history stack.
+ * @returns {import('./commands/base').Command[]}
+ * @readonly
+ */
+ get commands() {
+ return [...this.#commands];
+ }
+
+ /**
+ * Executes a command, adds it to the history, and clears the redo stack.
+ * If a `historyId` is provided, it may bundle the command with the previous one.
+ * @param {import('./commands/base').Command} command - The command to execute.
+ * @param {object} [options] - Options for execution.
+ * @param {string} [options.historyId] - An ID to bundle commands together into a single undo/redo step.
+ */
+ execute(command, options = {}) {
+ command.execute();
+ this.#commands = this.#commands.slice(0, this.#index + 1);
+
+ const historyId = options.historyId;
+ let commandToPush = command;
+ let isBundled = false;
+
+ if (historyId) {
+ const lastCommand = this.#commands[this.#commands.length - 1];
+ if (lastCommand && lastCommand.id === historyId) {
+ if (lastCommand instanceof BundleCommand) {
+ lastCommand.addCommand(command);
+ } else {
+ this.#commands[this.#commands.length - 1] = new BundleCommand(
+ historyId,
+ [lastCommand, command],
+ );
+ }
+ commandToPush = this.#commands[this.#commands.length - 1];
+ isBundled = true;
+ } else {
+ commandToPush = new BundleCommand(historyId, [command]);
+ }
+ }
+
+ if (!isBundled) {
+ this.#commands.push(commandToPush);
+ this.#index++;
+ if (this.#commands.length > this.#maxCommands) {
+ this.#commands.shift();
+ this.#index--;
+ }
+ }
+
+ this.emit('history:executed', { command: commandToPush, target: this });
+ }
+
+ /**
+ * Undoes the last executed command.
+ */
+ undo() {
+ if (this.canUndo()) {
+ const command = this.#commands[this.#index];
+ command.undo();
+ this.#index--;
+
+ this.emit('history:undone', { command, target: this });
+ }
+ }
+
+ /**
+ * Redoes the last undone command.
+ */
+ redo() {
+ if (this.canRedo()) {
+ this.#index++;
+ const command = this.#commands[this.#index];
+ command.execute();
+
+ this.emit('history:redone', { command, target: this });
+ }
+ }
+
+ /**
+ * Checks if there are any commands to undo.
+ * @returns {boolean} True if an undo operation is possible.
+ */
+ canUndo() {
+ return this.#index >= 0;
+ }
+
+ /**
+ * Checks if there are any commands to redo.
+ * @returns {boolean} True if a redo operation is possible.
+ */
+ canRedo() {
+ return this.#index < this.#commands.length - 1;
+ }
+
+ /**
+ * Clears the entire command history.
+ */
+ clear() {
+ this.#commands = [];
+ this.#index = -1;
+
+ this.emit('history:cleared', { target: this });
+ }
+
+ /**
+ * Sets up hotkeys for undo/redo functionality (Ctrl+Z, Ctrl+Y/Cmd+Shift+Z).
+ * @private
+ */
+ _setHotkeys() {
+ this.#hotkeyListener = (e) => {
+ const key = (e.key || '').toLowerCase();
+ if (isInput(e.target)) return;
+
+ if (key === 'z' && (e.ctrlKey || e.metaKey)) {
+ if (e.shiftKey) {
+ this.redo();
+ } else {
+ this.undo();
+ }
+ e.preventDefault();
+ }
+ if (key === 'y' && (e.ctrlKey || e.metaKey)) {
+ this.redo();
+ e.preventDefault();
+ }
+ };
+
+ document.addEventListener('keydown', this.#hotkeyListener, false);
+ }
+
+ /**
+ * Removes event listeners and clears all internal states to prevent memory leaks.
+ */
+ destroy() {
+ if (this.#hotkeyListener) {
+ document.removeEventListener('keydown', this.#hotkeyListener, false);
+ this.#hotkeyListener = null;
+ }
+ this.clear();
+
+ this.emit('history:destroyed', { target: this });
+ this.removeAllListeners();
+ }
+}
diff --git a/src/command/undo-redo-manager.test.js b/src/command/UndoRedoManager.test.js
similarity index 95%
rename from src/command/undo-redo-manager.test.js
rename to src/command/UndoRedoManager.test.js
index 9c73e2a3..2faf974e 100644
--- a/src/command/undo-redo-manager.test.js
+++ b/src/command/UndoRedoManager.test.js
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
+import { UndoRedoManager } from './UndoRedoManager';
import { Command } from './commands/base';
import { BundleCommand } from './commands/bundle';
-import { UndoRedoManager } from './undo-redo-manager';
/**
* A mock command for testing purposes.
@@ -149,24 +149,22 @@ describe('UndoRedoManager', () => {
it('should correctly notify subscribers on state changes', () => {
const manager = new UndoRedoManager();
const listener = vi.fn();
- const unsubscribe = manager.subscribe(listener);
-
- expect(listener).toHaveBeenCalledTimes(1);
+ manager.on('history:*', listener);
manager.execute(new MockCommand());
- expect(listener).toHaveBeenCalledTimes(2);
+ expect(listener).toHaveBeenCalledTimes(1);
manager.undo();
- expect(listener).toHaveBeenCalledTimes(3);
+ expect(listener).toHaveBeenCalledTimes(2);
manager.redo();
- expect(listener).toHaveBeenCalledTimes(4);
+ expect(listener).toHaveBeenCalledTimes(3);
manager.clear();
- expect(listener).toHaveBeenCalledTimes(5);
+ expect(listener).toHaveBeenCalledTimes(4);
- unsubscribe();
+ manager.off('history:*', listener);
manager.execute(new MockCommand());
- expect(listener).toHaveBeenCalledTimes(5);
+ expect(listener).toHaveBeenCalledTimes(4);
});
});
diff --git a/src/command/undo-redo-manager.js b/src/command/undo-redo-manager.js
deleted file mode 100644
index ea71449d..00000000
--- a/src/command/undo-redo-manager.js
+++ /dev/null
@@ -1,166 +0,0 @@
-import { BundleCommand } from './commands';
-import { isInput } from './utils';
-
-export class UndoRedoManager {
- constructor(maxCommands = 50) {
- this._commands = [];
- this._index = -1;
- this._listeners = new Set();
- this._maxCommands = maxCommands;
- this._hotkeyListener = null;
- }
-
- /**
- * Returns the list of commands in the history.
- * @returns {Array}
- */
- get commands() {
- return this._commands;
- }
-
- execute(command, options = {}) {
- command.execute();
- this._commands = this._commands.slice(0, this._index + 1);
-
- const historyId = options.historyId;
- let shouldPush = false;
- let commandToPush = command;
-
- if (historyId) {
- const lastCommand = this._commands[this._commands.length - 1];
- if (lastCommand && lastCommand.id === historyId) {
- if (lastCommand instanceof BundleCommand) {
- lastCommand.addCommand(command);
- } else {
- this._commands[this._commands.length - 1] = new BundleCommand(
- historyId,
- [lastCommand, command],
- );
- }
- } else {
- shouldPush = true;
- commandToPush = new BundleCommand(historyId, [command]);
- }
- } else {
- shouldPush = true;
- }
-
- if (shouldPush) {
- this._commands.push(commandToPush);
- this._index++;
- if (this._commands.length > this._maxCommands) {
- this._commands.shift();
- this._index--;
- }
- }
- this._emitChange();
- }
-
- /**
- * Undoes the last executed command.
- */
- undo() {
- if (this.canUndo()) {
- const command = this._commands[this._index];
- command.undo();
- this._index--;
- this._emitChange();
- }
- }
-
- /**
- * Redoes the last undone command.
- */
- redo() {
- if (this.canRedo()) {
- this._index++;
- const command = this._commands[this._index];
- command.execute();
- this._emitChange();
- }
- }
-
- /**
- * Checks if there are any commands to undo.
- * @returns {boolean}
- */
- canUndo() {
- return this._index >= 0;
- }
-
- /**
- * Checks if there are any commands to redo.
- * @returns {boolean}
- */
- canRedo() {
- return this._index < this._commands.length - 1;
- }
-
- /**
- * Clears the command history.
- */
- clear() {
- this._commands = [];
- this._index = -1;
- this._emitChange();
- }
-
- /**
- * Subscribes a listener to be called when the command history changes.
- * @param {Function} listener - The listener function to call.
- * @returns {Function} - A function to unsubscribe the listener.
- */
- subscribe(listener) {
- this._listeners.add(listener);
- listener(this);
- return () => {
- this._listeners.delete(listener);
- };
- }
-
- /**
- * Emits a change event to all listeners.
- * @private
- */
- _emitChange() {
- this._listeners.forEach((listener) => listener(this));
- }
-
- /**
- * Sets up hotkeys for undo/redo functionality (Ctrl+Z, Ctrl+Y).
- * @private
- */
- _setHotkeys() {
- this._hotkeyListener = (e) => {
- const key = (e.key || '').toLowerCase();
- if (isInput(e.target)) return;
-
- if (key === 'z' && (e.ctrlKey || e.metaKey)) {
- if (e.shiftKey) {
- this.redo();
- } else {
- this.undo();
- }
- e.preventDefault();
- }
- if (key === 'y' && (e.ctrlKey || e.metaKey)) {
- this.redo();
- e.preventDefault();
- }
- };
-
- document.addEventListener('keydown', this._hotkeyListener, false);
- }
-
- /**
- * Removes event listeners and clears all internal states to prevent memory leaks.
- */
- destroy() {
- if (this._hotkeyListener) {
- document.removeEventListener('keydown', this._hotkeyListener, false);
- this._hotkeyListener = null;
- }
- this.clear();
- this._listeners.clear();
- }
-}
diff --git a/src/display/update.js b/src/display/update.js
index 6f654266..1fedd173 100644
--- a/src/display/update.js
+++ b/src/display/update.js
@@ -38,6 +38,7 @@ export const update = (viewport, opts) => {
refresh: config.refresh,
});
}
+ return elements;
};
const applyRelativeTransform = (element, changes) => {
diff --git a/src/events/StateManager.js b/src/events/StateManager.js
index 9863542f..5db5d5bd 100644
--- a/src/events/StateManager.js
+++ b/src/events/StateManager.js
@@ -1,10 +1,20 @@
+import { WildcardEventEmitter } from '../utils/event/WildcardEventEmitter';
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.
+ *
+ * @extends WildcardEventEmitter
+ * @fires StateManager#state:pushed
+ * @fires StateManager#state:popped
+ * @fires StateManager#state:set
+ * @fires StateManager#state:reset
+ * @fires StateManager#state:destroyed
+ * @fires StateManager#modifier:activated
+ * @fires StateManager#modifier:deactivated
*/
-export default class StateManager {
+export default class StateManager extends WildcardEventEmitter {
/** @private */
#context;
/** @private */
@@ -23,6 +33,7 @@ export default class StateManager {
* @param {object} context - The context in which the StateManager operates, typically containing the viewport and other global instances.
*/
constructor(context) {
+ super();
this.#context = context;
}
@@ -77,7 +88,11 @@ export default class StateManager {
*/
setState(name, ...args) {
this.resetState();
- this.pushState(name, ...args);
+ const newState = this.pushState(name, ...args);
+
+ if (newState) {
+ this.emit('state:set', { state: newState, target: this });
+ }
}
/**
@@ -86,16 +101,19 @@ export default class StateManager {
resetState() {
this.exitAll();
this.#stateStack.length = 0;
+
+ this.emit('state:reset', { target: this });
}
/**
* 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.
+ * @returns {import('./states/State').default | undefined} The new state instance that was pushed, or undefined if not found.
*/
pushState(name, ...args) {
- const currentState = this.getCurrentState();
- currentState?.pause?.();
+ const pausedState = this.getCurrentState();
+ pausedState?.pause?.();
const stateDef = this.#stateRegistry.get(name);
if (!stateDef) {
@@ -114,6 +132,13 @@ export default class StateManager {
this.#stateStack.push(instance);
instance.enter?.(this.#context, ...args);
+
+ this.emit('state:pushed', {
+ pushedState: instance,
+ pausedState,
+ target: this,
+ });
+ return instance;
}
/**
@@ -124,12 +149,14 @@ export default class StateManager {
popState(payload) {
if (this.#stateStack.length === 0) return null;
- const currentState = this.#stateStack.pop();
- currentState?.exit?.();
+ const poppedState = this.#stateStack.pop();
+ poppedState?.exit?.();
+
+ const resumedState = this.getCurrentState();
+ resumedState?.resume?.(payload);
- const previousState = this.getCurrentState();
- previousState?.resume?.(payload);
- return currentState;
+ this.emit('state:popped', { poppedState, resumedState, target: this });
+ return poppedState;
}
/**
@@ -199,14 +226,26 @@ export default class StateManager {
this.#modifierState = instance;
this.#modifierState.enter?.(this.#context, ...args);
+
+ this.emit('modifier:activated', {
+ modifierState: this.#modifierState,
+ target: this,
+ });
}
/**
* Deactivates the current modifier state, restoring event handling to the main state stack.
*/
deactivateModifier() {
- this.#modifierState?.exit?.();
- this.#modifierState = null;
+ const deactivatedModifier = this.#modifierState;
+ if (deactivatedModifier) {
+ deactivatedModifier.exit?.();
+ this.#modifierState = null;
+ this.emit('modifier:deactivated', {
+ modifierState: deactivatedModifier,
+ target: this,
+ });
+ }
}
/**
@@ -280,5 +319,8 @@ export default class StateManager {
this.#modifierState = null;
this.#boundEvents.clear();
this.#eventListeners = {};
+
+ this.emit('state:destroyed', { target: this });
+ this.removeAllListeners();
}
}
diff --git a/src/patch-map.ts b/src/patch-map.ts
index f04787a0..a566d4e9 100644
--- a/src/patch-map.ts
+++ b/src/patch-map.ts
@@ -1,5 +1,5 @@
export { Patchmap } from './patchmap';
-export { UndoRedoManager } from './command/undo-redo-manager';
+export { UndoRedoManager } from './command/UndoRedoManager';
export { Command } from './command/commands/base';
export { default as Transformer } from './transformer/Transformer';
export { default as State, PROPAGATE_EVENT } from './events/states/State';
diff --git a/src/patchmap.js b/src/patchmap.js
index 2173da4a..5f2c4b1a 100644
--- a/src/patchmap.js
+++ b/src/patchmap.js
@@ -1,7 +1,7 @@
import gsap from 'gsap';
import { Application, UPDATE_PRIORITY } from 'pixi.js';
import { isValidationError } from 'zod-validation-error';
-import { UndoRedoManager } from './command/undo-redo-manager';
+import { UndoRedoManager } from './command/UndoRedoManager';
import { draw } from './display/draw';
import { update } from './display/update';
import { fit, focus } from './events/focus-fit';
@@ -22,8 +22,9 @@ import './display/components/registry';
import StateManager from './events/StateManager';
import SelectionState from './events/states/SelectionState';
import Transformer from './transformer/Transformer';
+import { WildcardEventEmitter } from './utils/event/WildcardEventEmitter';
-class Patchmap {
+class Patchmap extends WildcardEventEmitter {
_app = null;
_viewport = null;
_resizeObserver = null;
@@ -131,6 +132,7 @@ class Patchmap {
this._stateManager.register('selection', SelectionState, true);
this.transformer = transformer;
this.isInit = true;
+ this.emit('patchmap:initialized', { target: this });
}
destroy() {
@@ -139,6 +141,7 @@ class Patchmap {
this.undoRedoManager.destroy();
this.animationContext.revert();
this.stateManager.resetState();
+ this.stateManager.destroy();
event.removeAllEvent(this.viewport);
this.viewport.destroy({ children: true, context: true, style: true });
const parentElement = this.app.canvas.parentElement;
@@ -154,6 +157,9 @@ class Patchmap {
this._undoRedoManager = new UndoRedoManager();
this._animationContext = gsap.context(() => {});
this._transformer = null;
+ this._stateManager = null;
+ this.emit('patchmap:destroyed', { target: this });
+ this.removeAllListeners();
}
draw(data) {
@@ -188,6 +194,7 @@ class Patchmap {
);
this.app.start();
+ this.emit('patchmap:draw', { data: validatedData, target: this });
return validatedData;
function processData(data) {
@@ -202,7 +209,8 @@ class Patchmap {
}
update(opts) {
- update(this.viewport, opts);
+ const updatedElements = update(this.viewport, opts);
+ this.emit('patchmap:updated', { elements: updatedElements, target: this });
}
focus(ids) {
diff --git a/src/transformer/SelectionModel.js b/src/transformer/SelectionModel.js
new file mode 100644
index 00000000..1aaa9fd5
--- /dev/null
+++ b/src/transformer/SelectionModel.js
@@ -0,0 +1,66 @@
+import { convertArray } from '../utils/convert';
+import { WildcardEventEmitter } from '../utils/event/WildcardEventEmitter';
+
+export default class SelectionModel extends WildcardEventEmitter {
+ #elements = [];
+
+ get elements() {
+ return [...this.#elements];
+ }
+
+ set(elements) {
+ const newElements = elements ? convertArray(elements) : [];
+ const oldElements = this.#elements;
+ this.#elements = newElements;
+
+ const added = newElements.filter((el) => !oldElements.includes(el));
+ const removed = oldElements.filter((el) => !newElements.includes(el));
+ this.emit('update', {
+ target: this,
+ current: this.#elements,
+ added,
+ removed,
+ });
+ }
+
+ add(elementsToAdd) {
+ const added = convertArray(elementsToAdd).filter(
+ (el) => !this.#elements.includes(el),
+ );
+ if (added.length > 0) {
+ this.#elements.push(...added);
+ this.emit('update', {
+ target: this,
+ current: this.#elements,
+ added,
+ removed: [],
+ });
+ }
+ }
+
+ remove(elementsToRemove) {
+ const toRemove = convertArray(elementsToRemove);
+ const removed = [];
+ const newElements = this.#elements.filter((el) => {
+ if (toRemove.includes(el)) {
+ removed.push(el);
+ return false;
+ }
+ return true;
+ });
+
+ if (removed.length > 0) {
+ this.#elements = newElements;
+ this.emit('update', {
+ target: this,
+ current: this.#elements,
+ added: [],
+ removed,
+ });
+ }
+ }
+
+ destroy() {
+ this.removeAllListeners();
+ }
+}
diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js
index a8774b48..ab72f978 100644
--- a/src/transformer/Transformer.js
+++ b/src/transformer/Transformer.js
@@ -4,6 +4,7 @@ import { isValidationError } from 'zod-validation-error';
import { calcGroupOrientedBounds, calcOrientedBounds } from '../utils/bounds';
import { getViewport } from '../utils/get';
import { validate } from '../utils/validator';
+import SelectionModel from './SelectionModel';
import { Wireframe } from './Wireframe';
const DEFAULT_WIREFRAME_STYLE = {
@@ -13,6 +14,11 @@ const DEFAULT_WIREFRAME_STYLE = {
/**
* @typedef {'all' | 'groupOnly' | 'elementOnly' | 'none'} BoundsDisplayMode
+ * The mode for displaying 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.
*/
/**
@@ -41,6 +47,7 @@ const TransformerSchema = z
* 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
+ * @fires Transformer#update_elements
*/
export default class Transformer extends Container {
/** @private */
@@ -48,22 +55,11 @@ export default class Transformer extends Container {
/**
* 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
@@ -85,6 +81,13 @@ export default class Transformer extends Container {
*/
_viewport = null;
+ /**
+ * Manages the state of the currently selected elements.
+ * @private
+ * @type {SelectionModel}
+ */
+ _selection;
+
/**
* @param {TransformerOptions} [opts] - The options for the transformer.
*/
@@ -94,6 +97,7 @@ export default class Transformer extends Container {
const options = validate(opts, TransformerSchema);
if (isValidationError(options)) throw options;
+ this._selection = new SelectionModel();
this.#wireframe = this.addChild(new Wireframe({ label: 'wireframe' }));
this.wireframeStyle = DEFAULT_WIREFRAME_STYLE;
this.onRender = this.#refresh.bind(this);
@@ -105,6 +109,18 @@ export default class Transformer extends Container {
}
}
+ /**
+ * @event Transformer#update_elements
+ * @type {object}
+ * @property {PIXI.DisplayObject[]} current - The current array of selected elements.
+ * @property {PIXI.DisplayObject[]} added - The elements that were added in this update.
+ * @property {PIXI.DisplayObject[]} removed - The elements that were removed in this update.
+ */
+ this._selection.on('update', ({ current, added, removed }) => {
+ this.update();
+ this.emit('update_elements', { target: this, current, added, removed });
+ });
+
this.on('added', () => {
this._viewport = getViewport(this);
if (this._viewport) {
@@ -114,8 +130,9 @@ export default class Transformer extends Container {
}
/**
- * The wireframe graphics instance.
- * @returns {Wireframe}
+ * The wireframe graphics instance used for drawing bounds.
+ * @type {Wireframe}
+ * @readonly
*/
get wireframe() {
return this.#wireframe;
@@ -123,7 +140,7 @@ export default class Transformer extends Container {
/**
* The current bounds display mode.
- * @returns {BoundsDisplayMode}
+ * @type {BoundsDisplayMode}
*/
get boundsDisplayMode() {
return this._boundsDisplayMode;
@@ -138,33 +155,45 @@ export default class Transformer extends Container {
}
/**
- * The array of elements to be transformed.
- * @returns {PIXI.DisplayObject[]}
+ * The selection model instance that manages the selected elements.
+ * Use this to programmatically add, remove, or set the selection.
+ * @type {SelectionModel}
+ * @readonly
+ * @example
+ * transformer.selection.add(newElement);
+ * transformer.selection.remove(oldElement);
+ * transformer.selection.set([element1, element2]);
+ */
+ get selection() {
+ return this._selection;
+ }
+
+ /**
+ * The array of elements currently being transformed.
+ * This is a convenient getter for `selection.elements`.
+ * @type {PIXI.DisplayObject[]}
*/
get elements() {
- return this._elements;
+ return this._selection.elements;
}
/**
+ * Sets the elements to be transformed, replacing any existing selection.
+ * This is a convenient setter for `selection.set()`.
* @param {PIXI.DisplayObject | PIXI.DisplayObject[]} value
*/
set elements(value) {
- this._elements = value ? (Array.isArray(value) ? value : [value]) : [];
- this.update();
- this.emit('update_elements');
+ this._selection.set(value);
}
/**
* The style of the wireframe.
- * @returns {WireframeStyle}
+ * @type {WireframeStyle}
*/
get wireframeStyle() {
return this._wireframeStyle;
}
- /**
- * @param {Partial} value
- */
set wireframeStyle(value) {
this._wireframeStyle = Object.assign(this._wireframeStyle, value);
this.wireframe.setStrokeStyle(this.wireframeStyle);
@@ -181,6 +210,7 @@ export default class Transformer extends Container {
if (this._viewport) {
this._viewport.off('zoomed', this.update);
}
+ this.selection.destroy();
super.destroy(options);
}
diff --git a/src/utils/convert.js b/src/utils/convert.js
index fcd07f39..f7a06565 100644
--- a/src/utils/convert.js
+++ b/src/utils/convert.js
@@ -1,7 +1,7 @@
import { uid } from './uuid';
export const convertArray = (items) => {
- return Array.isArray(items) ? items : [items];
+ return Array.isArray(items) ? [...items] : [items];
};
export const convertLegacyData = (data) => {
diff --git a/src/utils/event/WildcardEventEmitter.js b/src/utils/event/WildcardEventEmitter.js
new file mode 100644
index 00000000..0142f388
--- /dev/null
+++ b/src/utils/event/WildcardEventEmitter.js
@@ -0,0 +1,66 @@
+import { EventEmitter } from 'pixi.js';
+
+/**
+ * Extends PIXI.EventEmitter to add support for namespace-based wildcard events.
+ * It enriches the wildcard event payload with structured data, including the
+ * original namespace and event type.
+ *
+ * @extends PIXI.EventEmitter
+ * @example
+ * const emitter = new WildcardEventEmitter();
+ *
+ * // Listen for a specific event
+ * emitter.on('history:undone', ({ command }) => {
+ * console.log(`Command undone: ${command.id}`);
+ * });
+ *
+ * // Listen for all events in the 'history' namespace
+ * emitter.on('history:*', ({ command }) => {
+ * console.log('A history event occurred.');
+ * });
+ *
+ * emitter.emit('history:undone', { command: { id: 'cmd-1' } });
+ * // CONSOLE LOG: Command undone: cmd-1
+ * // CONSOLE LOG: A history event occurred.
+ */
+export class WildcardEventEmitter extends EventEmitter {
+ /**
+ * Emits an event to listeners.
+ * If the event name has a namespace (e.g., 'namespace:event'), this method
+ * will first emit the specific event. Then, it will emit a corresponding
+ * wildcard event ('namespace:*') with a structured payload that includes
+ * the `namespace` and `type` of the original event.
+ *
+ * @override
+ * @param {string} eventName - The name of the event to emit.
+ * @param {...*} args - The arguments to pass to the listeners.
+ * @returns {boolean} `true` if the event had listeners, else `false`.
+ */
+ emit(eventName, ...args) {
+ const specificResult = super.emit(eventName, ...args);
+
+ const separatorIndex = eventName.indexOf(':');
+ if (separatorIndex > 0) {
+ const namespace = eventName.substring(0, separatorIndex);
+ const type = eventName.substring(separatorIndex + 1);
+ const wildcardEvent = `${namespace}:*`;
+
+ const originalPayload = args[0];
+ let wildcardArgs = args;
+
+ if (
+ originalPayload &&
+ typeof originalPayload === 'object' &&
+ !Array.isArray(originalPayload)
+ ) {
+ const wildcardPayload = { ...originalPayload, namespace, type };
+ wildcardArgs = [wildcardPayload, ...args.slice(1)];
+ }
+
+ const wildcardResult = super.emit(wildcardEvent, ...wildcardArgs);
+ return specificResult || wildcardResult;
+ }
+
+ return specificResult;
+ }
+}