From 2db2651ee0c0e433511d5d5927e0207967f63597 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 18 Dec 2025 17:16:36 +0900 Subject: [PATCH 1/2] feat: add drill-down and deep select support to selection state --- src/events/find.js | 30 ++-- src/events/states/SelectionState.js | 244 ++++++++++++++++------------ src/events/utils.js | 63 +++---- 3 files changed, 195 insertions(+), 142 deletions(-) diff --git a/src/events/find.js b/src/events/find.js index 327b54a7..ea10ead7 100644 --- a/src/events/find.js +++ b/src/events/find.js @@ -4,12 +4,12 @@ import { intersectPoint } from '../utils/intersects/intersect-point'; import { getSelectObject } from './utils'; export const findIntersectObject = ( - viewport, + parent, point, - { filter, selectUnit } = {}, + { filter, selectUnit, filterParent } = {}, ) => { const allCandidates = collectCandidates( - viewport, + parent, (child) => child.constructor.isSelectable, ); @@ -17,8 +17,8 @@ export const findIntersectObject = ( const zDiff = (b.zIndex || 0) - (a.zIndex || 0); if (zDiff !== 0) return zDiff; - const pathA = getAncestorPath(a, viewport); - const pathB = getAncestorPath(b, viewport); + const pathA = getAncestorPath(a, parent); + const pathB = getAncestorPath(b, parent); const minLength = Math.min(pathA.length, pathB.length); for (let i = 0; i < minLength; i++) { @@ -42,7 +42,12 @@ export const findIntersectObject = ( for (const target of targets) { const isIntersecting = intersectPoint(target, point); if (isIntersecting) { - const selectObject = getSelectObject(candidate, selectUnit); + const selectObject = getSelectObject( + parent, + candidate, + selectUnit, + filterParent, + ); if (selectObject && (!filter || filter(selectObject))) { return selectObject; } @@ -54,12 +59,12 @@ export const findIntersectObject = ( }; export const findIntersectObjects = ( - viewport, + parent, selectionBox, - { filter, selectUnit } = {}, + { filter, selectUnit, filterParent } = {}, ) => { const allCandidates = collectCandidates( - viewport, + parent, (child) => child.constructor.isSelectable, ); const found = []; @@ -73,7 +78,12 @@ export const findIntersectObjects = ( for (const target of targets) { const isIntersecting = intersect(selectionBox, target); if (isIntersecting) { - const selectObject = getSelectObject(candidate, selectUnit); + const selectObject = getSelectObject( + parent, + candidate, + selectUnit, + filterParent, + ); if (selectObject && (!filter || filter(selectObject))) { found.push(selectObject); break; diff --git a/src/events/states/SelectionState.js b/src/events/states/SelectionState.js index f09838b2..eddc955b 100644 --- a/src/events/states/SelectionState.js +++ b/src/events/states/SelectionState.js @@ -4,10 +4,10 @@ import { findIntersectObject, findIntersectObjects } from '../find'; import { isMoved } from '../utils'; import State from './State'; -const InteractionState = { - IDLE: 'idle', - PRESSING: 'pressing', - DRAGGING: 'dragging', +const stateSymbol = { + IDLE: Symbol('IDLE'), + PRESSING: Symbol('PRESSING'), + DRAGGING: Symbol('DRAGGING'), }; /** @@ -15,6 +15,8 @@ const InteractionState = { * @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 {boolean} [drillDown=false] - Enables drill-down selection on double click. + * @property {boolean} [deepSelect=false] - Enables deep selection (force 'entity') when holding Ctrl/Meta key. * @property {object} [selectionBoxStyle] - Style options for the drag selection box. * @property {object} [selectionBoxStyle.fill] - Fill style. * @property {string | number} [selectionBoxStyle.fill.color='#9FD6FF'] - Fill color. @@ -50,6 +52,26 @@ const InteractionState = { * Callback fired on `pointerover` when the pointer enters a new object (and not dragging). */ +const defaultConfig = { + draggable: false, + filter: () => true, + selectUnit: 'entity', + drillDown: false, + deepSelect: false, + onDown: () => {}, + onUp: () => {}, + onClick: () => {}, + onDoubleClick: () => {}, + onDragStart: () => {}, + onDrag: () => {}, + onDragEnd: () => {}, + onOver: () => {}, + selectionBoxStyle: { + fill: { color: '#9FD6FF', alpha: 0.2 }, + stroke: { width: 2, color: '#1099FF' }, + }, +}; + export default class SelectionState extends State { static handledEvents = [ 'onpointerdown', @@ -61,7 +83,7 @@ export default class SelectionState extends State { /** @type {SelectionStateConfig} */ config = {}; - interactionState = InteractionState.IDLE; + interactionState = stateSymbol.IDLE; dragStartPoint = null; movedViewport = false; _selectionBox = new Graphics(); @@ -71,127 +93,170 @@ export default class SelectionState extends State { * @param {object} context - The application context, containing the viewport. * @param {SelectionStateConfig} config - Configuration for the selection behavior. */ - enter(context, config) { + enter(context, config = {}) { super.enter(context); - const defaultConfig = { - draggable: false, - filter: () => true, - selectUnit: 'entity', - onDown: () => {}, - onUp: () => {}, - onClick: () => {}, - onDoubleClick: () => {}, - onDragStart: () => {}, - onDrag: () => {}, - onDragEnd: () => {}, - onOver: () => {}, - selectionBoxStyle: { - fill: { color: '#9FD6FF', alpha: 0.2 }, - stroke: { width: 2, color: '#1099FF' }, - }, - }; - this.config = deepMerge(defaultConfig, config || {}); - + 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); - } + this.#clear({ state: true, selectionBox: true, gesture: true }); + this._selectionBox?.destroy(true); } pause() { - this.#clearSelectionBox(); - } - - destroy() { - this._selectionBox.destroy(true); - super.destroy(); + this.#clear({ selectionBox: true }); } onpointerdown(e) { - this.#clearGesture(); - this.interactionState = InteractionState.PRESSING; + this.#clear({ gesture: true }); + this.interactionState = stateSymbol.PRESSING; this.dragStartPoint = this.viewport.toWorld(e.global); - const target = this.findPoint(this.dragStartPoint); + const target = this.#searchObject(this.dragStartPoint, e); this.config.onDown(target, e); } onpointermove(e) { + if (this.interactionState === stateSymbol.IDLE || !this.config.draggable) { + return; + } + if ( - this.interactionState === InteractionState.PRESSING && + this.interactionState === stateSymbol.PRESSING && this.viewport.moving ) { this.movedViewport = true; } - if ( - this.interactionState === InteractionState.IDLE || - !this.config.draggable - ) { - return; - } const currentPoint = this.viewport.toWorld(e.global); - if ( - this.interactionState === InteractionState.PRESSING && + this.interactionState === stateSymbol.PRESSING && isMoved(this.dragStartPoint, currentPoint, this.viewport.scale) ) { - this.interactionState = InteractionState.DRAGGING; + this.interactionState = stateSymbol.DRAGGING; this.viewport.plugin.start('mouse-edges'); this.config.onDragStart(e); } - if (this.interactionState === InteractionState.DRAGGING) { + if (this.interactionState === stateSymbol.DRAGGING) { this.#drawSelectionBox(this.dragStartPoint, currentPoint); - const selected = this.findPolygon(this._selectionBox); + const selected = this.#searchObjects(this._selectionBox); this.config.onDrag(selected, e); } } onpointerup(e) { - if (this.interactionState === InteractionState.PRESSING) { - const target = this.findPoint(this.viewport.toWorld(e.global)); + if (this.interactionState === stateSymbol.PRESSING) { + const target = this.#searchObject(this.viewport.toWorld(e.global), e); this.config.onUp(target, e); - } else if (this.interactionState === InteractionState.DRAGGING) { - const selected = this.findPolygon(this._selectionBox); + } else if (this.interactionState === stateSymbol.DRAGGING) { + const selected = this.#searchObjects(this._selectionBox); this.config.onDragEnd(selected, e); this.viewport.plugin.stop('mouse-edges'); } - this.#clearSelectionBox(); - this.#clearInteractionState(); + this.#clear({ state: true, selectionBox: true, gesture: true }); } onpointerover(e) { - if (this.interactionState !== InteractionState.IDLE) return; - const selected = this.findPoint(this.viewport.toWorld(e.global)); - this.config.onOver(selected, e); + if (this.interactionState !== stateSymbol.IDLE) return; + const target = this.#searchObject(this.viewport.toWorld(e.global), e); + this.config.onOver(target, e); } onclick(e) { if (this.movedViewport) { - this.#clearGesture(); + this.#clear({ gesture: true }); return; } const currentPoint = this.viewport.toWorld(e.global); if (isMoved(this.dragStartPoint, currentPoint, this.viewport.scale)) { - this.#clearGesture(); + this.#clear({ gesture: true }); return; } - const target = this.findPoint(currentPoint); + let target = this.#searchObject(currentPoint, e); + if (this.config.drillDown && e.detail >= 2) { + for (let i = 1; i < e.detail; i++) { + if (!target) break; + const deeperTarget = findIntersectObject( + target, + currentPoint, + this.config, + ); + if (!deeperTarget) break; + target = deeperTarget; + } + } + if (e.detail === 2) { this.config.onDoubleClick(target, e); } else { this.config.onClick(target, e); } - this.#clearGesture(); + this.#clear({ gesture: true }); + } + + #searchObject(point, e) { + if (this.config.deepSelect && (e.ctrlKey || e.metaKey)) { + return this.#findByPoint(point, { ...this.config, selectUnit: 'grid' }); + } + + return this.#findByPoint(point, { + ...this.config, + filterParent: this.#getSelectionAncestors(), + }); + } + + #searchObjects(polygon) { + return this.#findByPolygon(polygon, { + ...this.config, + filterParent: this.#getSelectionAncestors(), + }); + } + + #findByPoint(point, config = this.config) { + const object = findIntersectObject(this.viewport, point, config); + if (!object || object.type !== 'wireframe') { + return object; + } + + const underObject = findIntersectObject(this.viewport, point, { + ...config, + filter: (obj) => this.config.filter(obj) && obj.type !== 'wireframe', + }); + if (!underObject || underObject.type === 'canvas') { + return object; + } + return underObject; + } + + #findByPolygon(polygon, config = this.config) { + return findIntersectObjects(this.viewport, polygon, { + ...config, + filter: (obj) => this.config.filter(obj) && obj.type !== 'wireframe', + }); + } + + /** + * Retrieves the ancestors of selected elements. + * @private + * @returns {Set} A set of ancestors of selected elements. + */ + #getSelectionAncestors() { + const selectionAncestors = new Set(); + for (const element of this.context.transformer.elements) { + let current = element.parent; + while (current) { + if (current.type === 'canvas') break; + selectionAncestors.add(current); + current = current.parent; + } + } + return selectionAncestors; } /** @@ -217,48 +282,23 @@ export default class SelectionState extends State { } /** - * Clears the selection box if it exists and is not destroyed. + * Clears the selection state and optional components. * @private + * @param {object} options - Options to control what to clear. + * @param {boolean} [options.state=false] - Clear the interaction state. + * @param {boolean} [options.selectionBox=false] - Clear the selection box. + * @param {boolean} [options.gesture=false] - Clear gesture-related data. */ - #clearSelectionBox() { - if (!this._selectionBox.destroyed) { + #clear({ state = false, selectionBox = false, gesture = false }) { + if (state) { + this.interactionState = stateSymbol.IDLE; + } + if (selectionBox && !this._selectionBox.destroyed) { this._selectionBox.clear(); } - } - - /** - * Clears the current interaction state and resets to IDLE. - * @private - */ - #clearInteractionState() { - this.interactionState = InteractionState.IDLE; - } - - /** - * Resets gesture-related properties, clearing drag start point and moved viewport state. - * @private - */ - #clearGesture() { - this.dragStartPoint = null; - this.movedViewport = false; - } - - /** - * Resets the interaction and gesture states to their initial values. - * Calls {@link #clearInteractionState} and {@link #clearGesture}. - * @private - */ - #clear() { - this.#clearInteractionState(); - this.#clearSelectionBox(); - this.#clearGesture(); - } - - findPoint(point) { - return findIntersectObject(this.viewport, point, this.config); - } - - findPolygon(polygon) { - return findIntersectObjects(this.viewport, polygon, this.config); + if (gesture) { + this.dragStartPoint = null; + this.movedViewport = false; + } } } diff --git a/src/events/utils.js b/src/events/utils.js index 467d06f3..15409238 100644 --- a/src/events/utils.js +++ b/src/events/utils.js @@ -4,33 +4,30 @@ export const checkEvents = (viewport, eventId) => { return eventId.split(' ').every((id) => event.getEvent(viewport, id)); }; -export const getSelectObject = (obj, selectUnit) => { - if (!obj || !obj.constructor.isSelectable) { +export const getSelectObject = ( + parent, + obj, + selectUnit, + filterParent = new Set(), +) => { + if (!obj?.constructor?.isSelectable) { return null; } - switch (selectUnit) { - case 'entity': - return obj; + const strategies = { + entity: () => obj, + closestGroup: () => + findClosestParent(parent, obj, 'group', filterParent) || + findClosestParent(parent, obj, 'grid', filterParent), + highestGroup: () => + findHighestParent(parent, obj, 'group', filterParent) || + findClosestParent(parent, obj, 'grid', filterParent), + grid: () => findClosestParent(parent, obj, 'grid', filterParent), + }; - case 'closestGroup': { - const closestGroup = findClosestParent(obj, 'group'); - return closestGroup || obj; - } - - case 'highestGroup': { - const highestGroup = findHighestParent(obj, 'group'); - return highestGroup || obj; - } - - case 'grid': { - const parentGrid = findClosestParent(obj, 'grid'); - return parentGrid || obj; - } - - default: - return obj; - } + const finder = strategies[selectUnit]; + const target = finder ? finder() : obj; + return target || obj; }; const MOVE_DELTA = 4; @@ -44,22 +41,28 @@ export const isMoved = (point1, point2, scale = { x: 1, y: 1 }) => { ); }; -const findClosestParent = (obj, type) => { +const findClosestParent = (parent, obj, type, filterParent) => { let current = obj; - while (current && current.type !== 'canvas') { - if (current.type === type) { + while (current && current.type !== 'canvas' && current !== parent) { + if ( + current.type === type && + (!filterParent || !filterParent.has(current)) + ) { return current; } current = current.parent; } - return null; // 해당하는 부모가 없음 + return null; }; -const findHighestParent = (obj, type) => { +const findHighestParent = (parent, obj, type, filterParent) => { let topParent = null; let current = obj; - while (current && current.type !== 'canvas') { - if (current.type === type) { + while (current && current.type !== 'canvas' && current !== parent) { + if ( + current.type === type && + (!filterParent || !filterParent.has(current)) + ) { topParent = current; } current = current.parent; From 3c490d723939e86af2b3d5a8186d92da5e54e036 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 18 Dec 2025 17:22:02 +0900 Subject: [PATCH 2/2] fix docs --- README.md | 2 ++ README_KR.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index 7168ac68..0d3c9ffd 100644 --- a/README.md +++ b/README.md @@ -487,6 +487,8 @@ The default state that handles user selection and drag events. It is automatical - `'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. +- `drillDown` (optional, boolean): Enables step-by-step navigation into nested groups through double-clicks (or consecutive clicks). When a parent group is already selected, subsequent clicks will search for and select deeper child objects at that specific location. +- `deepSelect` (optional, boolean): Enables immediate searching and selection of sub-elements (defaulting to the 'grid' unit) regardless of the configured selectUnit when holding the Ctrl (Windows) or Meta (Mac) key. This is useful for quickly picking specific items without navigating through complex group structures. - `filter` (optional, function): A function to filter selectable objects based on a condition. - `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 }`. diff --git a/README_KR.md b/README_KR.md index e4bff93b..b88de8b7 100644 --- a/README_KR.md +++ b/README_KR.md @@ -496,6 +496,8 @@ patchmap.stateManager.setState('custom', { message: 'Hello World' }); - `'closestGroup'`: 선택된 객체에서 가장 가까운 상위 그룹을 선택합니다. - `'highestGroup'`: 선택된 객체에서 가장 최상위 그룹을 선택합니다. - `'grid'`: 선택된 객체가 속한 그리드를 선택합니다. +- `drillDown` (optional, boolean): 더블 클릭(또는 연속 클릭) 시 중첩된 그룹 내부로 단계별로 진입하며 하위 요소를 탐색하여 선택하는 기능을 활성화합니다. 활성화 시 이미 상위 그룹이 선택된 상태에서 다시 클릭하면, 해당 클릭 지점에 있는 더 깊은 단계의 자식 객체를 찾아 선택합니다. +- `deepSelect` (optional, boolean): Ctrl(Windows) 또는 Meta(Mac) 키를 누른 상태에서 클릭할 때, 설정된 selectUnit과 관계없이 즉시 하위 요소(기본 'grid' 단위)를 검색하여 선택할 수 있는 기능을 활성화합니다. 복잡한 그룹 구조를 거치지 않고 특정 요소를 빠르게 선택하고 싶을 때 유용합니다. - `filter` (optional, function): 선택 대상 객체를 조건에 따라 필터링할 수 있는 함수입니다. - `selectionBoxStyle` (optional, object): 드래그 선택 시 표시되는 사각형의 스타일을 지정합니다. - `fill` (object): 채우기 스타일. 기본값: `{ color: '#9FD6FF', alpha: 0.2 }`.