import { useCallback, useRef } from 'react' import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' import { canvasState, canvasZoomState, checkedModuleState, currentMenuState, drewRoofCellsState, moduleIsSetupState, moduleRowColArrayState, moduleSetupSurfaceState, redoStackState, roofPolygonArrayState, undoStackState, } from '@/store/canvasAtom' import { outerLinePointsState } from '@/store/outerLineAtom' import { selectedMenuState } from '@/store/menuAtom' import { pendingPopupState, popupState } from '@/store/popupAtom' import { MENU, POLYGON_TYPE, SAVE_KEY } from '@/common/common' const MAX_HISTORY = 3 // 모든 useUndoRedo 인스턴스가 공유하는 복원 플래그 // useRef는 인스턴스마다 독립적이므로, 모듈 레벨 변수로 전환 let _isRestoring = false let _restoreTimer = null /** * 외부 훅/모듈에서 현재 undo/redo 복원 중인지 확인할 때 사용 */ export function isUndoRedoRestoring() { return _isRestoring } // loadFromJSON 공통 처리 - 이벤트 일시 중단 후 복원 function _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom) { // 진행 중인 복원 타이머가 있으면 취소 if (_restoreTimer) { clearTimeout(_restoreTimer) _restoreTimer = null } // loadFromJSON 중 object:added 등 이벤트로 인한 부작용 방지 // 리스너를 저장/복원하지 않고 단순히 off만 한다 // restoreAtomSnapshot에서 currentMenu 변경 → useCanvasEvent가 attachDefaultEventOnCanvas로 재등록 ;['object:added', 'object:modified', 'object:removed'].forEach((eventName) => { canvas.off(eventName) }) canvas.loadFromJSON(snapshot.canvasJSON, () => { rebuildCanvasAtoms() canvas.renderAll() restoreAtomSnapshot(snapshot.atomSnapshot) if (canvas.viewportTransform) { setCanvasZoom(Number((canvas.viewportTransform[0] * 100).toFixed(0))) } // 다음 undo/redo 허용까지 짧은 딜레이 (연속 클릭 디바운스) _restoreTimer = setTimeout(() => { _isRestoring = false _restoreTimer = null }, 50) }) } // 순환 참조만 안전하게 건너뛰는 JSON 직렬화 // WeakSet 방식은 공유 참조(같은 배열이 여러 곳에서 참조)까지 제거하므로 사용 불가 // 대신 조상 경로(ancestors) 스택으로 진짜 순환만 감지 function safeStringify(obj) { const ancestors = [] return JSON.stringify(obj, function (key, value) { if (typeof value !== 'object' || value === null) return value // `this`는 현재 key를 담고 있는 부모 객체 // ancestors 스택에서 현재 부모 이후의 항목(형제 경로)을 정리 while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) { ancestors.pop() } // 현재 value가 조상 경로에 이미 있으면 순환 참조 if (ancestors.includes(value)) return undefined ancestors.push(value) return value }) } function safeClone(value) { if (value === null || value === undefined) return value try { return JSON.parse(safeStringify(value)) } catch { return null } } export function useUndoRedo() { const canvas = useRecoilValue(canvasState) const [undoStack, setUndoStack] = useRecoilState(undoStackState) const [redoStack, setRedoStack] = useRecoilState(redoStackState) const [, setModuleSetupSurface] = useRecoilState(moduleSetupSurfaceState) const [moduleIsSetup, setModuleIsSetup] = useRecoilState(moduleIsSetupState) const [checkedModule, setCheckedModule] = useRecoilState(checkedModuleState) const [moduleRowColArray, setModuleRowColArray] = useRecoilState(moduleRowColArrayState) const [, setDrewRoofCells] = useRecoilState(drewRoofCellsState) const [, setRoofPolygonArray] = useRecoilState(roofPolygonArrayState) const [outerLinePoints, setOuterLinePoints] = useRecoilState(outerLinePointsState) const [selectedMenu, setSelectedMenu] = useRecoilState(selectedMenuState) const [currentMenu, setCurrentMenu] = useRecoilState(currentMenuState) const [, setCanvasZoom] = useRecoilState(canvasZoomState) const setPopup = useSetRecoilState(popupState) const [pendingPopup, setPendingPopup] = useRecoilState(pendingPopupState) // 최신 스택 값을 ref로 유지하여 클로저 stale 문제 방지 const undoStackRef = useRef(undoStack) undoStackRef.current = undoStack const redoStackRef = useRef(redoStack) redoStackRef.current = redoStack const getAtomSnapshot = useCallback(() => { return { moduleIsSetup: safeClone(moduleIsSetup || []), checkedModule: safeClone(checkedModule || []), moduleRowColArray: safeClone(moduleRowColArray || []), outerLinePoints: safeClone(outerLinePoints || []), selectedMenu, currentMenu, pendingPopup: pendingPopup ? safeClone(pendingPopup) : null, } }, [moduleIsSetup, checkedModule, moduleRowColArray, outerLinePoints, selectedMenu, currentMenu, pendingPopup]) // loadFromJSON 후 오브젝트 간 라이브 참조 관계를 재구축 const rebuildObjectGraph = useCallback(() => { if (!canvas) return const objects = canvas.getObjects() // wall polygon의 lines를 캔버스의 outerLine 오브젝트로 재연결 const walls = objects.filter((obj) => obj.name === POLYGON_TYPE.WALL) const outerLines = objects.filter((obj) => obj.name === 'outerLine') walls.forEach((wall) => { if (outerLines.length > 0) { // outerLine에 idx가 있으면 순서대로, 없으면 parentId로 매칭 const wallOuterLines = outerLines.filter((line) => !line.parentId || line.parentId === wall.id).sort((a, b) => (a.idx ?? 0) - (b.idx ?? 0)) if (wallOuterLines.length > 0) { wall.lines = wallOuterLines } } }) // roof polygon의 innerLines, hips, ridges, lines를 캔버스 오브젝트로 재연결 const roofs = objects.filter((obj) => obj.name === POLYGON_TYPE.ROOF) roofs.forEach((roof) => { // innerLines: hip과 ridge 라인들 (roof.innerLines.push(hip), roof.innerLines.push(ridge) 패턴) const hipLines = objects.filter((obj) => obj.parentId === roof.id && obj.name === 'hip') const ridgeLines = objects.filter((obj) => obj.parentId === roof.id && obj.name === 'ridge') if (hipLines.length > 0 || ridgeLines.length > 0) { roof.innerLines = [...hipLines, ...ridgeLines] } if (hipLines.length > 0) { roof.hips = hipLines } if (ridgeLines.length > 0) { roof.ridges = ridgeLines } // roof.lines: eaveHelpLine 또는 helpLine (지붕면 할당에서 사용) const eaveHelpLines = objects.filter( (obj) => (obj.lineName === 'eaveHelpLine' || obj.lineName === 'helpLine' || obj.name === 'helpLine') && obj.roofId === roof.id, ) if (eaveHelpLines.length > 0) { roof.lines = eaveHelpLines } // skeletonLines 재연결 const skeletonLines = objects.filter((obj) => (obj.lineName === 'roofLine' || obj.lineName === 'skeletonLine') && obj.parentId === roof.id) if (skeletonLines.length > 0) { roof.skeletonLines = skeletonLines } }) // moduleSetupSurface의 modules 배열을 캔버스의 실제 QPolygon 모듈 객체로 재연결 const surfaces = objects.filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) const moduleObjects = objects.filter((obj) => obj.name === POLYGON_TYPE.MODULE) surfaces.forEach((surface) => { const surfaceModules = moduleObjects.filter((mod) => mod.surfaceId === surface.id) surface.modules = surfaceModules }) // fabric.Group 내부의 QPolygon/QLine 자식에 canvas 참조 설정 // loadFromJSON 후 Group 자식은 canvas 를 받지 못해 init/addLengthText 등이 실패할 수 있음 objects .filter((obj) => obj.type === 'group') .forEach((group) => { if (group._objects) { group._objects.forEach((child) => { if (!child.canvas) { child.canvas = canvas } }) } }) // 부모가 없는 고아 lengthText, arrow, flowText 제거 // fabric.Group 내부의 QPolygon/QLine 자식 id도 부모로 간주해야 // 도머(Group) 내부 삼각형들의 lengthText가 사라지지 않음 const parentIds = new Set() objects.forEach((obj) => { if (obj.id) parentIds.add(obj.id) if (obj.type === 'group' && obj._objects) { obj._objects.forEach((child) => { if (child.id) parentIds.add(child.id) // 그룹 내부 QPolygon의 lines(QLine) id도 포함 if (child.lines && Array.isArray(child.lines)) { child.lines.forEach((line) => { if (line.id) parentIds.add(line.id) }) } }) } // 일반 QPolygon의 lines id도 포함 (내부 보조선/치수선의 parentId) if (obj.lines && Array.isArray(obj.lines)) { obj.lines.forEach((line) => { if (line.id) parentIds.add(line.id) }) } }) const orphans = objects.filter( (obj) => (obj.name === 'lengthText' || obj.name === 'arrow' || obj.name === 'flowText') && obj.parentId && !parentIds.has(obj.parentId), ) orphans.forEach((obj) => canvas.remove(obj)) // lengthText 이동 관련 속성 및 이벤트 재설정 // loadFromJSON 중 object:added 이벤트가 꺼져있어 addEvent가 실행되지 않으므로 수동 설정 const lengthTexts = canvas.getObjects().filter((obj) => obj.name === 'lengthText' && obj.type?.toLowerCase().includes('text')) lengthTexts.forEach((target) => { target.bringToFront() target.selectable = true target.evented = true target.lockMovementX = false target.lockMovementY = false target.lockRotation = true target.lockScalingX = true target.lockScalingY = true // 기존 per-object 이벤트 제거 후 재등록 target.off('selected') target.off('moving') const x = target.left const y = target.top target.on('selected', () => { Object.keys(target.controls).forEach((controlKey) => { target.setControlVisible(controlKey, false) }) }) target.on('moving', () => { 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() }) }) }, [canvas]) const rebuildCanvasAtoms = useCallback(() => { if (!canvas) return // 먼저 오브젝트 간 참조 관계 재구축 rebuildObjectGraph() const objects = canvas.getObjects() const surfaces = objects.filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) setModuleSetupSurface(surfaces) const roofs = objects.filter((obj) => obj.name === POLYGON_TYPE.ROOF) setRoofPolygonArray(roofs) const cells = roofs.filter((obj) => obj.cells && obj.cells.length > 0) setDrewRoofCells(cells) // loadFromJSON 후 QPolygon/QLine 의 lengthText 가 orphan 제거되었을 수 있으므로 재생성 // (initLines 로 새 QLine 이 만들어지면 기존 lengthText 의 parentId 가 안 맞음) const shouldRebuildText = (poly) => poly.type === 'QPolygon' && poly.name !== 'arrow' && poly.name !== POLYGON_TYPE.MODULE && poly.name !== POLYGON_TYPE.MODULE_SETUP_SURFACE && poly.name !== POLYGON_TYPE.OBJECT_SURFACE const qPolygons = objects.filter(shouldRebuildText) qPolygons.forEach((polygon) => { try { polygon.initLines() polygon.addLengthText() } catch (e) { /* skip */ } }) // Group 내부 QPolygon(도머 등)의 lengthText 도 동일하게 재생성 objects .filter((obj) => obj.type === 'group' && obj._objects) .forEach((group) => { group._objects.forEach((child) => { if (shouldRebuildText(child)) { try { if (!child.canvas) child.canvas = canvas child.initLines?.() child.addLengthText?.() } catch (e) { /* skip */ } } }) }) canvas.getObjects() .filter((obj) => obj.type === 'QLine') .forEach((line) => { try { if (typeof line.addLengthText === 'function') { line.addLengthText() } } catch (e) { /* skip */ } }) }, [canvas, rebuildObjectGraph]) const restoreAtomSnapshot = useCallback((atomSnapshot) => { if (!atomSnapshot) return setModuleIsSetup(atomSnapshot.moduleIsSetup || []) setCheckedModule(atomSnapshot.checkedModule || []) setModuleRowColArray(atomSnapshot.moduleRowColArray || []) setOuterLinePoints(atomSnapshot.outerLinePoints || []) if (atomSnapshot.selectedMenu !== undefined) setSelectedMenu(atomSnapshot.selectedMenu) if (atomSnapshot.currentMenu !== undefined) setCurrentMenu(atomSnapshot.currentMenu) // undo/redo 시 복원해야 할 팝업 정보 setPendingPopup(atomSnapshot.pendingPopup ?? null) }, []) // canvas.toJSON(SAVE_KEY)의 결과를 순환 참조 없이 안전하게 직렬화 const getCanvasSnapshot = useCallback(() => { if (!canvas) return null try { const rawJSON = canvas.toJSON(SAVE_KEY) return JSON.parse(safeStringify(rawJSON)) } catch (e) { console.warn('Canvas snapshot failed:', e) return null } }, [canvas]) const saveSnapshot = useCallback((atomOverrides = null, { force = false } = {}) => { if (!canvas || _isRestoring) return // 지붕형상 수동설정 메뉴에서는 스냅샷을 쌓지 않는다 (force 시 예외) if (!force && currentMenu === MENU.ROOF_COVERING.ROOF_SHAPE_PASSIVITY_SETTINGS) return // snapshot 전에 mouseLine 제거 const mouseLines = canvas.getObjects().filter((obj) => obj.name === 'mouseLine') mouseLines.forEach((obj) => canvas.remove(obj)) const canvasJSON = getCanvasSnapshot() if (!canvasJSON) return const atomSnapshot = getAtomSnapshot() // atomOverrides 로 비동기 state 반영이 안 된 값을 즉시 덮어쓸 수 있다 if (atomOverrides) { Object.assign(atomSnapshot, atomOverrides) } const snapshot = { canvasJSON, atomSnapshot, } setUndoStack((prev) => { const newStack = [...prev, snapshot] if (newStack.length > MAX_HISTORY) { return newStack.slice(newStack.length - MAX_HISTORY) } return newStack }) setRedoStack([]) }, [canvas, currentMenu, getCanvasSnapshot, getAtomSnapshot]) const closeAllPopups = useCallback(() => { setPopup((prev) => ({ // 처마 케라바 변경 팝업(EavesGableEdit)은 undo/redo 시 닫지 않는다 config: prev.config.filter((p) => p.component?.type?.name === 'EavesGableEdit'), other: prev.other.filter((p) => p.component?.type?.name === 'EavesGableEdit'), })) }, [setPopup]) const undo = useCallback(() => { // 지붕형상 수동설정 중에는 undo 차단 if (currentMenu === MENU.ROOF_COVERING.ROOF_SHAPE_PASSIVITY_SETTINGS) return const currentUndoStack = undoStackRef.current if (!canvas || currentUndoStack.length === 0 || _isRestoring) return _isRestoring = true const currentCanvasJSON = getCanvasSnapshot() const currentAtomSnapshot = getAtomSnapshot() const currentSnapshot = { canvasJSON: currentCanvasJSON, atomSnapshot: currentAtomSnapshot, } const newUndoStack = [...currentUndoStack] const snapshot = newUndoStack.pop() // undo/redo 시 항상 팝업을 닫고 복원한다 closeAllPopups() setUndoStack(newUndoStack) setRedoStack((prev) => [...prev, currentSnapshot]) _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom) }, [canvas, currentMenu, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups]) const redo = useCallback(() => { // 지붕형상 수동설정 중에는 redo 차단 if (currentMenu === MENU.ROOF_COVERING.ROOF_SHAPE_PASSIVITY_SETTINGS) return const currentRedoStack = redoStackRef.current if (!canvas || currentRedoStack.length === 0 || _isRestoring) return _isRestoring = true const currentCanvasJSON = getCanvasSnapshot() const currentAtomSnapshot = getAtomSnapshot() const currentSnapshot = { canvasJSON: currentCanvasJSON, atomSnapshot: currentAtomSnapshot, } const newRedoStack = [...currentRedoStack] const snapshot = newRedoStack.pop() // undo/redo 시 항상 팝업을 닫고 복원한다 closeAllPopups() setRedoStack(newRedoStack) setUndoStack((prev) => [...prev, currentSnapshot]) _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom) }, [canvas, currentMenu, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups]) const clearStacks = useCallback(() => { setUndoStack([]) setRedoStack([]) }, []) /** undo 스택에서 마지막 스냅샷 1개만 제거 (취소 시 사용) */ const popLastSnapshot = useCallback(() => { setUndoStack((prev) => (prev.length > 0 ? prev.slice(0, -1) : prev)) }, []) return { saveSnapshot, undo, redo, clearStacks, popLastSnapshot, canUndo: undoStack.length > 0, canRedo: redoStack.length > 0, } }