qcast-front/src/hooks/useCanvasEvent.js
ysCha f2a444ffb7 [1189_1] 모듈 복수선택(드래그 박스/Shift) + 단일 마우스 드래그 이동 — 手動配置 활성 시 드래그 모드 중지
- canvas.selection: 모듈 기본설정 + 手動配置 비활성 조건에서만 true
- refineModuleSelection: 비모듈 혼합 선택 시 모듈만 재구성, 단일/복수 이동잠금 해제
- commitModuleDragMove: 드래그 완료 후 지붕면 이탈/겹침 검증, 위반 시 시작 위치 복원
- modulesRemove: 복수선택 일괄 삭제 지원, 회로 연결 시 전체 차단
- QContextMenu: 2개 이상 선택 시 컨텍스트 메뉴 차단

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-10 11:25:05 +09:00

617 lines
19 KiB
JavaScript

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, 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() {
const canvas = useRecoilValue(canvasState)
const [canvasForEvent, setCanvasForEvent] = useState(null)
const [currentObject, setCurrentObject] = useRecoilState(currentObjectState)
const canvasSize = useRecoilValue(canvasSizeState)
const [canvasZoom, setCanvasZoom] = useRecoilState(canvasZoomState)
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)
}, [canvasZoom])
// canvasZoom이 100으로 변경되고 shouldCenter가 true일 때만 중앙 정렬 실행
useEffect(() => {
if (canvasZoom === 100 && canvas && shouldCenter) {
setTimeout(() => {
zoomToAllObjects()
canvas.renderAll()
setShouldCenter(false) // 실행 후 플래그 초기화
}, 100)
}
}, [canvasZoom, shouldCenter])
useEffect(() => {
attachDefaultEventOnCanvas()
// [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 = () => {
removeEventOnCanvas()
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)
canvas?.on('selection:updated', selectionEvent.updated)
/*canvas?.on('object:added', () => {
document.addEventListener('keydown', handleKeyDown)
})*/
canvas?.on('object:removed', objectEvent.removed)
}
const objectEvent = {
onChange: (e) => {
const target = e.target
if (target) {
// settleDown(target)
// roof 이동 후 좌표 재계산
if (target.name === POLYGON_TYPE.ROOF && target.type === 'QPolygon') {
target.fire('polygonMoved')
}
}
},
addEvent: (e) => {
const target = e.target
if (!target.id) {
target.id = uuidv4()
}
if (!target.uuid) {
target.uuid = uuidv4()
}
if (target.type === 'QPolygon' || target.type === 'QLine') {
const textObjs = canvas?.getObjects().filter((obj) => obj.name === 'lengthText')
textObjs.forEach((obj) => {
obj.bringToFront()
})
if (target.name === POLYGON_TYPE.ROOF) {
setTimeout(() => {
setPolygonLinesActualSize(target)
changeCorridorDimensionText()
}, 300)
}
}
if (target.name === 'cell') {
target.on('mousedown', () => {
if (target.get('selected')) {
target.set({ selected: false })
target.set({ fill: '#BFFD9F' })
} else {
target.set({ selected: true })
target.set({ fill: 'red' })
}
canvas?.renderAll()
})
}
if (target.name === 'trestle') {
target.on('mousedown', () => {
if (target.defense === 'north') {
alert('북쪽은 선택 불가합니다.')
return
}
if (target.get('selected')) {
target.set({ strokeWidth: 1 })
target.set({ strokeDashArray: [5, 5] })
target.set({ selected: false })
} else {
target.set({ strokeWidth: 5 })
target.set({ strokeDashArray: [0, 0] })
target.set({ selected: true })
}
canvas?.renderAll()
})
}
if (target.name === 'lengthText' && target.type.toLowerCase().includes('text') > 0) {
const x = target.left
const y = target.top
target.lockMovementX = false
target.lockMovementY = false
target.fill = lengthTextOption.fontColor.value
target.fontFamily = lengthTextOption.fontFamily.value
target.fontSize = lengthTextOption.fontSize.value
target.fontStyle = lengthTextOption.fontWeight.value.toLowerCase().includes('italic') ? 'italic' : 'normal'
target.fontWeight = lengthTextOption.fontWeight.value.toLowerCase().includes('bold') ? 'bold' : 'normal'
// Add a property to store the previous value
const previousValue = target.text
target.on('selected', (e) => {
Object.keys(target.controls).forEach((controlKey) => {
target.setControlVisible(controlKey, false)
})
})
target.on('moving', (e) => {
target.uuid = uuidv4()
if (target.parentDirection === 'left' || target.parentDirection === 'right') {
const minX = target.minX
const maxX = target.maxX
if (target.left <= minX) {
target.set({ left: minX, top: y })
} else if (target.left >= maxX) {
target.set({ left: maxX, top: y })
} else {
target.set({ top: y })
}
} else if (target.parentDirection === 'top' || target.parentDirection === 'bottom') {
const minY = target.minY
const maxY = target.maxY
if (target.top <= minY) {
target.set({ left: x, top: minY })
} else if (target.top >= maxY) {
target.set({ left: x, top: maxY })
} else {
target.set({ left: x })
}
}
canvas?.renderAll()
})
}
},
removed: (e) => {
const whiteList = ['mouseLine', 'guideLine']
if (whiteList.includes(e.target.name)) {
}
},
}
const selectionEvent = {
created: (e) => {
const target = e.selected[0]
setCurrentObject(target)
const { selected } = e
if (selected?.length > 0) {
selected.forEach((obj) => {
// if (obj.type === 'QPolygon' && currentMenu !== MENU.MODULE_CIRCUIT_SETTING.BASIC_SETTING) {
if (obj.type === 'QPolygon') {
const originStroke = obj.stroke
obj.set({ stroke: 'red' })
if (currentMenu === MENU.MODULE_CIRCUIT_SETTING.BASIC_SETTING) {
if (obj.name === POLYGON_TYPE.MODULE) {
obj.set({ strokeWidth: 3 })
}
if (obj.name === POLYGON_TYPE.ROOF) {
canvas.discardActiveObject()
obj.set({ stroke: originStroke })
}
}
}
})
canvas.renderAll()
}
refineModuleSelection()
},
cleared: (e) => {
setCurrentObject(null)
const { deselected } = e
if (deselected?.length > 0) {
deselected.forEach((obj) => {
if (obj.type === 'QPolygon') {
if (obj.name !== 'moduleSetupSurface') {
obj.set({ stroke: 'black' })
}
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 })
}
}
})
}
canvas.renderAll()
},
updated: (e) => {
const target = e.selected[0]
setCurrentObject(target)
const { selected, deselected } = e
if (deselected?.length > 0) {
deselected.forEach((obj) => {
if (obj.type === 'QPolygon') {
obj.set({ stroke: 'black' })
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 })
}
}
})
}
if (selected?.length > 0) {
selected.forEach((obj) => {
if (obj.type === 'QPolygon') {
obj.set({ stroke: 'red' })
if (obj.name === POLYGON_TYPE.MODULE && currentMenu === MENU.MODULE_CIRCUIT_SETTING.BASIC_SETTING) {
//모듈 선택시 라인 두께 변경
obj.set({ strokeWidth: 3 })
}
}
})
}
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')
canvas?.off('object:removed')
canvas?.off('selection:cleared')
canvas?.off('selection:created')
canvas?.off('selection:updated')
}
/**
* 각종 키보드 이벤트
* https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values
*/
const handleKeyDown = (e) => {
const key = e.key
switch (key) {
case 'Delete':
case 'Backspace':
// handleDelete()
break
case 'Down': // IE/Edge에서 사용되는 값
case 'ArrowDown':
// "아래 화살표" 키가 눌렸을 때의 동작입니다.
// moveDown()
break
case 'Up': // IE/Edge에서 사용되는 값
case 'ArrowUp':
// "위 화살표" 키가 눌렸을 때의 동작입니다.
// moveUp()
break
case 'Left': // IE/Edge에서 사용되는 값
case 'ArrowLeft':
// "왼쪽 화살표" 키가 눌렸을 때의 동작입니다.
// moveLeft()
break
case 'Right': // IE/Edge에서 사용되는 값
case 'ArrowRight':
// "오른쪽 화살표" 키가 눌렸을 때의 동작입니다.
// moveRight()
break
case 'Enter':
// "enter" 또는 "return" 키가 눌렸을 때의 동작입니다.
break
case 'Esc': // IE/Edge에서 사용되는 값
case 'Escape':
break
case 'z':
if (!e.ctrlKey) {
return
}
console.log('뒤로가기')
break
default:
return // 키 이벤트를 처리하지 않는다면 종료합니다.
}
e.preventDefault()
}
const moveDown = () => {
const targetObj = canvas?.getActiveObject()
if (!targetObj) {
return
}
let top = targetObj.top + 10
if (top > canvasSize.vertical) {
top = canvasSize.vertical
}
targetObj.set({ top: top })
targetObj.fire('modified')
canvas?.renderAll()
}
const moveUp = () => {
const targetObj = canvas?.getActiveObject()
if (!targetObj) {
return
}
let top = targetObj.top - 10
if (top < 0) {
top = 0
}
targetObj.set({ top: top })
targetObj.fire('modified')
canvas?.renderAll()
}
const moveLeft = () => {
const targetObj = canvas?.getActiveObject()
if (!targetObj) {
return
}
let left = targetObj.left - 10
if (left < 0) {
left = 0
}
targetObj.set({ left: left })
targetObj.fire('modified')
canvas?.renderAll()
}
const moveRight = () => {
const targetObj = canvas?.getActiveObject()
if (!targetObj) {
return
}
let left = targetObj.left + 10
if (left > canvasSize.horizontal) {
left = canvasSize.horizontal
}
targetObj.set({ left: left })
targetObj.fire('modified')
canvas?.renderAll()
}
/**
* 선택한 도형을 삭제한다.
*/
const handleDelete = () => {
const targets = canvas?.getActiveObjects()
if (targets?.length === 0) {
alert('삭제할 대상을 선택해주세요.')
return
}
if (!confirm('정말로 삭제하시겠습니까?')) {
return
}
targets?.forEach((target) => {
canvas?.remove(target)
})
}
const handleZoom = (isZoom) => {
let zoom
if (isZoom) {
zoom = canvasZoom + 10
if (zoom > 500) {
return
}
} else {
zoom = canvasZoom - 10
if (zoom < 10) {
//50%->10%
return
}
}
setCanvasZoom(zoom)
}
const handleZoomClear = () => {
setShouldCenter(true) // 중앙 정렬 플래그 설정
setCanvasZoom(100)
}
const zoomToAllObjects = () => {
const allObjects = canvas.getObjects()
// 주요 도형 객체만 필터링 (roof, arrow 등)
const objects = allObjects.filter((obj) => {
// 주요 도형 객체만 포함
return obj.name === 'roof' || obj.name === 'arrow' || obj.name === 'module' || obj.name === 'dormer' || obj.name === 'object'
})
console.log('🔍 All objects:', allObjects.length)
console.log('🔍 Main objects for centering:', objects.length)
console.log(
'🔍 Main object details:',
objects.map((obj) => ({
name: obj.name,
type: obj.type,
visible: obj.visible,
id: obj.id,
})),
)
if (objects.length === 0) {
console.log('❌ No main objects found for centering')
return
}
let minX = Infinity,
minY = Infinity
let maxX = -Infinity,
maxY = -Infinity
objects.forEach((obj) => {
const bounds = obj.getBoundingRect()
minX = Math.min(minX, bounds.left)
minY = Math.min(minY, bounds.top)
maxX = Math.max(maxX, bounds.left + bounds.width)
maxY = Math.max(maxY, bounds.top + bounds.height)
})
const objectsCenterX = (minX + maxX) / 2
const objectsCenterY = (minY + maxY) / 2
// 화면 중앙 좌표 (메뉴 높이 고려하여 Y 위치 조정)
const screenCenterX = canvas.getWidth() / 2
const menuHeightOffset = 80 // 위쪽 메뉴 높이만큼 위로 조정
const screenCenterY = canvas.getHeight() / 2 - menuHeightOffset
// 객체 중심을 화면 중앙으로 이동
const deltaX = screenCenterX - objectsCenterX
const deltaY = screenCenterY - objectsCenterY
// canvas viewport transform 조정
const viewportTransform = canvas.viewportTransform
viewportTransform[4] += deltaX
viewportTransform[5] += deltaY
canvas.setViewportTransform(viewportTransform)
// 브라우저 스크롤을 중앙으로 조정하여 전체 canvas를 모니터 중앙에 위치
const canvasElement = canvas.getElement()
const canvasRect = canvasElement.getBoundingClientRect()
// 현재 스크롤 위치
const currentScrollTop = window.pageYOffset || document.documentElement.scrollTop
const currentScrollLeft = window.pageXOffset || document.documentElement.scrollLeft
// canvas의 현재 화면 위치
const canvasScreenTop = canvasRect.top + currentScrollTop
const canvasScreenLeft = canvasRect.left + currentScrollLeft
// 모니터 중앙
const monitorCenterX = window.innerWidth / 2
const monitorCenterY = window.innerHeight / 2
// canvas가 모니터 중앙에 오도록 스크롤 위치 계산
const targetScrollLeft = canvasScreenLeft + canvasRect.width / 2 - monitorCenterX
const targetScrollTop = canvasScreenTop + canvasRect.height / 2 - monitorCenterY + menuHeightOffset
// 스크롤을 중앙으로 이동
window.scrollTo({
top: targetScrollTop,
left: targetScrollLeft,
behavior: 'smooth', // 부드러운 스크롤
})
canvas.renderAll()
}
return {
setCanvasForEvent,
attachDefaultEventOnCanvas,
handleZoomClear,
handleZoom,
}
}