559 lines
21 KiB
JavaScript
559 lines
21 KiB
JavaScript
import { useCallback, useRef } from 'react'
|
|
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
|
|
import {
|
|
canvasState,
|
|
canvasZoomState,
|
|
checkedModuleState,
|
|
currentMenuState,
|
|
drewRoofCellsState,
|
|
moduleIsSetupState,
|
|
moduleRowColArrayState,
|
|
moduleSetupSurfaceState,
|
|
redoStackState,
|
|
roofPolygonArrayState,
|
|
undoRedoDrawingState,
|
|
undoStackState,
|
|
} from '@/store/canvasAtom'
|
|
import { outerLinePointsState } from '@/store/outerLineAtom'
|
|
import { selectedMenuState } from '@/store/menuAtom'
|
|
import { pendingPopupState, popupState } from '@/store/popupAtom'
|
|
import { POLYGON_TYPE, SAVE_KEY } from '@/common/common'
|
|
import { useCircuitTrestle } from '@/hooks/useCirCuitTrestle'
|
|
|
|
const MAX_HISTORY = 3
|
|
|
|
// 모든 useUndoRedo 인스턴스가 공유하는 복원 플래그
|
|
// useRef는 인스턴스마다 독립적이므로, 모듈 레벨 변수로 전환
|
|
let _isRestoring = false
|
|
let _restoreTimer = null
|
|
|
|
// 외벽선/배치면 등 그리기 진행 중에는 undo/redo/snapshot 을 차단한다.
|
|
// (확정 시점부터 history 가 동작해야 하므로)
|
|
let _isDrawing = false
|
|
|
|
/**
|
|
* 외부 훅/모듈에서 현재 undo/redo 복원 중인지 확인할 때 사용
|
|
*/
|
|
export function isUndoRedoRestoring() {
|
|
return _isRestoring
|
|
}
|
|
|
|
/**
|
|
* 외벽선/배치면 등 그리기 진행 상태를 외부에서 설정한다.
|
|
* 그리기가 시작될 때 true, 확정 또는 취소 시 false 로 설정한다.
|
|
*/
|
|
export function setUndoRedoDrawing(value) {
|
|
_isDrawing = !!value
|
|
}
|
|
|
|
/**
|
|
* 외부 훅/모듈에서 현재 그리기 진행 중인지 확인할 때 사용
|
|
*/
|
|
export function isUndoRedoDrawing() {
|
|
return _isDrawing
|
|
}
|
|
|
|
// loadFromJSON 공통 처리 - 이벤트 일시 중단 후 복원
|
|
function _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom, setModuleStatisticsData) {
|
|
// 진행 중인 복원 타이머가 있으면 취소
|
|
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.discardActiveObject()
|
|
canvas.getObjects().forEach((obj) => {
|
|
if (obj.type === 'QPolygon' && obj.name === POLYGON_TYPE.MODULE) {
|
|
obj.set({ stroke: 'black', strokeWidth: 0.3 })
|
|
}
|
|
})
|
|
|
|
canvas.renderAll()
|
|
restoreAtomSnapshot(snapshot.atomSnapshot)
|
|
|
|
// [undo 집계 fix] 캔버스/surface.modules 복원 후 패널 배치 집계를 재계산한다.
|
|
// 집계 atom(moduleStatisticsState)은 스냅샷 대상이 아니라, 복원 시점에 다시 계산해야 한다.
|
|
if (typeof setModuleStatisticsData === 'function') setModuleStatisticsData()
|
|
|
|
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 [isDrawing, setIsDrawing] = useRecoilState(undoRedoDrawingState)
|
|
|
|
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)
|
|
const { setModuleStatisticsData } = useCircuitTrestle()
|
|
|
|
// 최신 스택 값을 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
|
|
|
|
// 먼저 오브젝트 간 참조 관계 재구축 (roof.innerLines 등)
|
|
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 의 내부 lines 배열은 plain object 로만 복원되므로
|
|
// initLines() 로 실제 QLine 인스턴스를 재구축한다.
|
|
// (lengthText 는 JSON 에서 이미 복원되므로 재생성하지 않는다.
|
|
// 과거에는 addLengthText 도 함께 호출했으나, undo/redo 반복 시 숨겨진 모서리 라인의
|
|
// 텍스트가 누적 생성되어 "707" 같은 잔존 텍스트가 화면에 나타나는 문제가 있었음)
|
|
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
|
|
|
|
// lengthText 인스턴스를 parentId 기준으로 한 번만 모아둔다 (texts 배열 복원에 재사용)
|
|
const lengthTextsByParent = new Map()
|
|
objects.forEach((obj) => {
|
|
if (obj.name === 'lengthText' && obj.parentId) {
|
|
const arr = lengthTextsByParent.get(obj.parentId) || []
|
|
arr.push(obj)
|
|
lengthTextsByParent.set(obj.parentId, arr)
|
|
}
|
|
})
|
|
|
|
const qPolygons = objects.filter(shouldRebuildText)
|
|
qPolygons.forEach((polygon) => {
|
|
try {
|
|
polygon.initLines()
|
|
// texts 배열 참조 복원 (재생성이 아닌 기존 lengthText 인스턴스 연결만)
|
|
// commonDeleteText 가 group._objects[i].texts / object.texts 를 통해 제거하므로,
|
|
// loadFromJSON 후 끊긴 참조를 다시 이어주지 않으면 삭제 시 lengthText 가 잔존한다.
|
|
if (polygon.id) {
|
|
polygon.texts = lengthTextsByParent.get(polygon.id) || []
|
|
}
|
|
} catch (e) {
|
|
/* skip */
|
|
}
|
|
})
|
|
|
|
// Group 내부 QPolygon(도머 등)도 동일하게 내부 라인 인스턴스만 재구축한다.
|
|
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?.()
|
|
if (child.id) {
|
|
child.texts = lengthTextsByParent.get(child.id) || []
|
|
}
|
|
} 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 && (_isDrawing || isDrawing)) 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, isDrawing, 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 (_isDrawing || isDrawing) 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, setModuleStatisticsData)
|
|
}, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups, setModuleStatisticsData])
|
|
|
|
const redo = useCallback(() => {
|
|
// 외벽선/배치면/지붕형상 수동설정 등 그리기 진행 중에는 redo 차단
|
|
if (_isDrawing || isDrawing) 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, setModuleStatisticsData)
|
|
}, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups, setModuleStatisticsData])
|
|
|
|
const clearStacks = useCallback(() => {
|
|
setUndoStack([])
|
|
setRedoStack([])
|
|
}, [])
|
|
|
|
/** undo 스택에서 마지막 스냅샷 1개만 제거 (취소 시 사용) */
|
|
const popLastSnapshot = useCallback(() => {
|
|
setUndoStack((prev) => (prev.length > 0 ? prev.slice(0, -1) : prev))
|
|
}, [])
|
|
|
|
/**
|
|
* 외벽선/배치면 등 그리기 진행 중에는 undo/redo 를 차단한다.
|
|
* 마운트 시 true, 언마운트 시 false 로 설정한다.
|
|
*/
|
|
const setDrawing = useCallback(
|
|
(value) => {
|
|
const v = !!value
|
|
setIsDrawing(v)
|
|
setUndoRedoDrawing(v)
|
|
},
|
|
[setIsDrawing],
|
|
)
|
|
|
|
return {
|
|
saveSnapshot,
|
|
undo,
|
|
redo,
|
|
clearStacks,
|
|
popLastSnapshot,
|
|
setDrawing,
|
|
canUndo: undoStack.length > 0 && !isDrawing,
|
|
canRedo: redoStack.length > 0 && !isDrawing,
|
|
}
|
|
}
|