diff --git a/src/components/floor-plan/CanvasMenu.jsx b/src/components/floor-plan/CanvasMenu.jsx
index be42c977..6ef889d1 100644
--- a/src/components/floor-plan/CanvasMenu.jsx
+++ b/src/components/floor-plan/CanvasMenu.jsx
@@ -52,6 +52,7 @@ import { QcastContext } from '@/app/QcastProvider'
import { useRoofFn } from '@/hooks/common/useRoofFn'
import { usePolygon } from '@/hooks/usePolygon'
import { useTrestle } from '@/hooks/module/useTrestle'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
export default function CanvasMenu(props) {
const [currentCanvasPlan, setCurrentCanvasPlan] = useRecoilState(currentCanvasPlanState)
@@ -73,6 +74,7 @@ export default function CanvasMenu(props) {
const { handleZoomClear, handleZoom } = useCanvasEvent()
const { setAllModuleSurfaceIsComplete, isAllComplete } = useTrestle()
const { handleMenu } = useMenu()
+ const { undo, redo, canUndo, canRedo } = useUndoRedo()
// const urlParams = useSearchParams()
const { handleEstimateSubmit, fetchSetting, estimateContextState, setEstimateContextState } = useEstimateController()
const estimateRecoilState = useRecoilValue(estimateState)
@@ -569,6 +571,30 @@ export default function CanvasMenu(props) {
{!['estimate', 'simulation'].some((num) => num === selectedMenu) && (
<>
+
+
+
+
{
{getMessage('plan.mode.vertical.horizontal')}
diff --git a/src/components/floor-plan/modal/flowDirection/FlowDirectionSetting.jsx b/src/components/floor-plan/modal/flowDirection/FlowDirectionSetting.jsx
index e9f57869..8c30afe1 100644
--- a/src/components/floor-plan/modal/flowDirection/FlowDirectionSetting.jsx
+++ b/src/components/floor-plan/modal/flowDirection/FlowDirectionSetting.jsx
@@ -9,6 +9,7 @@ import { canvasState } from '@/store/canvasAtom'
import { usePolygon } from '@/hooks/usePolygon'
import { useSurfaceShapeBatch } from '@/hooks/surface/useSurfaceShapeBatch'
import { useRoofFn } from '@/hooks/common/useRoofFn'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
const FLOW_DIRECTION_TYPE = {
EIGHT_AZIMUTH: 'eightAzimuth',
@@ -24,6 +25,7 @@ export default function FlowDirectionSetting(props) {
const { setPolygonLinesActualSize } = usePolygon()
const { changeSurfaceLineType } = useSurfaceShapeBatch({})
+ const { saveSnapshot } = useUndoRedo()
useEffect(() => {
return () => {
@@ -77,6 +79,7 @@ export default function FlowDirectionSetting(props) {
const [type, setType] = useState(target.surfaceCompassType ?? FLOW_DIRECTION_TYPE.EIGHT_AZIMUTH)
const changeSurfaceFlowDirection = (roof, direction, orientation) => {
+ saveSnapshot()
roof.set({
direction: direction,
surfaceCompass: orientation,
diff --git a/src/hooks/common/useCommonUtils.js b/src/hooks/common/useCommonUtils.js
index fc4c907b..db6f4b5b 100644
--- a/src/hooks/common/useCommonUtils.js
+++ b/src/hooks/common/useCommonUtils.js
@@ -14,6 +14,7 @@ import { useObjectBatch } from '@/hooks/object/useObjectBatch'
import { BATCH_TYPE } from '@/common/common'
import { useMouse } from '@/hooks/useMouse'
import { QPolygon } from '@/components/fabric/QPolygon'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
export function useCommonUtils() {
const canvas = useRecoilValue(canvasState)
@@ -28,6 +29,7 @@ export function useCommonUtils() {
const { drawDirectionArrow, addLengthText } = usePolygon()
const { applyDormers } = useObjectBatch({})
const { getIntersectMousePoint } = useMouse()
+ const { saveSnapshot } = useUndoRedo()
useEffect(() => {
commonTextMode()
@@ -834,6 +836,7 @@ export function useCommonUtils() {
}
const deleteObject = () => {
+ saveSnapshot()
const selectedObj = canvas?.getActiveObjects()
if (selectedObj) {
selectedObj.forEach((obj) => {
@@ -863,6 +866,7 @@ export function useCommonUtils() {
}
const copyObject = () => {
+ saveSnapshot()
const obj = canvas?.getActiveObject()
commonCopyObject(obj)
}
diff --git a/src/hooks/module/useModule.js b/src/hooks/module/useModule.js
index d4a0e7de..41f014f9 100644
--- a/src/hooks/module/useModule.js
+++ b/src/hooks/module/useModule.js
@@ -12,6 +12,7 @@ import { selectedModuleState } from '@/store/selectedModuleOptions'
import { useCircuitTrestle } from '../useCirCuitTrestle'
import { useTrestle } from './useTrestle'
import { useTurf } from '@/hooks/common/useTurf'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
export const MODULE_REMOVE_TYPE = {
LEFT: 'left',
@@ -44,6 +45,7 @@ export function useModule() {
const { setModuleStatisticsData, resetCircuits } = useCircuitTrestle()
const { clear: removeTrestleMaterials } = useTrestle()
const { checkModuleDisjointSurface } = useTurf()
+ const { saveSnapshot } = useUndoRedo()
// clone() 대신 직접 새 QPolygon을 생성하여 Maximum call stack 방지
const createModuleCopy = (module, newLeft, newTop, overrides = {}) => {
@@ -92,6 +94,7 @@ export function useModule() {
})
return
}
+ saveSnapshot()
// canvas.discardActiveObject() //선택해제
const isSetupModules = getOtherModules(selectedObj)
@@ -171,6 +174,7 @@ export function useModule() {
removeTrestleMaterials()
}
+ saveSnapshot()
const modules = type === 'row' ? getRowModules(activeModule) : getColumnModules(activeModule)
const otherModules = getOtherModules(modules)
const objects = getObjects()
@@ -221,6 +225,7 @@ export function useModule() {
}
const moduleMoveAll = (length, direction, surfaceArray) => {
+ saveSnapshot()
surfaceArray.forEach((surface) => {
const modules = canvas
.getObjects()
@@ -471,6 +476,7 @@ export function useModule() {
if (surfaces.some((surface) => surface.isComplete)) {
removeTrestleMaterials()
}
+ saveSnapshot()
canvas.discardActiveObject()
moduleSetupSurface.set({ modules: otherModules })
canvas.remove(...columnModules)
@@ -605,6 +611,7 @@ export function useModule() {
if (surfaces.some((surface) => surface.isComplete)) {
removeTrestleMaterials()
}
+ saveSnapshot()
canvas.discardActiveObject()
moduleSetupSurface.set({ modules: otherModules })
canvas.remove(...rowModules)
@@ -969,6 +976,7 @@ export function useModule() {
return
}
+ saveSnapshot()
surface.set({ modules: modules })
canvas.remove(activeModule)
canvas.renderAll()
diff --git a/src/hooks/module/useModuleBasicSetting.js b/src/hooks/module/useModuleBasicSetting.js
index c48c2d21..9adb82be 100644
--- a/src/hooks/module/useModuleBasicSetting.js
+++ b/src/hooks/module/useModuleBasicSetting.js
@@ -33,6 +33,7 @@ import { isObjectNotEmpty } from '@/util/common-utils'
import { useCircuitTrestle } from '@/hooks/useCirCuitTrestle'
import { useMode } from '@/hooks/useMode'
import { useTurf } from '@/hooks/common/useTurf'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
export function useModuleBasicSetting(tabNum) {
const canvas = useRecoilValue(canvasState)
@@ -62,6 +63,7 @@ export function useModuleBasicSetting(tabNum) {
const setModuleRowColArray = useSetRecoilState(moduleRowColArrayState)
const { checkModuleDisjointSurface } = useTurf()
+ const { saveSnapshot } = useUndoRedo()
useEffect(() => {
initEvent()
@@ -104,6 +106,7 @@ export function useModuleBasicSetting(tabNum) {
}
} else {
//육지붕 일경우에는 바로 배치면 설치LL
+ saveSnapshot()
canvas
.getObjects()
.filter((roof) => roof.name === POLYGON_TYPE.ROOF)
@@ -117,6 +120,7 @@ export function useModuleBasicSetting(tabNum) {
//가대 상세 데이터 들어오면 실행
useEffect(() => {
if (trestleDetailList.length > 0) {
+ saveSnapshot()
let rowColArray = []
//지붕을 가져옴
canvas
@@ -570,7 +574,7 @@ export function useModuleBasicSetting(tabNum) {
*/
const manualModuleSetup = () => {
//레이아웃 수동설치 토글
- const moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) //모듈설치면를 가져옴
+ let moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) //모듈설치면를 가져옴
const isChidori = moduleSetupOption.isChidori
if (isManualModuleSetup) {
@@ -626,6 +630,9 @@ export function useModuleBasicSetting(tabNum) {
//마우스 이벤트 삭제 후 재추가
const mousePoint = canvas.getPointer(e.e)
+ // undo 후 캔버스 객체가 교체될 수 있으므로 매번 다시 가져옴
+ moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) || []
+
for (let i = 0; i < moduleSetupSurfaces.length; i++) {
//배치면이 여러개 일때 옮겨가면서 동작해야함
const moduleSetupSurface = moduleSetupSurfaces[i]
@@ -916,8 +923,10 @@ export function useModuleBasicSetting(tabNum) {
addCanvasMouseEventListener('mouse:up', (e) => {
let isIntersection = true
+ if (!trestlePolygon) return
+
// 혼합 설치 불가능한 모듈인지 확인 Y는 Y끼리 N은 N끼리만 설치 가능
- if (trestlePolygon.modules.length > 0) {
+ if (trestlePolygon.modules && trestlePolygon.modules.length > 0) {
//이미 설치된 모듈중에 한개만 가져옴
const mixAsgYn = trestlePolygon.modules[0].moduleInfo.mixAsgYn
//현재 체크된 모듈기준으로 혼합가능인지 확인 Y === Y, N === N 일때만 설치 가능
@@ -951,9 +960,10 @@ export function useModuleBasicSetting(tabNum) {
tempModule.set({ points: rectPoints })
const tempTurfModule = polygonToTurfPolygon(tempModule)
- //도머 객체를 가져옴
- if (batchObjects) {
- batchObjects.forEach((object) => {
+ //도머 객체를 가져옴 (undo 후 캔버스 객체가 교체될 수 있으므로 다시 가져옴)
+ const currentBatchObjects = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.OBJECT_SURFACE)
+ if (currentBatchObjects) {
+ currentBatchObjects.forEach((object) => {
let dormerTurfPolygon = polygonToTurfPolygon(object, true)
const intersection = turf.intersect(turf.featureCollection([dormerTurfPolygon, tempTurfModule])) //겹치는지 확인
//겹치면 안됨
@@ -989,6 +999,7 @@ export function useModuleBasicSetting(tabNum) {
toFixed: 2,
})
+ saveSnapshot()
canvas?.add(manualModule)
canvas.bringToFront(manualModule)
manualDrawModules.push(manualModule)
@@ -1026,7 +1037,7 @@ export function useModuleBasicSetting(tabNum) {
//단 열수 지정배치
const manualModuleLayoutSetup = (layoutSetupRef) => {
- const moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) //모듈설치면를 가져옴
+ let moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) //모듈설치면를 가져옴
const isChidori = moduleSetupOption.isChidori //치도리 여부
const setupLocation = moduleSetupOption.setupLocation //모듈 설치 위치 처마, 용마루
@@ -1124,6 +1135,9 @@ export function useModuleBasicSetting(tabNum) {
//마우스 이벤트 삭제 후 재추가
const mousePoint = canvas.getPointer(e.e)
+ // undo 후 캔버스 객체가 교체될 수 있으므로 매번 다시 가져옴
+ moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) || []
+
for (let i = 0; i < moduleSetupSurfaces.length; i++) {
//배치면이 여러개 일때 옮겨가면서 동작해야함
const moduleSetupSurface = moduleSetupSurfaces[i]
@@ -1401,7 +1415,9 @@ export function useModuleBasicSetting(tabNum) {
})
addCanvasMouseEventListener('mouse:up', (e) => {
- if (trestlePolygon.modules.length > 0) {
+ if (!trestlePolygon) return
+
+ if (trestlePolygon.modules && trestlePolygon.modules.length > 0) {
//이미 설치된 모듈중에 한개만 가져옴
const mixAsgYn = trestlePolygon.modules[0].moduleInfo.mixAsgYn
//현재 체크된 모듈기준으로 혼합가능인지 확인 Y === Y, N === N 일때만 설치 가능
@@ -1455,6 +1471,7 @@ export function useModuleBasicSetting(tabNum) {
}
if (tempModule) {
+ saveSnapshot()
let startX, startY
let installedLastHeightCoord = 0 //마지막으로 설치된 모듈의 좌표
let installedHeightModuleCount = 0
@@ -2913,6 +2930,7 @@ export function useModuleBasicSetting(tabNum) {
return orderA - orderB
})
+ saveSnapshot()
sortedModuleSetupSurfaces.forEach((moduleSetupSurface, index) => {
moduleSetupSurface.fire('mousedown')
const moduleSetupArray = []
@@ -3740,6 +3758,7 @@ export function useModuleBasicSetting(tabNum) {
canvas?.add(manualModule)
manualDrawModules.push(manualModule)
setModuleStatisticsData()
+ saveSnapshot()
} else {
swalFire({ text: getMessage('module.place.overlab'), icon: 'warning' })
}
@@ -3751,6 +3770,7 @@ export function useModuleBasicSetting(tabNum) {
}
// getModuleStatistics()
setModuleStatisticsData()
+ saveSnapshot()
} else {
if (moduleSetupSurfaces) {
//수동모드 해제시 모듈 설치면 선택 잠금
@@ -4059,6 +4079,7 @@ export function useModuleBasicSetting(tabNum) {
})
}
+ saveSnapshot()
moduleSetupSurfaces.forEach((moduleSetupSurface, index) => {
moduleSetupSurface.fire('mousedown')
const moduleSetupArray = []
diff --git a/src/hooks/object/useObjectBatch.js b/src/hooks/object/useObjectBatch.js
index 2af636a5..19f74556 100644
--- a/src/hooks/object/useObjectBatch.js
+++ b/src/hooks/object/useObjectBatch.js
@@ -25,6 +25,7 @@ import { usePopup } from '@/hooks/usePopup'
import { useMouse } from '@/hooks/useMouse'
import { useTurf } from '@/hooks/common/useTurf'
import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
export function useObjectBatch({ isHidden, setIsHidden }) {
const { getMessage } = useMessage()
@@ -41,6 +42,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
const { getIntersectMousePoint } = useMouse()
const { checkModuleDisjointSurface } = useTurf()
const currentObject = useRecoilValue(currentObjectState)
+ const { saveSnapshot } = useUndoRedo()
useEffect(() => {
if (canvas) {
@@ -99,6 +101,8 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
let rect, isDown, origX, origY
let selectedSurface
+
+ saveSnapshot()
//프리입력
if (selectedType === INPUT_TYPE.FREE) {
@@ -321,6 +325,8 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
return
}
+ saveSnapshot()
+
let theta = 0
let bottomLength = 0,
@@ -1296,6 +1302,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
}
const resizeObjectBatch = (side, target, width, height, popupId) => {
+ saveSnapshot()
const objectWidth = target.width
const objectHeight = target.height
@@ -1417,6 +1424,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
const parentSurface = canvas?.getObjects().filter((item) => item.name === POLYGON_TYPE.ROOF && item.id === obj.parentId)[0]
if (obj) {
+ saveSnapshot()
obj.set({
lockMovementX: false,
lockMovementY: false,
@@ -1505,6 +1513,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
const copyObjectBatch = () => {
const obj = canvas.getActiveObject()
if (obj) {
+ saveSnapshot()
let clonedObj = null
const parentSurface = canvas?.getObjects().filter((item) => item.name === POLYGON_TYPE.ROOF && item.id === obj.parentId)[0]
@@ -1583,6 +1592,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
}
const dormerOffset = (arrow1, arrow2, length1, length2) => {
+ saveSnapshot()
length1 = parseInt(length1) / 10
length2 = parseInt(length2) / 10
diff --git a/src/hooks/roofcover/useAuxiliaryDrawing.js b/src/hooks/roofcover/useAuxiliaryDrawing.js
index c84e00a7..1e11c935 100644
--- a/src/hooks/roofcover/useAuxiliaryDrawing.js
+++ b/src/hooks/roofcover/useAuxiliaryDrawing.js
@@ -13,6 +13,7 @@ import { usePopup } from '@/hooks/usePopup'
import { calculateAngle, isSamePoint } from '@/util/qpolygon-utils'
import { POLYGON_TYPE } from '@/common/common'
import { useMessage } from '../useMessage'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
import {
auxiliaryLineAngle1State,
auxiliaryLineAngle2State,
@@ -36,6 +37,7 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
const { getAdsorptionPoints } = useAdsorptionPoint()
const { closePopup } = usePopup()
const { getMessage } = useMessage()
+ const { saveSnapshot } = useUndoRedo()
const adsorptionRange = useRecoilValue(adsorptionRangeState)
@@ -652,6 +654,8 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
return
}
+ saveSnapshot()
+
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
/*const allLines = [...auxiliaryLines]
@@ -883,6 +887,18 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
return
}
+ // lineHistory가 비어있으면 캔버스에서 직접 미확정 보조선을 찾아 제거
+ if (lineHistory.current.length === 0) {
+ const unfixedLines = canvas.getObjects().filter((obj) => obj.name === 'auxiliaryLine' && !obj.isAuxiliaryFixed)
+ if (unfixedLines.length > 0) {
+ const lastLine = unfixedLines[unfixedLines.length - 1]
+ canvas.remove(lastLine)
+ canvas.renderAll()
+ }
+ addCanvasMouseEventListener('mouse:move', mouseMove)
+ return
+ }
+
const lastLine = lineHistory.current.pop()
if (lastLine) {
roofAdsorptionPoints.current = roofAdsorptionPoints.current.filter(
@@ -901,6 +917,7 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
if (!confirm(getMessage('want.to.complete.auxiliary.creation'))) {
return
}
+ saveSnapshot()
// cutAuxiliary()
diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js
index e6397091..e83d5060 100644
--- a/src/hooks/roofcover/useEavesGableEdit.js
+++ b/src/hooks/roofcover/useEavesGableEdit.js
@@ -11,6 +11,7 @@ import { useSwal } from '@/hooks/useSwal'
import { usePopup } from '@/hooks/usePopup'
import { getChonByDegree } from '@/util/canvas-util'
import { settingModalFirstOptionsState } from '@/store/settingAtom'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
// 처마.케라바 변경
export function useEavesGableEdit(id) {
@@ -29,6 +30,7 @@ export function useEavesGableEdit(id) {
const typeRef = useRef(TYPES.EAVES)
const { removeLine, addPitchTextsByOuterLines } = useLine()
const { swalFire } = useSwal()
+ const { saveSnapshot } = useUndoRedo()
const { drawRoofPolygon } = useMode()
const currentAngleType = useRecoilValue(currentAngleTypeSelector)
@@ -210,6 +212,7 @@ export function useEavesGableEdit(id) {
break
}
+ saveSnapshot()
target.set({
attributes,
})
diff --git a/src/hooks/roofcover/useMovementSetting.js b/src/hooks/roofcover/useMovementSetting.js
index 92f2e2c0..c5085c6c 100644
--- a/src/hooks/roofcover/useMovementSetting.js
+++ b/src/hooks/roofcover/useMovementSetting.js
@@ -10,6 +10,7 @@ import Big from 'big.js'
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { getSelectLinePosition } from '@/util/skeleton-utils'
import { useMouse } from '@/hooks/useMouse'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
//동선이동 형 올림 내림
export function useMovementSetting(id) {
@@ -31,6 +32,7 @@ export function useMovementSetting(id) {
const [type, setType] = useState(TYPE.FLOW_LINE)
const typeRef = useRef(type)
const { swalFire } = useSwal()
+ const { saveSnapshot } = useUndoRedo()
const FLOW_LINE_REF = {
POINTER_INPUT_REF: useRef(null),
@@ -464,6 +466,7 @@ export function useMovementSetting(id) {
canvas.renderAll()
}
+ saveSnapshot()
const target = selectedObject.current !== null ? selectedObject.current : CONFIRM_LINE_REF.current?.target
if (!target) return
diff --git a/src/hooks/roofcover/useOuterLineWall.js b/src/hooks/roofcover/useOuterLineWall.js
index dbd9f104..0c49fccb 100644
--- a/src/hooks/roofcover/useOuterLineWall.js
+++ b/src/hooks/roofcover/useOuterLineWall.js
@@ -33,6 +33,7 @@ import { usePopup } from '@/hooks/usePopup'
import Big from 'big.js'
import RoofShapeSetting from '@/components/floor-plan/modal/roofShape/RoofShapeSetting'
import { useObject } from '@/hooks/useObject'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
//외벽선 그리기
export function useOuterLineWall(id, propertiesId) {
@@ -58,6 +59,7 @@ export function useOuterLineWall(id, propertiesId) {
const { tempGridMode } = useTempGrid()
const { addPolygonByLines } = usePolygon()
const { handleSelectableObjects } = useObject()
+ const { saveSnapshot } = useUndoRedo()
const verticalHorizontalMode = useRecoilValue(verticalHorizontalModeState)
const adsorptionPointAddMode = useRecoilValue(adsorptionPointAddModeState)
@@ -149,6 +151,7 @@ export function useOuterLineWall(id, propertiesId) {
pointer = { x: Big(pointer.x).toNumber(), y: Big(pointer.y).toNumber() }
if (points.length === 0) {
+ saveSnapshot()
setPoints((prev) => [...prev, pointer])
} else {
const lastPoint = points[points.length - 1]
diff --git a/src/hooks/roofcover/usePropertiesSetting.js b/src/hooks/roofcover/usePropertiesSetting.js
index dd5ca91a..a5868f16 100644
--- a/src/hooks/roofcover/usePropertiesSetting.js
+++ b/src/hooks/roofcover/usePropertiesSetting.js
@@ -13,6 +13,7 @@ import RoofAllocationSetting from '@/components/floor-plan/modal/roofAllocation/
import { settingModalFirstOptionsState } from '@/store/settingAtom'
import { useSwal } from '@/hooks/useSwal'
import { useMessage } from '@/hooks/useMessage'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
// 외벽선 속성 설정
export function usePropertiesSetting(id) {
@@ -29,6 +30,7 @@ export function usePropertiesSetting(id) {
const { addPopup, closePopup } = usePopup()
const { swalFire } = useSwal()
const { getMessage } = useMessage()
+ const { saveSnapshot } = useUndoRedo()
useEffect(() => {
const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
@@ -67,6 +69,7 @@ export function usePropertiesSetting(id) {
if (!selectedLine) {
return
}
+ saveSnapshot()
selectedLine.set({
stroke: '#45CD7D',
strokeWidth: 4,
@@ -86,6 +89,7 @@ export function usePropertiesSetting(id) {
if (!selectedLine) {
return
}
+ saveSnapshot()
selectedLine.set({
stroke: '#3FBAE6',
strokeWidth: 4,
@@ -150,6 +154,7 @@ export function usePropertiesSetting(id) {
return
}
+ saveSnapshot()
lines.forEach((line) => {
line.set({
attributes: line.attributes ? line.attributes : { offset: 0, type: LINE_TYPE.WALLLINE.WALL },
diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js
index c31797cf..62084977 100644
--- a/src/hooks/roofcover/useRoofAllocationSetting.js
+++ b/src/hooks/roofcover/useRoofAllocationSetting.js
@@ -31,6 +31,7 @@ import { usePlan } from '@/hooks/usePlan'
import { roofsState } from '@/store/roofAtom'
import { useText } from '@/hooks/useText'
import { QLine } from '@/components/fabric/QLine'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
export function useRoofAllocationSetting(id) {
const canvas = useRecoilValue(canvasState)
@@ -65,6 +66,7 @@ export function useRoofAllocationSetting(id) {
const resetPoints = useResetRecoilState(outerLinePointsState)
const [corridorDimension, setCorridorDimension] = useRecoilState(corridorDimensionSelector)
const { changeCorridorDimensionText } = useText()
+ const { saveSnapshot } = useUndoRedo()
const outlineDisplay = useRecoilValue(outlineDisplaySelector)
useEffect(() => {
@@ -440,6 +442,7 @@ export function useRoofAllocationSetting(id) {
* 지붕면 할당
*/
const apply = () => {
+ saveSnapshot()
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.roofMaterial)
const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
roofBases.forEach((roofBase) => {
diff --git a/src/hooks/roofcover/useRoofShapePassivitySetting.js b/src/hooks/roofcover/useRoofShapePassivitySetting.js
index 6149ca39..b693fe41 100644
--- a/src/hooks/roofcover/useRoofShapePassivitySetting.js
+++ b/src/hooks/roofcover/useRoofShapePassivitySetting.js
@@ -10,6 +10,7 @@ import { usePolygon } from '@/hooks/usePolygon'
import { useSwal } from '@/hooks/useSwal'
import { usePopup } from '@/hooks/usePopup'
import { getChonByDegree } from '@/util/canvas-util'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
//지붕형상 수동 설정
export function useRoofShapePassivitySetting(id) {
@@ -39,6 +40,7 @@ export function useRoofShapePassivitySetting(id) {
const [isLoading, setIsLoading] = useState(false)
const { closePopup } = usePopup()
+ const { saveSnapshot } = useUndoRedo()
const buttons = [
{ id: 1, name: getMessage('eaves'), type: TYPES.EAVES },
{ id: 2, name: getMessage('gable'), type: TYPES.GABLE },
@@ -166,6 +168,7 @@ export function useRoofShapePassivitySetting(id) {
}
}
+ saveSnapshot()
currentLineRef.current.set({
attributes: { ...attributes, isFixed: true },
})
@@ -191,6 +194,7 @@ export function useRoofShapePassivitySetting(id) {
}
const handleSave = () => {
+ saveSnapshot()
isFix.current = true
handleLineToPolygon()
diff --git a/src/hooks/roofcover/useRoofShapeSetting.js b/src/hooks/roofcover/useRoofShapeSetting.js
index f5c84f5b..e7c8e260 100644
--- a/src/hooks/roofcover/useRoofShapeSetting.js
+++ b/src/hooks/roofcover/useRoofShapeSetting.js
@@ -20,6 +20,7 @@ import {
} from '@/store/settingAtom'
import { useAxios } from '@/hooks/useAxios'
import { globalLocaleStore } from '@/store/localeAtom'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
// 지붕형상 설정
export function useRoofShapeSetting(id) {
@@ -69,6 +70,7 @@ export function useRoofShapeSetting(id) {
const { post } = useAxios(globalLocaleState)
const { addLine, removeLine } = useLine()
+ const { saveSnapshot } = useUndoRedo()
useEffect(() => {
pitchRef.current = currentAngleType === ANGLE_TYPE.SLOPE ? pitch : getChonByDegree(pitch)
@@ -182,6 +184,7 @@ export function useRoofShapeSetting(id) {
]
const handleSave = () => {
+ saveSnapshot()
let outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
let direction
@@ -661,6 +664,7 @@ export function useRoofShapeSetting(id) {
return
}
+ saveSnapshot()
let attributes
switch (buttonAct) {
case 1: {
diff --git a/src/hooks/surface/usePlacementShapeDrawing.js b/src/hooks/surface/usePlacementShapeDrawing.js
index 374616be..0ba0af66 100644
--- a/src/hooks/surface/usePlacementShapeDrawing.js
+++ b/src/hooks/surface/usePlacementShapeDrawing.js
@@ -26,6 +26,7 @@ import { useSurfaceShapeBatch } from './useSurfaceShapeBatch'
import { roofDisplaySelector } from '@/store/settingAtom'
import { useRoofFn } from '@/hooks/common/useRoofFn'
import { useObject } from '@/hooks/useObject'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
// 배치면 그리기
export function usePlacementShapeDrawing(id) {
@@ -74,6 +75,7 @@ export function usePlacementShapeDrawing(id) {
const isFix = useRef(false)
const { closePopup, addPopup } = usePopup()
+ const { saveSnapshot } = useUndoRedo()
const globalPitch = useRecoilValue(globalPitchState)
@@ -130,6 +132,7 @@ export function usePlacementShapeDrawing(id) {
let pointer = getIntersectMousePoint(e)
if (points.length === 0) {
+ saveSnapshot()
setPoints((prev) => [...prev, pointer])
} else {
const lastPoint = points[points.length - 1]
@@ -142,9 +145,17 @@ export function usePlacementShapeDrawing(id) {
const slope = Math.abs(vector.y / vector.x)
if (slope >= 1) {
- newPoint = { x: lastPoint.x, y: pointer.y }
+ // 기울기가 1 이상이면 수직으로 제한
+ newPoint = {
+ x: lastPoint.x,
+ y: pointer.y,
+ }
} else {
- newPoint = { x: pointer.x, y: lastPoint.y }
+ // 기울기가 1 미만이면 수평으로 제한
+ newPoint = {
+ x: pointer.x,
+ y: lastPoint.y,
+ }
}
}
setPoints((prev) => [...prev, newPoint])
@@ -302,9 +313,9 @@ export function usePlacementShapeDrawing(id) {
// 기존 도형의 변과 일치하는 경우 planeSize를 상속하는 함수
const inheritPlaneSizeFromExistingShapes = (newPolygon) => {
const tolerance = 2
- const existingPolygons = canvas.getObjects().filter(
- (obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.id !== newPolygon.id && obj.lines,
- )
+ const existingPolygons = canvas
+ .getObjects()
+ .filter((obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.id !== newPolygon.id && obj.lines)
if (existingPolygons.length === 0) return
@@ -330,15 +341,9 @@ export function usePlacementShapeDrawing(id) {
// 1순위: 양 끝점이 정확히 일치
const forwardMatch =
- Math.abs(x1 - ex1) < tolerance &&
- Math.abs(y1 - ey1) < tolerance &&
- Math.abs(x2 - ex2) < tolerance &&
- Math.abs(y2 - ey2) < tolerance
+ Math.abs(x1 - ex1) < tolerance && Math.abs(y1 - ey1) < tolerance && Math.abs(x2 - ex2) < tolerance && Math.abs(y2 - ey2) < tolerance
const reverseMatch =
- Math.abs(x1 - ex2) < tolerance &&
- Math.abs(y1 - ey2) < tolerance &&
- Math.abs(x2 - ex1) < tolerance &&
- Math.abs(y2 - ey1) < tolerance
+ Math.abs(x1 - ex2) < tolerance && Math.abs(y1 - ey2) < tolerance && Math.abs(x2 - ex1) < tolerance && Math.abs(y2 - ey1) < tolerance
if (forwardMatch || reverseMatch) {
line.attributes = { ...line.attributes, planeSize: edge.attributes.planeSize }
@@ -423,9 +428,7 @@ export function usePlacementShapeDrawing(id) {
// 기존 도형에서 매칭되는 변의 planeSize를 찾는 헬퍼 함수
const findMatchingEdgePlaneSize = (x1, y1, x2, y2) => {
const tolerance = 2
- const existingPolygons = canvas.getObjects().filter(
- (obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.lines,
- )
+ const existingPolygons = canvas.getObjects().filter((obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.lines)
const isHorizontal = Math.abs(y1 - y2) < tolerance
const isVertical = Math.abs(x1 - x2) < tolerance
diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js
index 27a6f737..2283bcb5 100644
--- a/src/hooks/surface/useSurfaceShapeBatch.js
+++ b/src/hooks/surface/useSurfaceShapeBatch.js
@@ -30,6 +30,7 @@ import { useText } from '@/hooks/useText'
import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting'
import { v4 as uuidv4 } from 'uuid'
import { useState } from 'react'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
const { getMessage } = useMessage()
@@ -52,6 +53,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
const { fetchSettings } = useCanvasSetting(false)
const [currentObject, setCurrentObject] = useRecoilState(currentObjectState)
const [popupId, setPopupId] = useState(uuidv4())
+ const { saveSnapshot } = useUndoRedo()
const applySurfaceShape = (surfaceRefs, selectedType, id) => {
let length1, length2, length3, length4, length5
@@ -857,6 +859,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
}
const moveSurfaceShapeBatch = () => {
+ saveSnapshot()
const roof = canvas.getActiveObject()
if (roof) {
@@ -937,6 +940,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
const resizeSurfaceShapeBatch = (side, target, width, height) => {
if (!target || target.type !== 'QPolygon') return
+ saveSnapshot()
width = width / 10
height = height / 10
@@ -1454,6 +1458,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
const rotateSurfaceShapeBatch = () => {
if (currentObject) {
+ saveSnapshot()
// 관련 객체들 찾기
// arrow는 제거
const arrow = canvas.getObjects().find((obj) => obj.parentId === currentObject.id && obj.name === 'arrow')
diff --git a/src/hooks/useCanvasEvent.js b/src/hooks/useCanvasEvent.js
index 7808bb5f..7925ca12 100644
--- a/src/hooks/useCanvasEvent.js
+++ b/src/hooks/useCanvasEvent.js
@@ -7,6 +7,7 @@ import { fontSelector } from '@/store/fontAtom'
import { MENU, POLYGON_TYPE } from '@/common/common'
import { useText } from '@/hooks/useText'
import { usePolygon } from '@/hooks/usePolygon'
+import { useUndoRedo } from '@/hooks/useUndoRedo'
// 캔버스에 필요한 이벤트
export function useCanvasEvent() {
@@ -19,6 +20,7 @@ export function useCanvasEvent() {
const currentMenu = useRecoilValue(currentMenuState)
const { changeCorridorDimensionText } = useText()
const { setPolygonLinesActualSize } = usePolygon()
+ const { undo, redo } = useUndoRedo()
useEffect(() => {
canvas?.setZoom(canvasZoom / 100)
@@ -28,6 +30,13 @@ export function useCanvasEvent() {
attachDefaultEventOnCanvas()
}, [currentMenu])
+ // 키보드 이벤트 등록 (Cmd+Z / Ctrl+Z: undo, Shift+Cmd+Z / Shift+Ctrl+Z: redo)
+ useEffect(() => {
+ if (!canvas) return
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [canvas, undo, redo])
+
// 기본적인 이벤트 필요시 추가
const attachDefaultEventOnCanvas = () => {
removeEventOnCanvas()
@@ -296,12 +305,14 @@ export function useCanvasEvent() {
break
case 'z':
- if (!e.ctrlKey) {
+ if (!e.ctrlKey && !e.metaKey) {
return
}
-
- console.log('뒤로가기')
-
+ if (e.shiftKey) {
+ redo()
+ } else {
+ undo()
+ }
break
default:
@@ -381,20 +392,26 @@ export function useCanvasEvent() {
/**
* 선택한 도형을 삭제한다.
*/
+ let _isDeleting = false
const handleDelete = () => {
+ if (_isDeleting) return
const targets = canvas?.getActiveObjects()
- if (targets?.length === 0) {
- alert('삭제할 대상을 선택해주세요.')
+ if (!targets || targets.length === 0) {
return
}
+ _isDeleting = true
if (!confirm('정말로 삭제하시겠습니까?')) {
+ _isDeleting = false
return
}
- targets?.forEach((target) => {
+ targets.forEach((target) => {
canvas?.remove(target)
})
+ canvas?.discardActiveObject()
+ canvas?.renderAll()
+ _isDeleting = false
}
const handleZoom = (isZoom) => {
diff --git a/src/hooks/useUndoRedo.js b/src/hooks/useUndoRedo.js
new file mode 100644
index 00000000..ce36d6de
--- /dev/null
+++ b/src/hooks/useUndoRedo.js
@@ -0,0 +1,369 @@
+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 = 20
+
+// 모든 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
+ closeAllPopups()
+
+ const currentCanvasJSON = getCanvasSnapshot()
+ const currentAtomSnapshot = getAtomSnapshot()
+ const currentSnapshot = {
+ canvasJSON: currentCanvasJSON,
+ atomSnapshot: currentAtomSnapshot,
+ }
+
+ const newUndoStack = [...currentUndoStack]
+ const snapshot = newUndoStack.pop()
+
+ setUndoStack(newUndoStack)
+ setRedoStack((prev) => [...prev, currentSnapshot])
+
+ _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom)
+ }, [canvas, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups])
+
+ const redo = useCallback(() => {
+ const currentRedoStack = redoStackRef.current
+ if (!canvas || currentRedoStack.length === 0 || _isRestoring) return
+
+ _isRestoring = true
+ closeAllPopups()
+
+ const currentCanvasJSON = getCanvasSnapshot()
+ const currentAtomSnapshot = getAtomSnapshot()
+ const currentSnapshot = {
+ canvasJSON: currentCanvasJSON,
+ atomSnapshot: currentAtomSnapshot,
+ }
+
+ const newRedoStack = [...currentRedoStack]
+ const snapshot = newRedoStack.pop()
+
+ setRedoStack(newRedoStack)
+ setUndoStack((prev) => [...prev, currentSnapshot])
+
+ _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom)
+ }, [canvas, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups])
+
+ return {
+ saveSnapshot,
+ undo,
+ redo,
+ canUndo: undoStack.length > 0,
+ canRedo: redoStack.length > 0,
+ }
+}
diff --git a/src/store/canvasAtom.js b/src/store/canvasAtom.js
index c8bb1ad0..c87db0d1 100644
--- a/src/store/canvasAtom.js
+++ b/src/store/canvasAtom.js
@@ -408,3 +408,16 @@ export const moduleRowColArrayState = atom({
default: [],
dangerouslyAllowMutability: true,
})
+
+// Undo/Redo 히스토리 스택
+export const undoStackState = atom({
+ key: 'undoStackState',
+ default: [],
+ dangerouslyAllowMutability: true,
+})
+
+export const redoStackState = atom({
+ key: 'redoStackState',
+ default: [],
+ dangerouslyAllowMutability: true,
+})
diff --git a/src/styles/_contents.scss b/src/styles/_contents.scss
index 58f9a3ca..fd70eb5e 100644
--- a/src/styles/_contents.scss
+++ b/src/styles/_contents.scss
@@ -152,6 +152,30 @@
}
}
}
+ .undo-redo-wrap{
+ display: flex;
+ gap: 2px;
+ margin-right: 5px;
+ button{
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 2px;
+ background: #373737;
+ color: #fff;
+ cursor: pointer;
+ transition: all .17s ease-in-out;
+ &:hover:not(.disabled){
+ background: #4B4B4B;
+ }
+ &.disabled{
+ opacity: 0.3;
+ cursor: default;
+ }
+ }
+ }
.vertical-horizontal{
display: flex;
min-width: 170px;
@@ -1519,6 +1543,9 @@
.btn-from{
gap: 3px;
}
+ .undo-redo-wrap{
+ margin-right: 3px;
+ }
.vertical-horizontal{
margin-right: 3px;
min-width: 150px;
diff --git a/src/util/canvas-util.js b/src/util/canvas-util.js
index 5c51560b..7d364f4f 100644
--- a/src/util/canvas-util.js
+++ b/src/util/canvas-util.js
@@ -1071,7 +1071,9 @@ export function findAndRemoveClosestPoint(targetPoint, points) {
export function polygonToTurfPolygon(object, current = false) {
let coordinates
coordinates = object.points.map((point) => [point.x, point.y])
- if (current) coordinates = object.getCurrentPoints().map((point) => [point.x, point.y])
+ if (current && typeof object.getCurrentPoints === 'function') {
+ coordinates = object.getCurrentPoints().map((point) => [point.x, point.y])
+ }
coordinates.push(coordinates[0])
return turf.polygon(
[coordinates],