From e104eff976a8fab3b459e4b5a977182d7f00af59 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 23 Jul 2025 18:30:54 +0900 Subject: [PATCH 01/42] add wireframe --- src/transformer/wireframe.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/transformer/wireframe.js diff --git a/src/transformer/wireframe.js b/src/transformer/wireframe.js new file mode 100644 index 00000000..2c427962 --- /dev/null +++ b/src/transformer/wireframe.js @@ -0,0 +1,18 @@ +import { Graphics } from 'pixi.js'; + +export class Wireframe extends Graphics { + constructor(transformer) { + super(); + this.transformer = transformer; + } + + drawBounds(bounds) { + if (bounds) { + const hull = bounds.hull.map((worldPoint) => { + return this.toLocal(worldPoint); + }); + + this.poly(hull).stroke(); + } + } +} From 172a8a0f3a6d3530cc23e1b24ec12fc4b7e815de Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 23 Jul 2025 18:31:43 +0900 Subject: [PATCH 02/42] add transformer --- src/transformer/transformer.js | 100 +++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/transformer/transformer.js diff --git a/src/transformer/transformer.js b/src/transformer/transformer.js new file mode 100644 index 00000000..9c659e68 --- /dev/null +++ b/src/transformer/transformer.js @@ -0,0 +1,100 @@ +import { Container } from 'pixi.js'; +import { calcGroupOrientedBounds, calcOrientedBounds } from '../utils/bounds'; +import { Wireframe } from './wireframe'; + +const DEFAULT_WIREFRAME_STYLE = { + thickness: 1.5, + color: '#1099FF', +}; + +export class Transformer extends Container { + constructor(options = {}) { + super({}); + this.zIndex = 100; + this.lazyTrigger = true; + this.wireframe = this.addChild(new Wireframe(this)); + this.onRender = this._refresh.bind(this); + + this._elements = options.elements || []; + this.lazyMode = options.lazyMode || false; + this._wireframeStyle = Object.assign( + DEFAULT_WIREFRAME_STYLE, + options.wireframeStyle || {}, + ); + this.boundsDisplayMode = options.boundsDisplayMode || 'all'; // 'all | 'groupOnly' | 'elementOnly' | 'none' + } + + get elements() { + return this._elements; + } + + set elements(value) { + this._elements = Array.isArray(value) ? value : [value]; + if (this.lazyMode) { + this.update(); + } + } + + get wireframeStyle() { + return this._wireframeStyle; + } + + set wireframeStyle(value) { + this._wireframeStyle = Object.assign(this._wireframeStyle, value); + } + + destroy(options) { + this.onRender = null; + super.destroy(options); + } + + _refresh() { + if ( + this.renderable && + this.visible && + (!this.lazyMode || this.lazyTrigger) + ) { + this.draw(); + } + } + + draw() { + const elements = this.elements; + if (!elements) { + return; + } + + const { color, thickness } = this._wireframeStyle; + this.wireframe.clear(); + if (this.boundsDisplayMode !== 'none') { + this.wireframe.setStrokeStyle({ + width: thickness / this.parent.scale.x, + color, + }); + } + + if ( + this.boundsDisplayMode === 'all' || + this.boundsDisplayMode === 'elementOnly' + ) { + elements.forEach((element) => { + this.wireframe.drawBounds(calcOrientedBounds(element)); + }); + } + + if ( + this.boundsDisplayMode === 'all' || + this.boundsDisplayMode === 'groupOnly' + ) { + const groupBounds = + elements.length > 1 ? calcGroupOrientedBounds(elements) : null; + this.wireframe.drawBounds(groupBounds); + } + + this.lazyTrigger = false; + } + + update() { + this.lazyTrigger = true; + } +} From a15d14cae4769acb531af714b47e5979a897ce1f Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 24 Jul 2025 12:22:23 +0900 Subject: [PATCH 03/42] export Transformer --- src/patch-map.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/patch-map.ts b/src/patch-map.ts index a483a7aa..4978531d 100644 --- a/src/patch-map.ts +++ b/src/patch-map.ts @@ -1,3 +1,4 @@ export { Patchmap } from './patchmap'; export { UndoRedoManager } from './command/undo-redo-manager'; export { Command } from './command/commands/base'; +export { Transformer } from './transformer/transformer'; From 28237fc5ca780461cc4889389d16cdeebddd99da Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 24 Jul 2025 12:22:36 +0900 Subject: [PATCH 04/42] add getter/setter --- src/patchmap.js | 46 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/patchmap.js b/src/patchmap.js index 6b063276..eef67b5e 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -21,20 +21,19 @@ import { themeStore } from './utils/theme'; import { validateMapData } from './utils/validator'; import './display/elements/registry'; import './display/components/registry'; +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(() => {}); + _singleSelectState = null; + _dragSelectState = null; + _transformer = null; get app() { return this._app; @@ -60,6 +59,29 @@ class Patchmap { return this._undoRedoManager; } + get transformer() { + return this._transformer; + } + + set transformer(value) { + if (this._transformer && !this._transformer.destroyed) { + 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); + } + } + get animationContext() { return this._animationContext; } From 8de9eccf396566b48c1cd8b951fadd57ee6625b3 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 24 Jul 2025 12:22:44 +0900 Subject: [PATCH 05/42] chore --- src/transformer/transformer.js | 12 ++++++------ src/transformer/wireframe.js | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/transformer/transformer.js b/src/transformer/transformer.js index 9c659e68..47b001e8 100644 --- a/src/transformer/transformer.js +++ b/src/transformer/transformer.js @@ -8,10 +8,10 @@ const DEFAULT_WIREFRAME_STYLE = { }; export class Transformer extends Container { + _renderDirty = true; + constructor(options = {}) { - super({}); - this.zIndex = 100; - this.lazyTrigger = true; + super({ zIndex: 999, isRenderGroup: true }); this.wireframe = this.addChild(new Wireframe(this)); this.onRender = this._refresh.bind(this); @@ -52,7 +52,7 @@ export class Transformer extends Container { if ( this.renderable && this.visible && - (!this.lazyMode || this.lazyTrigger) + (!this.lazyMode || this._renderDirty) ) { this.draw(); } @@ -91,10 +91,10 @@ export class Transformer extends Container { this.wireframe.drawBounds(groupBounds); } - this.lazyTrigger = false; + this._renderDirty = false; } update() { - this.lazyTrigger = true; + this._renderDirty = true; } } diff --git a/src/transformer/wireframe.js b/src/transformer/wireframe.js index 2c427962..220bcf0f 100644 --- a/src/transformer/wireframe.js +++ b/src/transformer/wireframe.js @@ -11,7 +11,6 @@ export class Wireframe extends Graphics { const hull = bounds.hull.map((worldPoint) => { return this.toLocal(worldPoint); }); - this.poly(hull).stroke(); } } From 01c4d1d2736b618397d1d17f8a06d31ac1ec8601 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 24 Jul 2025 13:01:17 +0900 Subject: [PATCH 06/42] fix Transformer --- src/transformer/transformer.js | 65 ++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/src/transformer/transformer.js b/src/transformer/transformer.js index 47b001e8..da85c375 100644 --- a/src/transformer/transformer.js +++ b/src/transformer/transformer.js @@ -1,5 +1,8 @@ import { Container } from 'pixi.js'; +import { z } from 'zod'; +import { isValidationError } from 'zod-validation-error'; import { calcGroupOrientedBounds, calcOrientedBounds } from '../utils/bounds'; +import { validate } from '../utils/validator'; import { Wireframe } from './wireframe'; const DEFAULT_WIREFRAME_STYLE = { @@ -7,21 +10,57 @@ const DEFAULT_WIREFRAME_STYLE = { color: '#1099FF', }; +const TransformerSchema = z + .object({ + elements: z.array(), + lazyMode: z.boolean(), + wireframeStyle: z.record(z.string(), z.unknown()), + boundsDisplayMode: z.enum(['all', 'groupOnly', 'elementOnly', 'none']), + }) + .partial(); + export class Transformer extends Container { + #wireframe; + _boundsDisplayMode = 'all'; + _elements = []; + _lazyMode = false; _renderDirty = true; + _wireframeStyle = DEFAULT_WIREFRAME_STYLE; - constructor(options = {}) { + constructor(opts) { super({ zIndex: 999, isRenderGroup: true }); - this.wireframe = this.addChild(new Wireframe(this)); + this.#wireframe = this.addChild(new Wireframe(this)); this.onRender = this._refresh.bind(this); - this._elements = options.elements || []; - this.lazyMode = options.lazyMode || false; - this._wireframeStyle = Object.assign( - DEFAULT_WIREFRAME_STYLE, - options.wireframeStyle || {}, - ); - this.boundsDisplayMode = options.boundsDisplayMode || 'all'; // 'all | 'groupOnly' | 'elementOnly' | 'none' + const options = validate(opts, TransformerSchema); + if (isValidationError(options)) throw options; + for (const key in options) { + if (key === 'wireframeStyle') { + this[key] = Object.assign(this[key], options[key]); + } else { + this[key] = options[key]; + } + } + } + + get wireframe() { + return this.#wireframe; + } + + get boundsDisplayMode() { + return this._boundsDisplayMode; + } + + set boundsDisplayMode(value) { + this._boundsDisplayMode = value; + } + + get lazyMode() { + return this._lazyMode; + } + + set lazyMode(value) { + this._lazyMode = value; } get elements() { @@ -41,6 +80,7 @@ export class Transformer extends Container { set wireframeStyle(value) { this._wireframeStyle = Object.assign(this._wireframeStyle, value); + this.wireframe.setStrokeStyle(this.wireframeStyle); } destroy(options) { @@ -64,13 +104,10 @@ export class Transformer extends Container { return; } - const { color, thickness } = this._wireframeStyle; this.wireframe.clear(); if (this.boundsDisplayMode !== 'none') { - this.wireframe.setStrokeStyle({ - width: thickness / this.parent.scale.x, - color, - }); + this.wireframe.strokeStyle.width = + this.wireframeStyle.thickness / this.parent.scale.x; } if ( From db4a3fd55ba5c5aef17cd2f2b919a69feb2e1d21 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 24 Jul 2025 14:49:23 +0900 Subject: [PATCH 07/42] chore --- src/transformer/{transformer.js => Transformer.js} | 6 +++--- src/transformer/{wireframe.js => Wireframe.js} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename src/transformer/{transformer.js => Transformer.js} (98%) rename src/transformer/{wireframe.js => Wireframe.js} (100%) diff --git a/src/transformer/transformer.js b/src/transformer/Transformer.js similarity index 98% rename from src/transformer/transformer.js rename to src/transformer/Transformer.js index da85c375..2ed95c96 100644 --- a/src/transformer/transformer.js +++ b/src/transformer/Transformer.js @@ -3,7 +3,6 @@ import { z } from 'zod'; import { isValidationError } from 'zod-validation-error'; import { calcGroupOrientedBounds, calcOrientedBounds } from '../utils/bounds'; import { validate } from '../utils/validator'; -import { Wireframe } from './wireframe'; const DEFAULT_WIREFRAME_STYLE = { thickness: 1.5, @@ -29,8 +28,6 @@ export class Transformer extends Container { constructor(opts) { super({ zIndex: 999, isRenderGroup: true }); - this.#wireframe = this.addChild(new Wireframe(this)); - this.onRender = this._refresh.bind(this); const options = validate(opts, TransformerSchema); if (isValidationError(options)) throw options; @@ -41,6 +38,9 @@ export class Transformer extends Container { this[key] = options[key]; } } + + this.#wireframe = this.addChild(new Wireframe(this)); + this.onRender = this._refresh.bind(this); } get wireframe() { diff --git a/src/transformer/wireframe.js b/src/transformer/Wireframe.js similarity index 100% rename from src/transformer/wireframe.js rename to src/transformer/Wireframe.js From 8f94c63316e1224c44515ab83561282d37c10f02 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 24 Jul 2025 15:07:00 +0900 Subject: [PATCH 08/42] fix --- src/patch-map.ts | 2 +- src/patchmap.js | 2 +- src/transformer/Transformer.js | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/patch-map.ts b/src/patch-map.ts index 4978531d..dae34f8d 100644 --- a/src/patch-map.ts +++ b/src/patch-map.ts @@ -1,4 +1,4 @@ export { Patchmap } from './patchmap'; export { UndoRedoManager } from './command/undo-redo-manager'; export { Command } from './command/commands/base'; -export { Transformer } from './transformer/transformer'; +export { Transformer } from './transformer/Transformer'; diff --git a/src/patchmap.js b/src/patchmap.js index eef67b5e..349827ed 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -21,7 +21,7 @@ import { themeStore } from './utils/theme'; import { validateMapData } from './utils/validator'; import './display/elements/registry'; import './display/components/registry'; -import { Transformer } from './transformer/transformer'; +import { Transformer } from './transformer/Transformer'; class Patchmap { _app = null; diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js index 2ed95c96..74255fa0 100644 --- a/src/transformer/Transformer.js +++ b/src/transformer/Transformer.js @@ -3,6 +3,7 @@ import { z } from 'zod'; import { isValidationError } from 'zod-validation-error'; import { calcGroupOrientedBounds, calcOrientedBounds } from '../utils/bounds'; import { validate } from '../utils/validator'; +import { Wireframe } from './Wireframe'; const DEFAULT_WIREFRAME_STYLE = { thickness: 1.5, @@ -31,6 +32,9 @@ export class Transformer extends Container { const options = validate(opts, TransformerSchema); if (isValidationError(options)) throw options; + + this.#wireframe = this.addChild(new Wireframe(this)); + this.onRender = this._refresh.bind(this); for (const key in options) { if (key === 'wireframeStyle') { this[key] = Object.assign(this[key], options[key]); @@ -38,9 +42,6 @@ export class Transformer extends Container { this[key] = options[key]; } } - - this.#wireframe = this.addChild(new Wireframe(this)); - this.onRender = this._refresh.bind(this); } get wireframe() { From 5ce9dddde59fbcee799d0e754229aa702b475fcc Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 24 Jul 2025 15:07:13 +0900 Subject: [PATCH 09/42] fix getViewport --- src/transformer/Transformer.js | 3 ++- src/utils/get.js | 11 ++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js index 74255fa0..f2aaa95d 100644 --- a/src/transformer/Transformer.js +++ b/src/transformer/Transformer.js @@ -2,6 +2,7 @@ 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'; @@ -108,7 +109,7 @@ export class Transformer extends Container { this.wireframe.clear(); if (this.boundsDisplayMode !== 'none') { this.wireframe.strokeStyle.width = - this.wireframeStyle.thickness / this.parent.scale.x; + this.wireframeStyle.thickness / (getViewport(this)?.scale?.x ?? 1); } if ( 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) => { From 50a7a4cb50842dd64081eb99292668b3d34a484a Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 24 Jul 2025 15:24:42 +0900 Subject: [PATCH 10/42] fix event --- src/patchmap.js | 2 ++ src/transformer/Transformer.js | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/patchmap.js b/src/patchmap.js index 349827ed..42ec0aad 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -65,6 +65,7 @@ class Patchmap { set transformer(value) { if (this._transformer && !this._transformer.destroyed) { + this.viewport.off('object_transformed', this.transformer.update); this._transformer.destroy(true); } @@ -79,6 +80,7 @@ class Patchmap { this._transformer = value; if (this._transformer) { this.viewport.addChild(this._transformer); + this.viewport.on('object_transformed', this.transformer.update); } } diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js index f2aaa95d..48f116a0 100644 --- a/src/transformer/Transformer.js +++ b/src/transformer/Transformer.js @@ -133,7 +133,7 @@ export class Transformer extends Container { this._renderDirty = false; } - update() { + update = () => { this._renderDirty = true; - } + }; } From 7d6e37539579fb06d02c53ec5bc475ae26f3c5ae Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 24 Jul 2025 18:18:45 +0900 Subject: [PATCH 11/42] trigger viewport zoomed --- src/transformer/Transformer.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js index 48f116a0..09412200 100644 --- a/src/transformer/Transformer.js +++ b/src/transformer/Transformer.js @@ -43,6 +43,13 @@ export class Transformer extends Container { this[key] = options[key]; } } + + this.on('added', () => { + const viewport = getViewport(this); + if (viewport) { + viewport.on('zoomed', this.update); + } + }); } get wireframe() { @@ -87,6 +94,10 @@ export class Transformer extends Container { destroy(options) { this.onRender = null; + const viewport = getViewport(this); + if (viewport) { + viewport.off('zoomed', this.update); + } super.destroy(options); } From 9609aac6c1705af90cf6af6ebb66d55c73c11131 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 24 Jul 2025 18:21:32 +0900 Subject: [PATCH 12/42] delete lazyMode --- src/transformer/Transformer.js | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js index 09412200..a96c0d99 100644 --- a/src/transformer/Transformer.js +++ b/src/transformer/Transformer.js @@ -14,7 +14,6 @@ const DEFAULT_WIREFRAME_STYLE = { const TransformerSchema = z .object({ elements: z.array(), - lazyMode: z.boolean(), wireframeStyle: z.record(z.string(), z.unknown()), boundsDisplayMode: z.enum(['all', 'groupOnly', 'elementOnly', 'none']), }) @@ -24,7 +23,6 @@ export class Transformer extends Container { #wireframe; _boundsDisplayMode = 'all'; _elements = []; - _lazyMode = false; _renderDirty = true; _wireframeStyle = DEFAULT_WIREFRAME_STYLE; @@ -64,23 +62,13 @@ export class Transformer extends Container { this._boundsDisplayMode = value; } - get lazyMode() { - return this._lazyMode; - } - - set lazyMode(value) { - this._lazyMode = value; - } - get elements() { return this._elements; } set elements(value) { this._elements = Array.isArray(value) ? value : [value]; - if (this.lazyMode) { - this.update(); - } + this.update(); } get wireframeStyle() { @@ -102,11 +90,7 @@ export class Transformer extends Container { } _refresh() { - if ( - this.renderable && - this.visible && - (!this.lazyMode || this._renderDirty) - ) { + if (this.renderable && this.visible && this._renderDirty) { this.draw(); } } From 0eee6cb9b1ff11973d1f7216bc76e1f5940e5de6 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 24 Jul 2025 18:26:24 +0900 Subject: [PATCH 13/42] fix --- src/transformer/Transformer.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js index a96c0d99..140760bb 100644 --- a/src/transformer/Transformer.js +++ b/src/transformer/Transformer.js @@ -25,6 +25,7 @@ export class Transformer extends Container { _elements = []; _renderDirty = true; _wireframeStyle = DEFAULT_WIREFRAME_STYLE; + _viewport = null; constructor(opts) { super({ zIndex: 999, isRenderGroup: true }); @@ -43,9 +44,9 @@ export class Transformer extends Container { } this.on('added', () => { - const viewport = getViewport(this); - if (viewport) { - viewport.on('zoomed', this.update); + this._viewport = getViewport(this); + if (this._viewport) { + this._viewport.on('zoomed', this.update); } }); } @@ -82,9 +83,8 @@ export class Transformer extends Container { destroy(options) { this.onRender = null; - const viewport = getViewport(this); - if (viewport) { - viewport.off('zoomed', this.update); + if (this._viewport) { + this._viewport.off('zoomed', this.update); } super.destroy(options); } @@ -104,7 +104,7 @@ export class Transformer extends Container { this.wireframe.clear(); if (this.boundsDisplayMode !== 'none') { this.wireframe.strokeStyle.width = - this.wireframeStyle.thickness / (getViewport(this)?.scale?.x ?? 1); + this.wireframeStyle.thickness / (this._viewport?.scale?.x ?? 1); } if ( From 36cbaaf1bca6d282fc7348eb8a463cfa048111d4 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 12 Aug 2025 10:47:38 +0900 Subject: [PATCH 14/42] fix relative transform --- src/display/update.js | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) 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; }; From 9c337bc22b7d1842b09f8fc2f16b3d01a078705a Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 12 Aug 2025 12:02:40 +0900 Subject: [PATCH 15/42] temp --- src/interaction/ToolManager.js | 48 ++++++++++++++++++++++++++ src/interaction/tools/SelectionTool.js | 7 ++++ src/interaction/tools/Tool.js | 19 ++++++++++ src/patch-map.ts | 1 + src/patchmap.js | 37 +++++--------------- 5 files changed, 84 insertions(+), 28 deletions(-) create mode 100644 src/interaction/ToolManager.js create mode 100644 src/interaction/tools/SelectionTool.js create mode 100644 src/interaction/tools/Tool.js diff --git a/src/interaction/ToolManager.js b/src/interaction/ToolManager.js new file mode 100644 index 00000000..2ca69afa --- /dev/null +++ b/src/interaction/ToolManager.js @@ -0,0 +1,48 @@ +import Tool from './tools/Tool'; + +export default class ToolManager { + #stateStack = []; + + get stateStack() { + return this.#stateStack; + } + + get currentTool() { + return this.stateStack.length > 0 + ? this.stateStack[this.stateStack.length - 1] + : null; + } + + pushState(newTool) { + if (!(newTool instanceof Tool)) { + throw new Error('Argument must be an instance of Tool'); + } + if (this.currentTool) { + this.currentTool.deactivate(); + } + this.stateStack.push(newTool); + newTool.activate(); + } + + popState() { + if (this.currentTool) { + this.currentTool.deactivate(); + this.stateStack.pop(); + } + if (this.currentTool) { + this.currentTool.activate(); + } + } + + setState(newTool) { + this.destroy(); + this.pushState(newTool); + } + + destroy() { + while (this.currentTool) { + this.currentTool.deactivate(); + this.#stateStack.pop(); + } + } +} diff --git a/src/interaction/tools/SelectionTool.js b/src/interaction/tools/SelectionTool.js new file mode 100644 index 00000000..1448eebc --- /dev/null +++ b/src/interaction/tools/SelectionTool.js @@ -0,0 +1,7 @@ +import Tool from './Tool'; + +export default class SelectionTool extends Tool { + activate() {} + + deactivate() {} +} diff --git a/src/interaction/tools/Tool.js b/src/interaction/tools/Tool.js new file mode 100644 index 00000000..5516c578 --- /dev/null +++ b/src/interaction/tools/Tool.js @@ -0,0 +1,19 @@ +export default class Tool { + #context = null; + + constructor(context) { + this.#context = context; + } + + get context() { + return this.#context; + } + + activate() { + throw new Error('The activate() method must be implemented.'); + } + + deactivate() { + throw new Error('The deactivate() method must be implemented.'); + } +} diff --git a/src/patch-map.ts b/src/patch-map.ts index dae34f8d..9ce98024 100644 --- a/src/patch-map.ts +++ b/src/patch-map.ts @@ -2,3 +2,4 @@ export { Patchmap } from './patchmap'; export { UndoRedoManager } from './command/undo-redo-manager'; export { Command } from './command/commands/base'; export { Transformer } from './transformer/Transformer'; +export { default as Tool } from './interaction/tools/Tool'; diff --git a/src/patchmap.js b/src/patchmap.js index 42ec0aad..20389688 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,6 +19,7 @@ import { themeStore } from './utils/theme'; import { validateMapData } from './utils/validator'; import './display/elements/registry'; import './display/components/registry'; +import ToolManager from './interaction/ToolManager'; import { Transformer } from './transformer/Transformer'; class Patchmap { @@ -31,9 +30,8 @@ class Patchmap { _theme = themeStore(); _undoRedoManager = new UndoRedoManager(); _animationContext = gsap.context(() => {}); - _singleSelectState = null; - _dragSelectState = null; _transformer = null; + _toolManager = null; get app() { return this._app; @@ -63,6 +61,10 @@ class Patchmap { return this._transformer; } + get toolManager() { + return this._toolManager; + } + set transformer(value) { if (this._transformer && !this._transformer.destroyed) { this.viewport.off('object_transformed', this.transformer.update); @@ -123,6 +125,7 @@ class Patchmap { initCanvas(element, this.app); this._resizeObserver = initResizeObserver(element, this.app, this.viewport); + this._toolManager = new ToolManager(); this.isInit = true; } @@ -145,8 +148,7 @@ class Patchmap { this._theme = themeStore(); this._undoRedoManager = new UndoRedoManager(); this._animationContext = gsap.context(() => {}); - this._singleSelectState = null; - this._dragSelectState = null; + this.toolManager.destroy(); } draw(data) { @@ -167,7 +169,6 @@ class Patchmap { this.undoRedoManager.clear(); this.animationContext.revert(); event.removeAllEvent(this.viewport); - this.initSelectState(); draw(context, validatedData); // Force a refresh of all relation elements after the initial draw. This ensures @@ -210,26 +211,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 }; From 52ed9fc8d4a14098238748247e2a9e7eb0a42a83 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 12 Aug 2025 12:46:09 +0900 Subject: [PATCH 16/42] fix --- src/events/StateManager.js | 217 +++++++++++++++++++++++++ src/events/states/State.js | 21 +++ src/interaction/ToolManager.js | 48 ------ src/interaction/tools/SelectionTool.js | 7 - src/interaction/tools/Tool.js | 19 --- src/patch-map.ts | 2 +- src/patchmap.js | 22 ++- 7 files changed, 255 insertions(+), 81 deletions(-) create mode 100644 src/events/StateManager.js create mode 100644 src/events/states/State.js delete mode 100644 src/interaction/ToolManager.js delete mode 100644 src/interaction/tools/SelectionTool.js delete mode 100644 src/interaction/tools/Tool.js diff --git a/src/events/StateManager.js b/src/events/StateManager.js new file mode 100644 index 00000000..322612a1 --- /dev/null +++ b/src/events/StateManager.js @@ -0,0 +1,217 @@ +/** + * Manages the state of the application, including the registration, transition, and management of states. + */ +export default class StateManager { + #context; + #stateRegistry = new Map(); + #stateStack = []; + #modifierState = null; + #boundEvents = new Set(); + #eventListeners = {}; + + /** + * Initializes the StateManager with a context. + * @param {object} context - The context in which the StateManager operates. + */ + constructor(context) { + this.#context = context; + } + + /** + * Gets the current modifier state. + * @returns {(object | null)} The current modifier state or null if none is active. + */ + get modifierState() { + return this.#modifierState; + } + + /** + * Registers a state class or singleton instance. + * @param {string} name - The unique name of the state. + * @param {(object | Function)} 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, maintaining the modifier state. + */ + transitionTo(name, ...args) { + while (this.#stateStack.length > 0) { + this.popState(); + } + this.pushState(name, ...args); + } + + /** + * Pushes a new state onto the stack. + */ + 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 and returns to the previous state. + * @param {*} payload - Payload to pass to the previous state's resume method. + * @returns {(object | 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; + } + + /** + * Gets the current active state. + * @returns {(object | null)} The current active state or null if none is active. + */ + getCurrentState() { + return this.#stateStack.length > 0 + ? this.#stateStack[this.#stateStack.length - 1] + : null; + } + + /** + * Activates a modifier state. + */ + 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. + */ + deactivateModifier() { + this.#modifierState?.exit?.(); + this.#modifierState = null; + } + + /** + * Ensures event listeners are registered for necessary events. + * @private + * @param {Array} eventNames - The names of the events to ensure listeners for. + */ + _ensureEventListeners(eventNames = []) { + const viewport = this.#context.viewport; + const dispatch = (eventName, event) => { + if (this.#modifierState) { + this.#modifierState[eventName]?.(event); + } else { + this.getCurrentState()?.[eventName]?.(event); + } + }; + + 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, releasing all resources. + */ + 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.clear(); + this.#stateStack = []; + this.#modifierState = null; + this.#boundEvents.clear(); + } +} diff --git a/src/events/states/State.js b/src/events/states/State.js new file mode 100644 index 00000000..e52b0325 --- /dev/null +++ b/src/events/states/State.js @@ -0,0 +1,21 @@ +export default class State { + static handledEvents = []; + abortController = new AbortController(); + + constructor() { + this.context = null; + } + + enter(context) { + this.context = context; + this.abortController = new AbortController(); + } + + exit() { + this.abortController.abort(); + } + + pause() {} + + resume() {} +} diff --git a/src/interaction/ToolManager.js b/src/interaction/ToolManager.js deleted file mode 100644 index 2ca69afa..00000000 --- a/src/interaction/ToolManager.js +++ /dev/null @@ -1,48 +0,0 @@ -import Tool from './tools/Tool'; - -export default class ToolManager { - #stateStack = []; - - get stateStack() { - return this.#stateStack; - } - - get currentTool() { - return this.stateStack.length > 0 - ? this.stateStack[this.stateStack.length - 1] - : null; - } - - pushState(newTool) { - if (!(newTool instanceof Tool)) { - throw new Error('Argument must be an instance of Tool'); - } - if (this.currentTool) { - this.currentTool.deactivate(); - } - this.stateStack.push(newTool); - newTool.activate(); - } - - popState() { - if (this.currentTool) { - this.currentTool.deactivate(); - this.stateStack.pop(); - } - if (this.currentTool) { - this.currentTool.activate(); - } - } - - setState(newTool) { - this.destroy(); - this.pushState(newTool); - } - - destroy() { - while (this.currentTool) { - this.currentTool.deactivate(); - this.#stateStack.pop(); - } - } -} diff --git a/src/interaction/tools/SelectionTool.js b/src/interaction/tools/SelectionTool.js deleted file mode 100644 index 1448eebc..00000000 --- a/src/interaction/tools/SelectionTool.js +++ /dev/null @@ -1,7 +0,0 @@ -import Tool from './Tool'; - -export default class SelectionTool extends Tool { - activate() {} - - deactivate() {} -} diff --git a/src/interaction/tools/Tool.js b/src/interaction/tools/Tool.js deleted file mode 100644 index 5516c578..00000000 --- a/src/interaction/tools/Tool.js +++ /dev/null @@ -1,19 +0,0 @@ -export default class Tool { - #context = null; - - constructor(context) { - this.#context = context; - } - - get context() { - return this.#context; - } - - activate() { - throw new Error('The activate() method must be implemented.'); - } - - deactivate() { - throw new Error('The deactivate() method must be implemented.'); - } -} diff --git a/src/patch-map.ts b/src/patch-map.ts index 9ce98024..2cbafea9 100644 --- a/src/patch-map.ts +++ b/src/patch-map.ts @@ -2,4 +2,4 @@ export { Patchmap } from './patchmap'; export { UndoRedoManager } from './command/undo-redo-manager'; export { Command } from './command/commands/base'; export { Transformer } from './transformer/Transformer'; -export { default as Tool } from './interaction/tools/Tool'; +export { default as State } from './events/states/State'; diff --git a/src/patchmap.js b/src/patchmap.js index 20389688..511e72df 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -19,7 +19,7 @@ import { themeStore } from './utils/theme'; import { validateMapData } from './utils/validator'; import './display/elements/registry'; import './display/components/registry'; -import ToolManager from './interaction/ToolManager'; +import StateManager from './events/StateManager'; import { Transformer } from './transformer/Transformer'; class Patchmap { @@ -31,7 +31,7 @@ class Patchmap { _undoRedoManager = new UndoRedoManager(); _animationContext = gsap.context(() => {}); _transformer = null; - _toolManager = null; + _stateManager = null; get app() { return this._app; @@ -61,8 +61,8 @@ class Patchmap { return this._transformer; } - get toolManager() { - return this._toolManager; + get stateManager() { + return this._stateManager; } set transformer(value) { @@ -125,7 +125,18 @@ class Patchmap { initCanvas(element, this.app); this._resizeObserver = initResizeObserver(element, this.app, this.viewport); - this._toolManager = new ToolManager(); + + const context = { + app: this.app, + viewport: this.viewport, + undoRedoManager: this.undoRedoManager, + theme: this.theme, + animationContext: this.animationContext, + state: null, + }; + this._stateManager = new StateManager(context); + context.state = this._stateManager; + this.isInit = true; } @@ -148,7 +159,6 @@ class Patchmap { this._theme = themeStore(); this._undoRedoManager = new UndoRedoManager(); this._animationContext = gsap.context(() => {}); - this.toolManager.destroy(); } draw(data) { From 88d35629bcfa9622d5788441a896c98016a120b4 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 12 Aug 2025 14:23:14 +0900 Subject: [PATCH 17/42] add SelectionState --- src/events/drag-select.js | 162 ---------------------------- src/events/find.js | 24 +++-- src/events/schema.js | 18 ---- src/events/single-select.js | 98 ----------------- src/events/states/SelectionState.js | 96 +++++++++++++++++ src/events/utils.js | 2 +- src/patchmap.js | 2 + 7 files changed, 115 insertions(+), 287 deletions(-) delete mode 100644 src/events/drag-select.js delete mode 100644 src/events/single-select.js create mode 100644 src/events/states/SelectionState.js 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..51eb8e48 --- /dev/null +++ b/src/events/states/SelectionState.js @@ -0,0 +1,96 @@ +import { Graphics } from 'pixi.js'; +import { findIntersectObject, findIntersectObjects } from '../find'; +import { isMoved } from '../utils'; +import State from './State'; + +export default class SelectionState extends State { + static handledEvents = ['onpointerdown', 'onpointermove', 'onpointerup']; + + isDragging = false; + dragStartPoint = null; + isDragSelecting = false; + _selectionBox = new Graphics(); + + enter(context, config) { + super.enter(context); + this.config = { + draggable: false, + filter: null, + selectUnit: 'entity', + onOver: () => {}, + onSelect: () => {}, + onDragSelect: () => {}, + ...config, + }; + } + + exit() { + super.exit(); + this.#clear(); + } + + onpointerdown(e) { + this.isDragging = true; + this.dragStartPoint = this.context.viewport.toWorld(e.global); + } + + onpointermove(e) { + if (!this.isDragging) return; + const currentPoint = this.context.viewport.toWorld(e.global); + + if ( + this.config.draggable && + isMoved(this.dragStartPoint, currentPoint, this.context.viewport.scale) + ) { + this.isDragSelecting = true; + this.#drawSelectionBox(this.dragStartPoint, currentPoint); + } + } + + onpointerup(e) { + if (!this.isDragging) return; + + if (this.isDragSelecting) { + const selected = findIntersectObjects( + this.context.viewport, + this._selectionBox, + this.config, + ); + this.config.onDragSelect(selected, e); + } else { + const selected = findIntersectObject( + this.context.viewport, + this.dragStartPoint, + this.config, + ); + this.config.onSelect(selected, e); + } + this.#clear(); + } + + #drawSelectionBox(p1, p2) { + if (!p1 || !p2) return; + + if (!this._selectionBox.parent) { + this.context.viewport.addChild(this._selectionBox); + } + + 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({ color: '#9FD6FF', alpha: 0.2 }) + .stroke({ width: 2, color: '#1099FF', pixelLine: true }); + } + + #clear() { + this.isDragging = false; + this.dragStartPoint = null; + this.isDragSelecting = false; + this._selectionBox.clear(); + } +} 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/patchmap.js b/src/patchmap.js index 511e72df..68e2ca93 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -20,6 +20,7 @@ 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 { @@ -136,6 +137,7 @@ class Patchmap { }; this._stateManager = new StateManager(context); context.state = this._stateManager; + this._stateManager.register('selection', SelectionState, true); this.isInit = true; } From 36a7ec9e27a605607f2e981426387e96ce6ce4ad Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 12 Aug 2025 16:17:15 +0900 Subject: [PATCH 18/42] add TransformState --- src/events/StateManager.js | 12 ++++++ src/events/states/SelectionState.js | 61 +++++++++++++++++++++++++---- src/events/states/State.js | 4 ++ src/events/states/TransformState.js | 59 ++++++++++++++++++++++++++++ src/patch-map.ts | 2 +- src/patchmap.js | 6 +-- src/transformer/Transformer.js | 24 +++++++++--- src/utils/bounds.js | 2 +- 8 files changed, 152 insertions(+), 18 deletions(-) create mode 100644 src/events/states/TransformState.js diff --git a/src/events/StateManager.js b/src/events/StateManager.js index 322612a1..bacb1a7f 100644 --- a/src/events/StateManager.js +++ b/src/events/StateManager.js @@ -25,6 +25,10 @@ export default class StateManager { return this.#modifierState; } + get stateRegistry() { + return this.#stateRegistry; + } + /** * Registers a state class or singleton instance. * @param {string} name - The unique name of the state. @@ -209,6 +213,14 @@ export default class StateManager { 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; diff --git a/src/events/states/SelectionState.js b/src/events/states/SelectionState.js index 51eb8e48..97dc8d11 100644 --- a/src/events/states/SelectionState.js +++ b/src/events/states/SelectionState.js @@ -1,7 +1,9 @@ import { Graphics } from 'pixi.js'; +import Transformer from '../../transformer/Transformer'; import { findIntersectObject, findIntersectObjects } from '../find'; import { isMoved } from '../utils'; import State from './State'; +import TransformState from './TransformState'; export default class SelectionState extends State { static handledEvents = ['onpointerdown', 'onpointermove', 'onpointerup']; @@ -15,13 +17,21 @@ export default class SelectionState extends State { super.enter(context); this.config = { draggable: false, - filter: null, + filter: () => true, selectUnit: 'entity', + transformer: null, onOver: () => {}, onSelect: () => {}, onDragSelect: () => {}, ...config, }; + + if ( + this.config.transformer && + !this.context.stateManager.stateRegistry.has('transform') + ) { + this.context.stateManager.register('transform', TransformState, false); + } } exit() { @@ -29,9 +39,34 @@ export default class SelectionState extends State { this.#clear(); } + destroy() { + this._selectionBox.destroy(true); + super.destroy(); + } + onpointerdown(e) { this.isDragging = true; this.dragStartPoint = this.context.viewport.toWorld(e.global); + + const transformer = this.config.transformer; + if (transformer) { + const selected = this.findPoint(this.dragStartPoint); + if ( + !transformer.elements.includes(selected) && + selected !== transformer + ) { + this.config.onSelect(selected, e); + } + + if (selected) { + this.context.stateManager.pushState( + 'transform', + e, + transformer.elements, + ); + this.#clear(); + } + } } onpointermove(e) { @@ -51,17 +86,15 @@ export default class SelectionState extends State { if (!this.isDragging) return; if (this.isDragSelecting) { - const selected = findIntersectObjects( - this.context.viewport, + const selected = this.findPolygon( this._selectionBox, - this.config, + (obj) => this.config.filter(obj) && !(obj instanceof Transformer), ); this.config.onDragSelect(selected, e); } else { - const selected = findIntersectObject( - this.context.viewport, + const selected = this.findPoint( this.dragStartPoint, - this.config, + (obj) => this.config.filter(obj) && !(obj instanceof Transformer), ); this.config.onSelect(selected, e); } @@ -93,4 +126,18 @@ export default class SelectionState extends State { this.isDragSelecting = false; this._selectionBox.clear(); } + + findPoint(point, filter) { + return findIntersectObject(this.context.viewport, point, { + ...this.config, + filter, + }); + } + + findPolygon(polygon, filter) { + return findIntersectObjects(this.context.viewport, polygon, { + ...this.config, + filter, + }); + } } diff --git a/src/events/states/State.js b/src/events/states/State.js index e52b0325..7da5761c 100644 --- a/src/events/states/State.js +++ b/src/events/states/State.js @@ -18,4 +18,8 @@ export default class State { pause() {} resume() {} + + destroy() { + this.exit(); + } } diff --git a/src/events/states/TransformState.js b/src/events/states/TransformState.js new file mode 100644 index 00000000..6428fbde --- /dev/null +++ b/src/events/states/TransformState.js @@ -0,0 +1,59 @@ +import { update } from '../../display/update'; +import { uid } from '../../utils/uuid'; +import { isMoved } from '../utils'; +import State from './State'; + +export default class TransformState extends State { + #dragState = {}; + _elements = []; + + static handledEvents = ['onpointermove', 'onpointerup', 'onpointerupoutside']; + + enter(context, event, elements) { + super.enter(context); + Object.assign(this.#dragState, { + isDragging: true, + startPoint: this.context.viewport.toWorld({ ...event.global }), + historyId: uid(), + }); + this._elements = elements; + } + + exit() { + super.exit(); + } + + onpointermove(e) { + if (!this.#dragState.isDragging || !e.global) return; + this.#dragState.endPoint = this.context.viewport.toWorld({ ...e.global }); + + this.#dragState.isMoved = isMoved( + this.#dragState.startPoint, + this.#dragState.endPoint, + this.context.viewport.scale, + ); + + if (!this.#dragState.isMoved) { + return; + } + + const dx = this.#dragState.endPoint.x - this.#dragState.startPoint.x; + const dy = this.#dragState.endPoint.y - this.#dragState.startPoint.y; + + update(this.context.viewport, { + elements: this._elements, + changes: { attrs: { x: dx, y: dy } }, + relativeTransform: true, + history: this.#dragState.historyId, + }); + this.#dragState.startPoint = this.#dragState.endPoint; + } + + onpointerup() { + this.context.stateManager.popState(); + } + + onpointerupoutside(e) { + this.onpointerup(e); + } +} diff --git a/src/patch-map.ts b/src/patch-map.ts index 2cbafea9..ff237f67 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 { Command } from './command/commands/base'; -export { Transformer } from './transformer/Transformer'; +export { default as Transformer } from './transformer/Transformer'; export { default as State } from './events/states/State'; diff --git a/src/patchmap.js b/src/patchmap.js index 68e2ca93..7df5e476 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -21,7 +21,7 @@ import './display/elements/registry'; import './display/components/registry'; import StateManager from './events/StateManager'; import SelectionState from './events/states/SelectionState'; -import { Transformer } from './transformer/Transformer'; +import Transformer from './transformer/Transformer'; class Patchmap { _app = null; @@ -133,10 +133,10 @@ class Patchmap { undoRedoManager: this.undoRedoManager, theme: this.theme, animationContext: this.animationContext, - state: null, + stateManager: null, }; this._stateManager = new StateManager(context); - context.state = this._stateManager; + context.stateManager = this._stateManager; this._stateManager.register('selection', SelectionState, true); this.isInit = true; diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js index 140760bb..7f3671c9 100644 --- a/src/transformer/Transformer.js +++ b/src/transformer/Transformer.js @@ -1,4 +1,4 @@ -import { Container } from 'pixi.js'; +import { Container, Polygon } from 'pixi.js'; import { z } from 'zod'; import { isValidationError } from 'zod-validation-error'; import { calcGroupOrientedBounds, calcOrientedBounds } from '../utils/bounds'; @@ -19,7 +19,9 @@ const TransformerSchema = z }) .partial(); -export class Transformer extends Container { +export default class Transformer extends Container { + static isSelectable = true; + #wireframe; _boundsDisplayMode = 'all'; _elements = []; @@ -97,11 +99,15 @@ export class Transformer extends Container { draw() { const elements = this.elements; - if (!elements) { + let groupBounds = null; + this.hitArea = null; + this.wireframe.clear(); + + if (!elements || elements.length === 0) { + this._renderDirty = false; return; } - this.wireframe.clear(); if (this.boundsDisplayMode !== 'none') { this.wireframe.strokeStyle.width = this.wireframeStyle.thickness / (this._viewport?.scale?.x ?? 1); @@ -120,11 +126,17 @@ export class Transformer extends Container { this.boundsDisplayMode === 'all' || this.boundsDisplayMode === 'groupOnly' ) { - const groupBounds = - elements.length > 1 ? calcGroupOrientedBounds(elements) : null; + groupBounds = calcGroupOrientedBounds(elements); this.wireframe.drawBounds(groupBounds); } + if (groupBounds) { + const hullPoints = groupBounds.hull.map((worldPoint) => + this.toLocal(worldPoint), + ); + this.hitArea = new Polygon(hullPoints); + } + this._renderDirty = false; } 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) => { From 4d93cd05d0aef111f331d68e29c5565927304980 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 13 Aug 2025 18:50:48 +0900 Subject: [PATCH 19/42] fix --- src/display/elements/Relations.js | 2 +- src/events/StateManager.js | 2 +- src/events/states/SelectionState.js | 77 +++++++------------------ src/events/states/TransformState.js | 59 ------------------- src/patch-map.ts | 1 + src/patchmap.js | 23 ++------ src/transformer/Transformer.js | 10 ++-- src/transformer/Wireframe.js | 5 +- src/utils/event/canvas.js | 2 +- src/utils/index.js | 2 + src/utils/intersects/intersect-point.js | 4 +- 11 files changed, 40 insertions(+), 147 deletions(-) delete mode 100644 src/events/states/TransformState.js create mode 100644 src/utils/index.js 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/events/StateManager.js b/src/events/StateManager.js index bacb1a7f..0cefcde8 100644 --- a/src/events/StateManager.js +++ b/src/events/StateManager.js @@ -58,7 +58,7 @@ export default class StateManager { /** * Transitions to a new state, maintaining the modifier state. */ - transitionTo(name, ...args) { + set(name, ...args) { while (this.#stateStack.length > 0) { this.popState(); } diff --git a/src/events/states/SelectionState.js b/src/events/states/SelectionState.js index 97dc8d11..2b1d809c 100644 --- a/src/events/states/SelectionState.js +++ b/src/events/states/SelectionState.js @@ -1,16 +1,14 @@ import { Graphics } from 'pixi.js'; -import Transformer from '../../transformer/Transformer'; import { findIntersectObject, findIntersectObjects } from '../find'; import { isMoved } from '../utils'; import State from './State'; -import TransformState from './TransformState'; export default class SelectionState extends State { static handledEvents = ['onpointerdown', 'onpointermove', 'onpointerup']; - isDragging = false; + isPointerdown = false; dragStartPoint = null; - isDragSelecting = false; + isDragging = false; _selectionBox = new Graphics(); enter(context, config) { @@ -19,19 +17,11 @@ export default class SelectionState extends State { draggable: false, filter: () => true, selectUnit: 'entity', - transformer: null, onOver: () => {}, onSelect: () => {}, onDragSelect: () => {}, ...config, }; - - if ( - this.config.transformer && - !this.context.stateManager.stateRegistry.has('transform') - ) { - this.context.stateManager.register('transform', TransformState, false); - } } exit() { @@ -39,63 +29,44 @@ export default class SelectionState extends State { this.#clear(); } + pause() { + this.#clear(); + } + destroy() { this._selectionBox.destroy(true); super.destroy(); } onpointerdown(e) { - this.isDragging = true; + this.isPointerdown = true; this.dragStartPoint = this.context.viewport.toWorld(e.global); - const transformer = this.config.transformer; - if (transformer) { - const selected = this.findPoint(this.dragStartPoint); - if ( - !transformer.elements.includes(selected) && - selected !== transformer - ) { - this.config.onSelect(selected, e); - } - - if (selected) { - this.context.stateManager.pushState( - 'transform', - e, - transformer.elements, - ); - this.#clear(); - } - } + const selected = this.findPoint(this.dragStartPoint); + this.config.onSelect(selected, e); } onpointermove(e) { - if (!this.isDragging) return; + if (!this.isPointerdown) return; const currentPoint = this.context.viewport.toWorld(e.global); if ( this.config.draggable && isMoved(this.dragStartPoint, currentPoint, this.context.viewport.scale) ) { - this.isDragSelecting = true; + this.isDragging = true; this.#drawSelectionBox(this.dragStartPoint, currentPoint); } } onpointerup(e) { - if (!this.isDragging) return; + if (!this.isPointerdown) return; - if (this.isDragSelecting) { - const selected = this.findPolygon( - this._selectionBox, - (obj) => this.config.filter(obj) && !(obj instanceof Transformer), - ); + if (this.isDragging) { + const selected = this.findPolygon(this._selectionBox); this.config.onDragSelect(selected, e); } else { - const selected = this.findPoint( - this.dragStartPoint, - (obj) => this.config.filter(obj) && !(obj instanceof Transformer), - ); + const selected = this.findPoint(this.dragStartPoint); this.config.onSelect(selected, e); } this.#clear(); @@ -121,23 +92,17 @@ export default class SelectionState extends State { } #clear() { - this.isDragging = false; + this.isPointerdown = false; this.dragStartPoint = null; - this.isDragSelecting = false; + this.isDragging = false; this._selectionBox.clear(); } - findPoint(point, filter) { - return findIntersectObject(this.context.viewport, point, { - ...this.config, - filter, - }); + findPoint(point) { + return findIntersectObject(this.context.viewport, point, this.config); } - findPolygon(polygon, filter) { - return findIntersectObjects(this.context.viewport, polygon, { - ...this.config, - filter, - }); + findPolygon(polygon) { + return findIntersectObjects(this.context.viewport, polygon, this.config); } } diff --git a/src/events/states/TransformState.js b/src/events/states/TransformState.js deleted file mode 100644 index 6428fbde..00000000 --- a/src/events/states/TransformState.js +++ /dev/null @@ -1,59 +0,0 @@ -import { update } from '../../display/update'; -import { uid } from '../../utils/uuid'; -import { isMoved } from '../utils'; -import State from './State'; - -export default class TransformState extends State { - #dragState = {}; - _elements = []; - - static handledEvents = ['onpointermove', 'onpointerup', 'onpointerupoutside']; - - enter(context, event, elements) { - super.enter(context); - Object.assign(this.#dragState, { - isDragging: true, - startPoint: this.context.viewport.toWorld({ ...event.global }), - historyId: uid(), - }); - this._elements = elements; - } - - exit() { - super.exit(); - } - - onpointermove(e) { - if (!this.#dragState.isDragging || !e.global) return; - this.#dragState.endPoint = this.context.viewport.toWorld({ ...e.global }); - - this.#dragState.isMoved = isMoved( - this.#dragState.startPoint, - this.#dragState.endPoint, - this.context.viewport.scale, - ); - - if (!this.#dragState.isMoved) { - return; - } - - const dx = this.#dragState.endPoint.x - this.#dragState.startPoint.x; - const dy = this.#dragState.endPoint.y - this.#dragState.startPoint.y; - - update(this.context.viewport, { - elements: this._elements, - changes: { attrs: { x: dx, y: dy } }, - relativeTransform: true, - history: this.#dragState.historyId, - }); - this.#dragState.startPoint = this.#dragState.endPoint; - } - - onpointerup() { - this.context.stateManager.popState(); - } - - onpointerupoutside(e) { - this.onpointerup(e); - } -} diff --git a/src/patch-map.ts b/src/patch-map.ts index ff237f67..3444bd7c 100644 --- a/src/patch-map.ts +++ b/src/patch-map.ts @@ -3,3 +3,4 @@ export { UndoRedoManager } from './command/undo-redo-manager'; export { Command } from './command/commands/base'; export { default as Transformer } from './transformer/Transformer'; export { default as State } from './events/states/State'; +export * from './utils'; diff --git a/src/patchmap.js b/src/patchmap.js index 7df5e476..4b90d455 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -62,10 +62,6 @@ class Patchmap { return this._transformer; } - get stateManager() { - return this._stateManager; - } - set transformer(value) { if (this._transformer && !this._transformer.destroyed) { this.viewport.off('object_transformed', this.transformer.update); @@ -87,6 +83,10 @@ class Patchmap { } } + get stateManager() { + return this._stateManager; + } + get animationContext() { return this._animationContext; } @@ -126,19 +126,7 @@ class Patchmap { initCanvas(element, this.app); this._resizeObserver = initResizeObserver(element, this.app, this.viewport); - - const context = { - app: this.app, - viewport: this.viewport, - undoRedoManager: this.undoRedoManager, - theme: this.theme, - animationContext: this.animationContext, - stateManager: null, - }; - this._stateManager = new StateManager(context); - context.stateManager = this._stateManager; - this._stateManager.register('selection', SelectionState, true); - + this._stateManager = new StateManager(this); this.isInit = true; } @@ -182,6 +170,7 @@ class Patchmap { this.animationContext.revert(); event.removeAllEvent(this.viewport); 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 diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js index 7f3671c9..e2ceb82d 100644 --- a/src/transformer/Transformer.js +++ b/src/transformer/Transformer.js @@ -20,8 +20,6 @@ const TransformerSchema = z .partial(); export default class Transformer extends Container { - static isSelectable = true; - #wireframe; _boundsDisplayMode = 'all'; _elements = []; @@ -30,12 +28,12 @@ export default class Transformer extends Container { _viewport = null; constructor(opts) { - super({ zIndex: 999, isRenderGroup: true }); + super({ zIndex: 999, isRenderGroup: true, id: 'transformer' }); const options = validate(opts, TransformerSchema); if (isValidationError(options)) throw options; - this.#wireframe = this.addChild(new Wireframe(this)); + this.#wireframe = this.addChild(new Wireframe({ label: 'wireframe' })); this.onRender = this._refresh.bind(this); for (const key in options) { if (key === 'wireframeStyle') { @@ -100,7 +98,7 @@ export default class Transformer extends Container { draw() { const elements = this.elements; let groupBounds = null; - this.hitArea = null; + this.wireframe.hitArea = null; this.wireframe.clear(); if (!elements || elements.length === 0) { @@ -134,7 +132,7 @@ export default class Transformer extends Container { const hullPoints = groupBounds.hull.map((worldPoint) => this.toLocal(worldPoint), ); - this.hitArea = new Polygon(hullPoints); + this.wireframe.hitArea = new Polygon(hullPoints); } this._renderDirty = false; diff --git a/src/transformer/Wireframe.js b/src/transformer/Wireframe.js index 220bcf0f..5f91df6b 100644 --- a/src/transformer/Wireframe.js +++ b/src/transformer/Wireframe.js @@ -1,10 +1,7 @@ import { Graphics } from 'pixi.js'; export class Wireframe extends Graphics { - constructor(transformer) { - super(); - this.transformer = transformer; - } + static isSelectable = true; drawBounds(bounds) { if (bounds) { 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/index.js b/src/utils/index.js new file mode 100644 index 00000000..afdf6b3f --- /dev/null +++ b/src/utils/index.js @@ -0,0 +1,2 @@ +export { uid } from './uuid'; +export { intersectPoint } from './intersects/intersect-point'; 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); } From 9a0969c259b3b8fec77fd894feb379a0c15ca457 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 Aug 2025 10:59:47 +0900 Subject: [PATCH 20/42] fix --- src/events/states/SelectionState.js | 34 ++++++++++++++++++----------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/events/states/SelectionState.js b/src/events/states/SelectionState.js index 2b1d809c..90897614 100644 --- a/src/events/states/SelectionState.js +++ b/src/events/states/SelectionState.js @@ -22,6 +22,7 @@ export default class SelectionState extends State { onDragSelect: () => {}, ...config, }; + this.viewport = this.context.viewport; } exit() { @@ -40,22 +41,21 @@ export default class SelectionState extends State { onpointerdown(e) { this.isPointerdown = true; - this.dragStartPoint = this.context.viewport.toWorld(e.global); - - const selected = this.findPoint(this.dragStartPoint); - this.config.onSelect(selected, e); + this.dragStartPoint = this.viewport.toWorld(e.global); + this.select(e); } onpointermove(e) { if (!this.isPointerdown) return; - const currentPoint = this.context.viewport.toWorld(e.global); + const currentPoint = this.viewport.toWorld(e.global); if ( this.config.draggable && - isMoved(this.dragStartPoint, currentPoint, this.context.viewport.scale) + isMoved(this.dragStartPoint, currentPoint, this.viewport.scale) ) { this.isDragging = true; this.#drawSelectionBox(this.dragStartPoint, currentPoint); + this.dragSelect(e); } } @@ -63,11 +63,9 @@ export default class SelectionState extends State { if (!this.isPointerdown) return; if (this.isDragging) { - const selected = this.findPolygon(this._selectionBox); - this.config.onDragSelect(selected, e); + this.dragSelect(e); } else { - const selected = this.findPoint(this.dragStartPoint); - this.config.onSelect(selected, e); + this.select(e); } this.#clear(); } @@ -76,7 +74,7 @@ export default class SelectionState extends State { if (!p1 || !p2) return; if (!this._selectionBox.parent) { - this.context.viewport.addChild(this._selectionBox); + this.viewport.addChild(this._selectionBox); } this._selectionBox.clear(); @@ -98,11 +96,21 @@ export default class SelectionState extends State { this._selectionBox.clear(); } + select(e) { + const selected = this.findPoint(this.dragStartPoint); + this.config.onSelect(selected, e); + } + + dragSelect(e) { + const selected = this.findPolygon(this._selectionBox); + this.config.onDragSelect(selected, e); + } + findPoint(point) { - return findIntersectObject(this.context.viewport, point, this.config); + return findIntersectObject(this.viewport, point, this.config); } findPolygon(polygon) { - return findIntersectObjects(this.context.viewport, polygon, this.config); + return findIntersectObjects(this.viewport, polygon, this.config); } } From a4c31290044812fc33445a3b045838015e0601de Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 Aug 2025 11:39:42 +0900 Subject: [PATCH 21/42] delete hitarea --- src/transformer/Transformer.js | 11 +---------- src/utils/index.js | 1 + 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js index e2ceb82d..555f2dbb 100644 --- a/src/transformer/Transformer.js +++ b/src/transformer/Transformer.js @@ -1,4 +1,4 @@ -import { Container, Polygon } from 'pixi.js'; +import { Container } from 'pixi.js'; import { z } from 'zod'; import { isValidationError } from 'zod-validation-error'; import { calcGroupOrientedBounds, calcOrientedBounds } from '../utils/bounds'; @@ -98,7 +98,6 @@ export default class Transformer extends Container { draw() { const elements = this.elements; let groupBounds = null; - this.wireframe.hitArea = null; this.wireframe.clear(); if (!elements || elements.length === 0) { @@ -127,14 +126,6 @@ export default class Transformer extends Container { groupBounds = calcGroupOrientedBounds(elements); this.wireframe.drawBounds(groupBounds); } - - if (groupBounds) { - const hullPoints = groupBounds.hull.map((worldPoint) => - this.toLocal(worldPoint), - ); - this.wireframe.hitArea = new Polygon(hullPoints); - } - this._renderDirty = false; } diff --git a/src/utils/index.js b/src/utils/index.js index afdf6b3f..63948010 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,2 +1,3 @@ export { uid } from './uuid'; export { intersectPoint } from './intersects/intersect-point'; +export { isMoved } from '../events/utils'; From 1b1ef1ea27d08a35b84bb8d7e7f52ebe32ba60ef Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 Aug 2025 12:49:36 +0900 Subject: [PATCH 22/42] fix --- src/utils/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/index.js b/src/utils/index.js index 63948010..d75e0331 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,3 +1,4 @@ export { uid } from './uuid'; export { intersectPoint } from './intersects/intersect-point'; export { isMoved } from '../events/utils'; +export { findIntersectObject } from '../events/find'; From 4a13cb6be8cb6dec2c011355ffc5a4d44d640075 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 Aug 2025 15:36:26 +0900 Subject: [PATCH 23/42] add propagate event --- src/events/StateManager.js | 23 +++++++++++++++++++++-- src/events/states/SelectionState.js | 2 +- src/events/states/State.js | 2 ++ src/patch-map.ts | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/events/StateManager.js b/src/events/StateManager.js index 0cefcde8..19d08687 100644 --- a/src/events/StateManager.js +++ b/src/events/StateManager.js @@ -1,3 +1,5 @@ +import { PROPAGATE_EVENT } from './states/State'; + /** * Manages the state of the application, including the registration, transition, and management of states. */ @@ -107,6 +109,12 @@ export default class StateManager { return currentState; } + exitAll() { + this.#stateStack.forEach((state) => { + state?.exit?.(); + }); + } + /** * Gets the current active state. * @returns {(object | null)} The current active state or null if none is active. @@ -179,8 +187,19 @@ export default class StateManager { const dispatch = (eventName, event) => { if (this.#modifierState) { this.#modifierState[eventName]?.(event); - } else { - this.getCurrentState()?.[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; + } } }; diff --git a/src/events/states/SelectionState.js b/src/events/states/SelectionState.js index 90897614..77830085 100644 --- a/src/events/states/SelectionState.js +++ b/src/events/states/SelectionState.js @@ -31,7 +31,7 @@ export default class SelectionState extends State { } pause() { - this.#clear(); + this._selectionBox.clear(); } destroy() { diff --git a/src/events/states/State.js b/src/events/states/State.js index 7da5761c..75757a99 100644 --- a/src/events/states/State.js +++ b/src/events/states/State.js @@ -1,3 +1,5 @@ +export const PROPAGATE_EVENT = Symbol('propagate_event'); + export default class State { static handledEvents = []; abortController = new AbortController(); diff --git a/src/patch-map.ts b/src/patch-map.ts index 3444bd7c..f04787a0 100644 --- a/src/patch-map.ts +++ b/src/patch-map.ts @@ -2,5 +2,5 @@ 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 } from './events/states/State'; +export { default as State, PROPAGATE_EVENT } from './events/states/State'; export * from './utils'; From 52c74262bbf44b21fd0b9faee26eaf12455543af Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 Aug 2025 16:50:20 +0900 Subject: [PATCH 24/42] fix test --- src/tests/render/patchmap.test.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index bc01990a..bbd7afbc 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.set('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.set('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) { From 208439913a7ff022560af7713b6a8b5ec007478a Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 Aug 2025 18:09:48 +0900 Subject: [PATCH 25/42] docs --- README.md | 172 ++++++++++++++++++++++++++++++++++++++++++++------- README_KR.md | 160 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 289 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index cf8f1603..57ba1801 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,39 +402,161 @@ 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. -### `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. - - `'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. +When `patchmap.draw()` is executed, a `SelectionState` named `selection` is registered by default. ```js -patchmap.select({ - enabled: true, +// Activates the 'selection' state to use object selection and drag-selection features. +patchmap.stateManager.set('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); +}); +``` + +#### 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 'idle' state (the default state). + this.context.stateManager.set('idle'); + } + // 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.set('custom', { message: 'Hello World' }); +``` + +
+ +### `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.set('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 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. + + + +```js +patchmap.stateManager.set('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 ? [obj] : []; + } + }, + onDragSelect: (objs, event) => { + console.log('Drag Selected:', objs); + if (patchmap.transformer) { + patchmap.transformer.elements = 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 = []; ```
diff --git a/README_KR.md b/README_KR.md index 07122590..8820f57e 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,161 @@ 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.set('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') { + // 'idle' 상태(기본 상태)로 전환합니다. + this.context.stateManager.set('idle'); + } + // 이벤트를 스택의 다음 상태로 전파하려면 PROPAGATE_EVENT를 반환합니다. + return PROPAGATE_EVENT; + } +} + +// 2. StateManager에 등록 +patchmap.stateManager.register('custom', CustomState); + +// 3. 필요할 때 상태 전환 +patchmap.stateManager.set('custom', { message: 'Hello World' }); +``` + +
+ +### `SelectionState` +사용자의 선택 및 드래그 이벤트를 처리하는 기본 상태(State)입니다. `patchmap.draw()`가 실행되면 'selection'이라는 이름으로 `stateManager`에 자동으로 등록됩니다. `stateManager.set('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): 드래그를 통해 다수의 객체가 선택되었을 때 호출될 콜백 함수입니다. 선택된 객체 배열과 이벤트 객체를 인자로 받습니다. ```js -patchmap.select({ - enabled: true, +patchmap.stateManager.set('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 ? [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) 기능을 제공합니다. From dd8630ffdf7d24ad8e1a64214131d8f731e226f6 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 Aug 2025 18:12:40 +0900 Subject: [PATCH 26/42] fix --- src/transformer/Transformer.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js index 555f2dbb..274a5543 100644 --- a/src/transformer/Transformer.js +++ b/src/transformer/Transformer.js @@ -79,6 +79,7 @@ export default class Transformer extends Container { set wireframeStyle(value) { this._wireframeStyle = Object.assign(this._wireframeStyle, value); this.wireframe.setStrokeStyle(this.wireframeStyle); + this.update(); } destroy(options) { From 69708c66da7e99a0f2723c1b93e5a98f8c323389 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 Aug 2025 18:15:32 +0900 Subject: [PATCH 27/42] fix --- src/events/StateManager.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/events/StateManager.js b/src/events/StateManager.js index 19d08687..87ee8e3d 100644 --- a/src/events/StateManager.js +++ b/src/events/StateManager.js @@ -61,9 +61,8 @@ export default class StateManager { * Transitions to a new state, maintaining the modifier state. */ set(name, ...args) { - while (this.#stateStack.length > 0) { - this.popState(); - } + this.exitAll(); + this.#stateStack.length = 0; this.pushState(name, ...args); } @@ -244,5 +243,6 @@ export default class StateManager { this.#stateStack = []; this.#modifierState = null; this.#boundEvents.clear(); + this.#eventListeners = {}; } } From 4bd54a4645c98807b71cf19a8d7285d4f37d2a45 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 Aug 2025 18:20:38 +0900 Subject: [PATCH 28/42] add selectionState onpointerover event --- src/events/states/SelectionState.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/events/states/SelectionState.js b/src/events/states/SelectionState.js index 77830085..862f2200 100644 --- a/src/events/states/SelectionState.js +++ b/src/events/states/SelectionState.js @@ -4,7 +4,12 @@ import { isMoved } from '../utils'; import State from './State'; export default class SelectionState extends State { - static handledEvents = ['onpointerdown', 'onpointermove', 'onpointerup']; + static handledEvents = [ + 'onpointerdown', + 'onpointermove', + 'onpointerup', + 'onpointerover', + ]; isPointerdown = false; dragStartPoint = null; @@ -70,6 +75,10 @@ export default class SelectionState extends State { this.#clear(); } + onpointerover(e) { + this.hover(e); + } + #drawSelectionBox(p1, p2) { if (!p1 || !p2) return; @@ -106,6 +115,11 @@ export default class SelectionState extends State { this.config.onDragSelect(selected, e); } + 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); } From a04137c16ed9f5b145d2fc06dd8f30c1df33f072 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 Aug 2025 18:31:02 +0900 Subject: [PATCH 29/42] fix mouse-edges --- src/events/states/SelectionState.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/events/states/SelectionState.js b/src/events/states/SelectionState.js index 862f2200..fa423100 100644 --- a/src/events/states/SelectionState.js +++ b/src/events/states/SelectionState.js @@ -58,7 +58,11 @@ export default class SelectionState extends State { this.config.draggable && isMoved(this.dragStartPoint, currentPoint, this.viewport.scale) ) { - this.isDragging = true; + if (!this.isDragging) { + this.isDragging = true; + this.viewport.plugin.start('mouse-edges'); + } + this.#drawSelectionBox(this.dragStartPoint, currentPoint); this.dragSelect(e); } @@ -69,6 +73,7 @@ export default class SelectionState extends State { if (this.isDragging) { this.dragSelect(e); + this.viewport.plugin.stop('mouse-edges'); } else { this.select(e); } From 10345354ce3283ea761b4478466ebb3086c982cf Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 Aug 2025 19:06:43 +0900 Subject: [PATCH 30/42] fix SelectionState --- README.md | 23 +++---- README_KR.md | 3 + src/events/StateManager.js | 6 +- src/events/states/SelectionState.js | 99 +++++++++++++++++++++-------- 4 files changed, 91 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 57ba1801..d9bc9a1c 100644 --- a/README.md +++ b/README.md @@ -479,17 +479,18 @@ patchmap.stateManager.set('custom', { message: 'Hello World' }); 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.set('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 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. - - +- `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 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.stateManager.set('selection', { diff --git a/README_KR.md b/README_KR.md index 8820f57e..04f5102d 100644 --- a/README_KR.md +++ b/README_KR.md @@ -489,6 +489,9 @@ patchmap.stateManager.set('custom', { message: 'Hello World' }); - `filter` (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.stateManager.set('selection', { diff --git a/src/events/StateManager.js b/src/events/StateManager.js index 87ee8e3d..d6831bac 100644 --- a/src/events/StateManager.js +++ b/src/events/StateManager.js @@ -61,9 +61,13 @@ export default class StateManager { * Transitions to a new state, maintaining the modifier state. */ set(name, ...args) { + this.reset(); + this.pushState(name, ...args); + } + + reset() { this.exitAll(); this.#stateStack.length = 0; - this.pushState(name, ...args); } /** diff --git a/src/events/states/SelectionState.js b/src/events/states/SelectionState.js index fa423100..f1e9c257 100644 --- a/src/events/states/SelectionState.js +++ b/src/events/states/SelectionState.js @@ -1,8 +1,28 @@ -import { Graphics } from 'pixi.js'; +import { Graphics, Point } 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', @@ -11,28 +31,43 @@ export default class SelectionState extends State { 'onpointerover', ]; - isPointerdown = false; - dragStartPoint = null; - isDragging = false; + /** @type {SelectionStateConfig} */ + config = {}; + interactionState = InteractionState.IDLE; + dragStartPoint = new Point(); _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); - this.config = { + const defaultConfig = { draggable: false, filter: () => true, selectUnit: 'entity', onOver: () => {}, onSelect: () => {}, onDragSelect: () => {}, - ...config, + 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() { @@ -45,52 +80,55 @@ export default class SelectionState extends State { } onpointerdown(e) { - this.isPointerdown = true; - this.dragStartPoint = this.viewport.toWorld(e.global); + this.interactionState = InteractionState.PRESSING; + this.dragStartPoint.copyFrom(this.viewport.toWorld(e.global)); this.select(e); } onpointermove(e) { - if (!this.isPointerdown) return; + if (this.interactionState === InteractionState.IDLE) return; const currentPoint = this.viewport.toWorld(e.global); if ( - this.config.draggable && + this.interactionState === InteractionState.PRESSING && isMoved(this.dragStartPoint, currentPoint, this.viewport.scale) ) { - if (!this.isDragging) { - this.isDragging = true; - this.viewport.plugin.start('mouse-edges'); - } + 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.isPointerdown) return; - - if (this.isDragging) { + if (this.interactionState === InteractionState.PRESSING) { + this.select(e); + } else if (this.interactionState === InteractionState.DRAGGING) { this.dragSelect(e); this.viewport.plugin.stop('mouse-edges'); - } else { - this.select(e); } 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; - if (!this._selectionBox.parent) { - this.viewport.addChild(this._selectionBox); - } - + const { fill, stroke } = this.config.selectionBoxStyle; this._selectionBox.clear(); this._selectionBox .rect( @@ -99,27 +137,32 @@ export default class SelectionState extends State { Math.abs(p1.x - p2.x), Math.abs(p1.y - p2.y), ) - .fill({ color: '#9FD6FF', alpha: 0.2 }) - .stroke({ width: 2, color: '#1099FF', pixelLine: true }); + .fill(fill) + .stroke({ ...stroke, pixelLine: true }); } + /** + * Resets the internal state of the selection handler. + * @private + */ #clear() { - this.isPointerdown = false; - this.dragStartPoint = null; - this.isDragging = false; + this.interactionState = InteractionState.IDLE; this._selectionBox.clear(); } + /** Finalizes a single object selection. */ select(e) { const selected = this.findPoint(this.dragStartPoint); 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); From c03fa450478bf01a18103dc2b869445179ac827b Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 Aug 2025 19:07:23 +0900 Subject: [PATCH 31/42] chore --- src/events/states/SelectionState.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/events/states/SelectionState.js b/src/events/states/SelectionState.js index f1e9c257..ffcf8bd8 100644 --- a/src/events/states/SelectionState.js +++ b/src/events/states/SelectionState.js @@ -5,9 +5,9 @@ import { isMoved } from '../utils'; import State from './State'; const InteractionState = { - IDLE: 'idle', // 아무것도 안 하는 상태 - PRESSING: 'pressing', // 누르고 있지만 아직 움직이지 않은 상태 - DRAGGING: 'dragging', // 누른 채로 움직이는 상태 + IDLE: 'idle', + PRESSING: 'pressing', + DRAGGING: 'dragging', }; /** From 3c63151bf5f25c2b56c7669809335642a747703c Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 Aug 2025 19:17:49 +0900 Subject: [PATCH 32/42] fix --- src/events/states/SelectionState.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/events/states/SelectionState.js b/src/events/states/SelectionState.js index ffcf8bd8..3f7d8462 100644 --- a/src/events/states/SelectionState.js +++ b/src/events/states/SelectionState.js @@ -74,6 +74,10 @@ export default class SelectionState extends State { this._selectionBox.clear(); } + resume() { + this.#clear(); + } + destroy() { this._selectionBox.destroy(true); super.destroy(); From e0da6b83d9e25cd360d8e20da79f612b73d75491 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 18 Aug 2025 16:07:08 +0900 Subject: [PATCH 33/42] rename --- README.md | 10 +++++----- README_KR.md | 10 +++++----- src/events/StateManager.js | 6 +++--- src/tests/render/patchmap.test.js | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d9bc9a1c..450c633a 100644 --- a/README.md +++ b/README.md @@ -409,7 +409,7 @@ When `patchmap.draw()` is executed, a `SelectionState` named `selection` is regi ```js // Activates the 'selection' state to use object selection and drag-selection features. -patchmap.stateManager.set('selection', { +patchmap.stateManager.setState('selection', { draggable: true, selectUnit: 'grid', filter: (obj) => obj.type !== 'relations', @@ -459,7 +459,7 @@ class CustomState extends State { onkeydown(event) { if (event.key === 'Escape') { // Switch to the 'idle' state (the default state). - this.context.stateManager.set('idle'); + this.context.stateManager.setState('idle'); } // Return PROPAGATE_EVENT to propagate the event to the next state in the stack. return PROPAGATE_EVENT; @@ -470,14 +470,14 @@ class CustomState extends State { patchmap.stateManager.register('custom', CustomState); // 3. Switch states when needed -patchmap.stateManager.set('custom', { message: 'Hello World' }); +patchmap.stateManager.setState('custom', { message: 'Hello World' }); ```
### `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.set('selection', options)`. +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'`. @@ -493,7 +493,7 @@ The default state that handles user selection and drag events. It is automatical - `stroke` (object): The stroke style. Default: `{ width: 2, color: '#1099FF' }`. ```js -patchmap.stateManager.set('selection', { +patchmap.stateManager.setState('selection', { draggable: true, selectUnit: 'grid', filter: (obj) => obj.type !== 'relations', diff --git a/README_KR.md b/README_KR.md index 04f5102d..97bbd88a 100644 --- a/README_KR.md +++ b/README_KR.md @@ -411,7 +411,7 @@ const result = patchmap.selector('$..[?(@.label=="group-label-1")]') ```js // selection 상태를 활성화하여 객체 선택 및 드래그 선택 기능을 사용합니다. -patchmap.stateManager.set('selection', { +patchmap.stateManager.setState('selection', { draggable: true, selectUnit: 'grid', filter: (obj) => obj.type !== 'relations', @@ -461,7 +461,7 @@ class CustomState extends State { onkeydown(event) { if (event.key === 'Escape') { // 'idle' 상태(기본 상태)로 전환합니다. - this.context.stateManager.set('idle'); + this.context.stateManager.setState('idle'); } // 이벤트를 스택의 다음 상태로 전파하려면 PROPAGATE_EVENT를 반환합니다. return PROPAGATE_EVENT; @@ -472,13 +472,13 @@ class CustomState extends State { patchmap.stateManager.register('custom', CustomState); // 3. 필요할 때 상태 전환 -patchmap.stateManager.set('custom', { message: 'Hello World' }); +patchmap.stateManager.setState('custom', { message: 'Hello World' }); ```
### `SelectionState` -사용자의 선택 및 드래그 이벤트를 처리하는 기본 상태(State)입니다. `patchmap.draw()`가 실행되면 'selection'이라는 이름으로 `stateManager`에 자동으로 등록됩니다. `stateManager.set('selection', options)`를 호출하여 활성화하고 설정을 전달할 수 있습니다. +사용자의 선택 및 드래그 이벤트를 처리하는 기본 상태(State)입니다. `patchmap.draw()`가 실행되면 'selection'이라는 이름으로 `stateManager`에 자동으로 등록됩니다. `stateManager.setState('selection', options)`를 호출하여 활성화하고 설정을 전달할 수 있습니다. - `draggable` (optional, boolean): 드래그를 통한 다중 선택 활성화 여부를 결정합니다. - `selectUnit` (optional, string): 선택 시 반환될 논리적 단위를 지정합니다. 기본값은 `'entity'` 입니다. @@ -494,7 +494,7 @@ patchmap.stateManager.set('custom', { message: 'Hello World' }); - `stroke` (object): 테두리 스타일. 기본값: `{ width: 2, color: '#1099FF' }`. ```js -patchmap.stateManager.set('selection', { +patchmap.stateManager.setState('selection', { draggable: true, selectUnit: 'grid', filter: (obj) => obj.type !== 'relations', diff --git a/src/events/StateManager.js b/src/events/StateManager.js index d6831bac..2ea08894 100644 --- a/src/events/StateManager.js +++ b/src/events/StateManager.js @@ -60,12 +60,12 @@ export default class StateManager { /** * Transitions to a new state, maintaining the modifier state. */ - set(name, ...args) { - this.reset(); + setState(name, ...args) { + this.resetState(); this.pushState(name, ...args); } - reset() { + resetState() { this.exitAll(); this.#stateStack.length = 0; } diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index bbd7afbc..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.stateManager.set('selection', { + patchmap.stateManager.setState('selection', { enabled: true, draggable: false, selectUnit: 'grid', @@ -410,7 +410,7 @@ describe('patchmap test', () => { const onSelect = vi.fn(); - patchmap.stateManager.set('selection', { + patchmap.stateManager.setState('selection', { enabled: true, selectUnit: selectUnit, onSelect: onSelect, From 02dc0ce6bb9986b709c95be2a56a6cafb23ef66a Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 18 Aug 2025 16:14:46 +0900 Subject: [PATCH 34/42] add Transformer jsdoc --- src/transformer/Transformer.js | 110 ++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 3 deletions(-) diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js index 274a5543..78d5958f 100644 --- a/src/transformer/Transformer.js +++ b/src/transformer/Transformer.js @@ -11,22 +11,83 @@ const DEFAULT_WIREFRAME_STYLE = { 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(), + 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' }); @@ -34,7 +95,7 @@ export default class Transformer extends Container { if (isValidationError(options)) throw options; this.#wireframe = this.addChild(new Wireframe({ label: 'wireframe' })); - this.onRender = this._refresh.bind(this); + this.onRender = this.#refresh.bind(this); for (const key in options) { if (key === 'wireframeStyle') { this[key] = Object.assign(this[key], options[key]); @@ -51,37 +112,68 @@ export default class Transformer extends Container { }); } + /** + * 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 = 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) { @@ -90,12 +182,20 @@ export default class Transformer extends Container { super.destroy(options); } - _refresh() { + /** + * 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; @@ -130,6 +230,10 @@ export default class Transformer extends Container { 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; }; From 8d0bcc043a650c6c230c5c6142a8b9f17ed44fb0 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 18 Aug 2025 16:32:15 +0900 Subject: [PATCH 35/42] fix --- src/events/states/SelectionState.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/events/states/SelectionState.js b/src/events/states/SelectionState.js index 3f7d8462..5f97c561 100644 --- a/src/events/states/SelectionState.js +++ b/src/events/states/SelectionState.js @@ -1,4 +1,4 @@ -import { Graphics, Point } from 'pixi.js'; +import { Graphics } from 'pixi.js'; import { deepMerge } from '../../utils/deepmerge/deepmerge'; import { findIntersectObject, findIntersectObjects } from '../find'; import { isMoved } from '../utils'; @@ -34,7 +34,7 @@ export default class SelectionState extends State { /** @type {SelectionStateConfig} */ config = {}; interactionState = InteractionState.IDLE; - dragStartPoint = new Point(); + dragStartPoint = null; _selectionBox = new Graphics(); /** @@ -71,13 +71,10 @@ export default class SelectionState extends State { } pause() { + this.dragStartPoint = null; this._selectionBox.clear(); } - resume() { - this.#clear(); - } - destroy() { this._selectionBox.destroy(true); super.destroy(); @@ -85,7 +82,7 @@ export default class SelectionState extends State { onpointerdown(e) { this.interactionState = InteractionState.PRESSING; - this.dragStartPoint.copyFrom(this.viewport.toWorld(e.global)); + this.dragStartPoint = this.viewport.toWorld(e.global); this.select(e); } @@ -152,11 +149,12 @@ export default class SelectionState extends State { #clear() { this.interactionState = InteractionState.IDLE; this._selectionBox.clear(); + this.dragStartPoint = null; } /** Finalizes a single object selection. */ select(e) { - const selected = this.findPoint(this.dragStartPoint); + const selected = this.findPoint(this.viewport.toWorld(e.global)); this.config.onSelect(selected, e); } From 5801536ac1c0c27e42f0977c1ea2751edb6c91d8 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 18 Aug 2025 16:35:30 +0900 Subject: [PATCH 36/42] fix --- src/transformer/Transformer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js index 78d5958f..f2ba5f12 100644 --- a/src/transformer/Transformer.js +++ b/src/transformer/Transformer.js @@ -148,7 +148,7 @@ export default class Transformer extends Container { * @param {PIXI.DisplayObject | PIXI.DisplayObject[]} value */ set elements(value) { - this._elements = Array.isArray(value) ? value : [value]; + this._elements = value ? (Array.isArray(value) ? value : [value]) : []; this.update(); } From 480dc44b855d6ac82c141a2a26ac9f3b62e6efa5 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 18 Aug 2025 16:37:02 +0900 Subject: [PATCH 37/42] add Wireframe jsdoc --- src/transformer/Wireframe.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/transformer/Wireframe.js b/src/transformer/Wireframe.js index 5f91df6b..742af3a2 100644 --- a/src/transformer/Wireframe.js +++ b/src/transformer/Wireframe.js @@ -1,8 +1,27 @@ 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) => { From 8535fd3d5f3e4a1b4d56be7ee046fc1a85d9ce01 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 18 Aug 2025 16:40:52 +0900 Subject: [PATCH 38/42] chore --- README.md | 4 ++-- README_KR.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 450c633a..5859f735 100644 --- a/README.md +++ b/README.md @@ -458,8 +458,8 @@ class CustomState extends State { onkeydown(event) { if (event.key === 'Escape') { - // Switch to the 'idle' state (the default state). - this.context.stateManager.setState('idle'); + // 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; diff --git a/README_KR.md b/README_KR.md index 97bbd88a..6c97c265 100644 --- a/README_KR.md +++ b/README_KR.md @@ -460,8 +460,8 @@ class CustomState extends State { onkeydown(event) { if (event.key === 'Escape') { - // 'idle' 상태(기본 상태)로 전환합니다. - this.context.stateManager.setState('idle'); + // 'selection' 상태(기본 상태)로 전환합니다. + this.context.stateManager.setState('selection'); } // 이벤트를 스택의 다음 상태로 전파하려면 PROPAGATE_EVENT를 반환합니다. return PROPAGATE_EVENT; From a06e9b8794e5dc9805ab27273d6159341883a3c2 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 18 Aug 2025 16:45:25 +0900 Subject: [PATCH 39/42] add State jsdoc --- src/events/states/State.js | 61 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/events/states/State.js b/src/events/states/State.js index 75757a99..d10e2e67 100644 --- a/src/events/states/State.js +++ b/src/events/states/State.js @@ -1,26 +1,87 @@ +/** + * 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(); } From 497f0b596c8615aed383b3832ed118cb33e2363b Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 18 Aug 2025 16:48:36 +0900 Subject: [PATCH 40/42] add StateManager jsdoc --- src/events/StateManager.js | 66 ++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/src/events/StateManager.js b/src/events/StateManager.js index 2ea08894..9863542f 100644 --- a/src/events/StateManager.js +++ b/src/events/StateManager.js @@ -2,39 +2,52 @@ 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. + * @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. - * @returns {(object | null)} The current modifier state or null if none is active. + * 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 singleton instance. + * 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 {(object | Function)} StateClassOrObject - The state class or singleton instance. + * @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) { @@ -58,20 +71,27 @@ export default class StateManager { } /** - * Transitions to a new state, maintaining the modifier state. + * 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. + * 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(); @@ -97,9 +117,9 @@ export default class StateManager { } /** - * Pops the top state from the stack and returns to the previous state. - * @param {*} payload - Payload to pass to the previous state's resume method. - * @returns {(object | null)} The popped state or null if the stack is empty. + * 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; @@ -112,6 +132,11 @@ export default class StateManager { 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?.(); @@ -119,8 +144,8 @@ export default class StateManager { } /** - * Gets the current active state. - * @returns {(object | null)} The current active state or null if none is active. + * 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 @@ -129,7 +154,11 @@ export default class StateManager { } /** - * Activates a modifier state. + * 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); @@ -173,7 +202,7 @@ export default class StateManager { } /** - * Deactivates the current modifier state. + * Deactivates the current modifier state, restoring event handling to the main state stack. */ deactivateModifier() { this.#modifierState?.exit?.(); @@ -181,9 +210,11 @@ export default class StateManager { } /** - * Ensures event listeners are registered for necessary events. + * 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 {Array} eventNames - The names of the events to ensure listeners for. + * @param {string[]} [eventNames=[]] - The names of the events to ensure listeners for (e.g., 'onpointerdown'). */ _ensureEventListeners(eventNames = []) { const viewport = this.#context.viewport; @@ -223,7 +254,8 @@ export default class StateManager { } /** - * Destroys the StateManager, releasing all resources. + * Destroys the StateManager, cleaning up all event listeners, + * destroying state instances, and clearing all internal references. */ destroy() { for (const eventName of this.#boundEvents) { From 670cb0659218547aba114e62562ab5a0a10ca307 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 18 Aug 2025 16:50:22 +0900 Subject: [PATCH 41/42] chore --- README.md | 4 ++-- README_KR.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5859f735..ad0e40ba 100644 --- a/README.md +++ b/README.md @@ -501,7 +501,7 @@ patchmap.stateManager.setState('selection', { console.log('Selected:', obj); // Assign the selected object to the transformer if (patchmap.transformer) { - patchmap.transformer.elements = obj ? [obj] : []; + patchmap.transformer.elements = obj; } }, onDragSelect: (objs, event) => { @@ -523,7 +523,7 @@ A visual tool for displaying an outline around selected elements and performing 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. + - `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'`). diff --git a/README_KR.md b/README_KR.md index 6c97c265..a784830a 100644 --- a/README_KR.md +++ b/README_KR.md @@ -502,7 +502,7 @@ patchmap.stateManager.setState('selection', { console.log('Selected:', obj); // 선택된 객체를 transformer에 할당 if (patchmap.transformer) { - patchmap.transformer.elements = obj ? [obj] : []; + patchmap.transformer.elements = obj; } }, onDragSelect: (objs, event) => { @@ -524,7 +524,7 @@ patchmap.stateManager.setState('selection', { `Transformer` 인스턴스를 생성할 때 다음과 같은 옵션을 전달하여 동작을 제어할 수 있습니다. - - `elements` (optional, Array\): 초기에 외곽선을 표시할 요소들의 배열입니다. + - `elements` (optional, Array): 초기에 외곽선을 표시할 요소들의 배열입니다. - `wireframeStyle` (optional, object): 외곽선의 스타일을 지정합니다. - `thickness` (number): 선의 두께 (기본값: `1.5`). - `color` (string): 선의 색상 (기본값: `'#1099FF'`). From 383adf26b49e404972feb469c0d54a3bf614c747 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 18 Aug 2025 17:56:30 +0900 Subject: [PATCH 42/42] add Transformer test --- src/tests/Transformer.test.js | 193 +++++++++++++++++++++++++++++++++ src/transformer/Transformer.js | 2 +- 2 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 src/tests/Transformer.test.js 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/transformer/Transformer.js b/src/transformer/Transformer.js index f2ba5f12..61ec63ab 100644 --- a/src/transformer/Transformer.js +++ b/src/transformer/Transformer.js @@ -88,7 +88,7 @@ export default class Transformer extends Container { /** * @param {TransformerOptions} [opts] - The options for the transformer. */ - constructor(opts) { + constructor(opts = {}) { super({ zIndex: 999, isRenderGroup: true, id: 'transformer' }); const options = validate(opts, TransformerSchema);