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, } }