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