From f2a444ffb78c0d787af0f07a9dbdc258377b989f Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 10 Jun 2026 11:25:05 +0900 Subject: [PATCH] =?UTF-8?q?[1189=5F1]=20=EB=AA=A8=EB=93=88=20=EB=B3=B5?= =?UTF-8?q?=EC=88=98=EC=84=A0=ED=83=9D(=EB=93=9C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EB=B0=95=EC=8A=A4/Shift)=20+=20=EB=8B=A8=EC=9D=BC=20=EB=A7=88?= =?UTF-8?q?=EC=9A=B0=EC=8A=A4=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=E2=80=94=20=E6=89=8B=E5=8B=95=E9=85=8D=E7=BD=AE=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=20=EC=8B=9C=20=EB=93=9C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=A4=91=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - canvas.selection: 모듈 기본설정 + 手動配置 비활성 조건에서만 true - refineModuleSelection: 비모듈 혼합 선택 시 모듈만 재구성, 단일/복수 이동잠금 해제 - commitModuleDragMove: 드래그 완료 후 지붕면 이탈/겹침 검증, 위반 시 시작 위치 복원 - modulesRemove: 복수선택 일괄 삭제 지원, 회로 연결 시 전체 차단 - QContextMenu: 2개 이상 선택 시 컨텍스트 메뉴 차단 Co-Authored-By: Claude Opus 4 --- .../common/context-menu/QContextMenu.jsx | 4 + src/hooks/module/useModule.js | 85 ++++++++++++++--- src/hooks/useCanvasEvent.js | 94 ++++++++++++++++++- 3 files changed, 168 insertions(+), 15 deletions(-) diff --git a/src/components/common/context-menu/QContextMenu.jsx b/src/components/common/context-menu/QContextMenu.jsx index 610a2a29..c3adac21 100644 --- a/src/components/common/context-menu/QContextMenu.jsx +++ b/src/components/common/context-menu/QContextMenu.jsx @@ -34,6 +34,10 @@ export default function QContextMenu(props) { const handleContextMenu = (e) => { // e.preventDefault() //기존 contextmenu 막고 + // [MODULE-MULTI-SELECT 2026-06-10] 복수선택(2개 이상) 시 컨텍스트 메뉴 노출 차단. + // 복수선택은 Del 키 삭제 + 마우스 드래그 이동만 지원. + if (canvas?.getActiveObjects && canvas.getActiveObjects().length > 1) return + if (currentObject) { const isArray = currentObject.hasOwnProperty('arrayData') if (isArray && currentObject.arrayData.length === 0) return diff --git a/src/hooks/module/useModule.js b/src/hooks/module/useModule.js index 061e1c42..85d9aa3a 100644 --- a/src/hooks/module/useModule.js +++ b/src/hooks/module/useModule.js @@ -981,15 +981,11 @@ export function useModule() { // 회로가 없을때만 삭제 가능 const modulesRemove = () => { - const activeModule = canvas.getObjects().find((obj) => canvas.getActiveObjects()[0].id === obj.id) - const modules = canvas - .getObjects() - .filter((obj) => obj.surfaceId === activeModule.surfaceId && obj.name === POLYGON_TYPE.MODULE && activeModule.id !== obj.id) - const surface = canvas.getObjects().find((obj) => obj.id === activeModule.surfaceId && obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) - if (surface.isComplete) { - removeTrestleMaterials() - } - if (activeModule.circuit) { + // [MODULE-MULTI-SELECT 2026-06-10] 단일/복수 선택 모두 처리. 회로가 걸린 모듈이 하나라도 있으면 차단. + const activeModules = canvas.getActiveObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE) + if (activeModules.length === 0) return + + if (activeModules.some((module) => module.circuit)) { swalFire({ title: getMessage('can.not.remove.module'), icon: 'error', @@ -998,11 +994,26 @@ export function useModule() { return } - surface.set({ modules: modules }) - canvas.remove(activeModule) + const surfaceIds = [...new Set(activeModules.map((module) => module.surfaceId))] + const affectedSurfaces = canvas + .getObjects() + .filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && surfaceIds.includes(obj.id)) + + if (affectedSurfaces.some((surface) => surface.isComplete)) { + removeTrestleMaterials() + } + + activeModules.forEach((module) => canvas.remove(module)) + + affectedSurfaces.forEach((surface) => { + const remaining = canvas.getObjects().filter((obj) => obj.surfaceId === surface.id && obj.name === POLYGON_TYPE.MODULE) + surface.set({ modules: remaining }) + }) + + canvas.discardActiveObject() canvas.renderAll() setModuleStatisticsData() - recalculateAllModulesCoords(activeModule.surfaceId) + surfaceIds.forEach((surfaceId) => recalculateAllModulesCoords(surfaceId)) } const moduleRoofRemove = (surfaceArray) => { @@ -1130,6 +1141,55 @@ export function useModule() { canvas.renderAll() } + // [MODULE-MULTI-SELECT 2026-06-10] 드래그 완료 후 지붕면 이탈/겹침 검증 → 위반 시 startPos 복원, 정상이면 확정 + const commitModuleDragMove = (target, startPos) => { + if (!canvas || !target) return + const isGroup = target.type === 'activeSelection' + const modules = isGroup + ? target.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE) + : target.name === POLYGON_TYPE.MODULE + ? [target] + : [] + if (modules.length === 0) return + + const movingIds = new Set(modules.map((module) => module.id)) + const objects = getObjects() + + const invalid = modules.some((module) => { + const surface = canvas.getObjects().find((obj) => obj.id === module.surfaceId && obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) + if (surface && isOutsideSurface(module, surface)) return true + const others = canvas + .getObjects() + .filter((obj) => obj.name === POLYGON_TYPE.MODULE && obj.surfaceId === module.surfaceId && !movingIds.has(obj.id)) + if (isOverlapOtherModules(module, others)) return true + if (isOverlapObjects(module, objects)) return true + return false + }) + + // 위반 시 드래그 시작 위치로 복원 (그룹/단일 공통 — 둘 다 left/top 평행이동) + if (invalid && startPos) { + target.set({ left: startPos.left, top: startPos.top }) + target.setCoords() + } + + // 그룹은 자식 좌표가 상대값이라 discard 로 절대좌표 베이킹. 단일은 이미 절대좌표라 선택 유지. + if (isGroup) { + canvas.discardActiveObject() + } + + const surfaceIds = [...new Set(modules.map((module) => module.surfaceId))] + surfaceIds.forEach((surfaceId) => recalculateAllModulesCoords(surfaceId)) + canvas.requestRenderAll() + + if (invalid) { + swalFire({ + title: getMessage('can.not.move.module'), + icon: 'error', + type: 'alert', + }) + } + } + /** * [PLAN-CORRUPT-RESTORE 2026-05-18] 손상 module 좌표 복원. * @@ -1269,6 +1329,7 @@ export function useModule() { moduleRoofRemove, alignModule, recalculateAllModulesCoords, + commitModuleDragMove, restoreCorruptedModules, } } diff --git a/src/hooks/useCanvasEvent.js b/src/hooks/useCanvasEvent.js index 8e4eef8a..fc3b6e09 100644 --- a/src/hooks/useCanvasEvent.js +++ b/src/hooks/useCanvasEvent.js @@ -1,12 +1,14 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { fabric } from 'fabric' import { useRecoilState, useRecoilValue } from 'recoil' import { v4 as uuidv4 } from 'uuid' -import { canvasSizeState, canvasState, canvasZoomState, currentMenuState, currentObjectState } from '@/store/canvasAtom' +import { canvasSizeState, canvasState, canvasZoomState, currentMenuState, currentObjectState, isManualModuleSetupState } from '@/store/canvasAtom' import { QPolygon } from '@/components/fabric/QPolygon' import { fontSelector } from '@/store/fontAtom' import { MENU, POLYGON_TYPE } from '@/common/common' import { useText } from '@/hooks/useText' import { usePolygon } from '@/hooks/usePolygon' +import { useModule } from '@/hooks/module/useModule' // 캔버스에 필요한 이벤트 export function useCanvasEvent() { @@ -18,8 +20,12 @@ export function useCanvasEvent() { const [shouldCenter, setShouldCenter] = useState(false) // 중앙 정렬 플래그 추가 const lengthTextOption = useRecoilValue(fontSelector('lengthText')) const currentMenu = useRecoilValue(currentMenuState) + const isManualModuleSetup = useRecoilValue(isManualModuleSetupState) const { changeCorridorDimensionText } = useText() const { setPolygonLinesActualSize } = usePolygon() + const { commitModuleDragMove } = useModule() + // [MODULE-MULTI-SELECT 2026-06-10] 모듈 복수선택 refine 재진입 가드 (rebuild 중첩 방지) + const refiningRef = useRef(false) useEffect(() => { canvas?.setZoom(canvasZoom / 100) @@ -38,7 +44,16 @@ export function useCanvasEvent() { useEffect(() => { attachDefaultEventOnCanvas() - }, [currentMenu]) + // [MODULE-MULTI-SELECT 2026-06-10] 모듈 기본설정 메뉴에서만 드래그 박스/복수선택 활성화. + // 그 외 메뉴는 selection:false 유지(지붕선·마루 등 다른 레이어가 드래그에 잡히는 부작용 방지). + // [MODULE-MULTI-SELECT 2026-06-10] 手動配置 활성 중에는 드래그 모드 중지(배치 클릭과 충돌 방지). + if (canvas) { + const isModuleMenu = currentMenu === MENU.MODULE_CIRCUIT_SETTING.BASIC_SETTING + const enableDrag = isModuleMenu && !isManualModuleSetup + canvas.selection = enableDrag + if (!enableDrag) canvas.discardActiveObject() + } + }, [currentMenu, isManualModuleSetup]) // 기본적인 이벤트 필요시 추가 const attachDefaultEventOnCanvas = () => { @@ -46,6 +61,7 @@ export function useCanvasEvent() { canvas?.on('object:added', objectEvent.onChange) canvas?.on('object:added', objectEvent.addEvent) canvas?.on('object:modified', objectEvent.onChange) + canvas?.on('object:modified', handleModuleSelectionMoved) canvas?.on('object:removed', objectEvent.onChange) canvas?.on('selection:cleared', selectionEvent.cleared) canvas?.on('selection:created', selectionEvent.created) @@ -207,6 +223,7 @@ export function useCanvasEvent() { }) canvas.renderAll() } + refineModuleSelection() }, cleared: (e) => { setCurrentObject(null) @@ -221,6 +238,10 @@ export function useCanvasEvent() { if (obj.name === POLYGON_TYPE.MODULE && currentMenu === MENU.MODULE_CIRCUIT_SETTING.BASIC_SETTING) { obj.set({ strokeWidth: 0.3 }) } + // [MODULE-MULTI-SELECT 2026-06-10] 선택 해제 시 모듈 이동잠금 복원(다른 메뉴에서 드래그 방지) + if (obj.name === POLYGON_TYPE.MODULE) { + obj.set({ lockMovementX: true, lockMovementY: true }) + } } }) } @@ -239,6 +260,10 @@ export function useCanvasEvent() { //모듈 미선택시 라인 두께 변경 obj.set({ strokeWidth: 0.3 }) } + // [MODULE-MULTI-SELECT 2026-06-10] 선택 해제 시 모듈 이동잠금 복원(다른 메뉴에서 드래그 방지) + if (obj.name === POLYGON_TYPE.MODULE) { + obj.set({ lockMovementX: true, lockMovementY: true }) + } } }) } @@ -256,9 +281,72 @@ export function useCanvasEvent() { } canvas.renderAll() + refineModuleSelection() }, } + // [MODULE-MULTI-SELECT 2026-06-10] 선택을 모듈만으로 정제, 이동잠금 해제 (회전·스케일은 잠금 유지). refiningRef 로 재구성 중첩 차단. + const refineModuleSelection = () => { + if (!canvas) return + if (currentMenu !== MENU.MODULE_CIRCUIT_SETTING.BASIC_SETTING) return + if (refiningRef.current) return + + const active = canvas.getActiveObject() + if (!active) return + + // 단일 모듈 선택: 마우스 이동 가능하도록 이동잠금 해제 (회전·스케일은 잠금 유지). + if (active.type !== 'activeSelection') { + if (active.name === POLYGON_TYPE.MODULE) { + active.set({ lockMovementX: false, lockMovementY: false, lockRotation: true, lockScalingX: true, lockScalingY: true, hasControls: false }) + canvas.requestRenderAll() + } + return + } + + const objs = active.getObjects() + const modules = objs.filter((obj) => obj.name === POLYGON_TYPE.MODULE) + + if (modules.length !== objs.length) { + refiningRef.current = true + canvas.discardActiveObject() + if (modules.length > 1) { + canvas.setActiveObject(new fabric.ActiveSelection(modules, { canvas })) + } else if (modules.length === 1) { + canvas.setActiveObject(modules[0]) + } + refiningRef.current = false + } + + const finalActive = canvas.getActiveObject() + if (finalActive?.type === 'activeSelection') { + finalActive.set({ + lockMovementX: false, + lockMovementY: false, + lockScalingX: true, + lockScalingY: true, + lockRotation: true, + hasControls: false, + }) + } + canvas.requestRenderAll() + } + + // [MODULE-MULTI-SELECT 2026-06-10] object:modified 시 지붕면 경계 검증 → 위반이면 드래그 시작 좌표로 복원 + const handleModuleSelectionMoved = (e) => { + if (currentMenu !== MENU.MODULE_CIRCUIT_SETTING.BASIC_SETTING) return + const target = e?.target + if (!target) return + + const isGroup = target.type === 'activeSelection' + const isSingleModule = target.name === POLYGON_TYPE.MODULE + if (!isGroup && !isSingleModule) return + if (isGroup && !target.getObjects().some((obj) => obj.name === POLYGON_TYPE.MODULE)) return + + const original = e?.transform?.original + const startPos = original ? { left: original.left, top: original.top } : null + commitModuleDragMove(target, startPos) + } + const removeEventOnCanvas = () => { canvas?.off('object:added') canvas?.off('object:modified')