qcast-front/src/hooks/useUndoRedo.js
2026-04-01 15:54:55 +09:00

382 lines
14 KiB
JavaScript

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 { popupState } from '@/store/popupAtom'
import { POLYGON_TYPE, SAVE_KEY } from '@/common/common'
const MAX_HISTORY = 3
// 모든 useUndoRedo 인스턴스가 공유하는 복원 플래그
// useRef는 인스턴스마다 독립적이므로, 모듈 레벨 변수로 전환
let _isRestoring = false
let _restoreTimer = null
// 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)
// 최신 스택 값을 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,
}
}, [moduleIsSetup, checkedModule, moduleRowColArray, outerLinePoints, selectedMenu, currentMenu])
// 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
})
// 부모가 없는 고아 lengthText, arrow, flowText 제거
const parentIds = new Set(objects.filter((obj) => obj.id).map((obj) => obj.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)
}, [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)
}, [])
// 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(() => {
if (!canvas || _isRestoring) 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()
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, getCanvasSnapshot, getAtomSnapshot])
const closeAllPopups = useCallback(() => {
setPopup({ config: [], other: [] })
}, [setPopup])
const undo = useCallback(() => {
const currentUndoStack = undoStackRef.current
console.log('currentUndoStack', currentUndoStack)
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()
if (snapshot.atomSnapshot?.currentMenu !== currentMenu) {
closeAllPopups()
}
setUndoStack(newUndoStack)
setRedoStack((prev) => [...prev, currentSnapshot])
_restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom)
}, [canvas, currentMenu, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups])
const redo = useCallback(() => {
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()
if (snapshot.atomSnapshot?.currentMenu !== currentMenu) {
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([])
}, [])
return {
saveSnapshot,
undo,
redo,
clearStacks,
canUndo: undoStack.length > 0,
canRedo: redoStack.length > 0,
}
}