redo undo 작업중

This commit is contained in:
hyojun.choi 2026-04-15 17:37:49 +09:00
parent d4766e30a3
commit 77e3f688c6
18 changed files with 152 additions and 29 deletions

View File

@ -9,6 +9,7 @@ import { useAuxiliaryDrawing } from '@/hooks/roofcover/useAuxiliaryDrawing'
import { useSwal } from '@/hooks/useSwal'
import { normalizeDigits } from '@/util/input-utils'
import { CalculatorInput } from '@/components/common/input/CalcInput'
import { useUndoRedo } from '@/hooks/useUndoRedo'
export default function AuxiliaryEdit(props) {
const contextPopupPosition = useRecoilValue(contextPopupPositionState)
@ -22,6 +23,7 @@ export default function AuxiliaryEdit(props) {
const [arrow2, setArrow2] = useState(null)
const currentObject = useRecoilValue(currentObjectState)
const { swalFire } = useSwal()
const { saveSnapshot } = useUndoRedo()
const handleSave = () => {
if ((!arrow1 && !arrow2) || (+verticalSize === 0 && +horizonSize === 0)) {
swalFire({ title: getMessage('length.direction.is.required'), type: 'alert' })
@ -38,6 +40,7 @@ export default function AuxiliaryEdit(props) {
return
}
saveSnapshot()
if (type === 'copy') {
if (currentObject) {
copy(
@ -87,14 +90,12 @@ export default function AuxiliaryEdit(props) {
className={`direction up ${arrow1 === '↑' ? 'act' : ''}`}
onClick={() => {
setArrow1('↑')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
}}
></button>
<button
className={`direction down ${arrow1 === '↓' ? 'act' : ''}`}
onClick={() => {
setArrow1('↓')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
}}
></button>
</div>
@ -121,14 +122,12 @@ export default function AuxiliaryEdit(props) {
className={`direction left ${arrow2 === '←' ? 'act' : ''}`}
onClick={() => {
setArrow2('←')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }))
}}
></button>
<button
className={`direction right ${arrow2 === '→' ? 'act' : ''}`}
onClick={() => {
setArrow2('→')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
}}
></button>
</div>

View File

@ -8,6 +8,7 @@ import { useEffect, useState } from 'react'
import Big from 'big.js'
import { calcLineActualSize, calcLinePlaneSize } from '@/util/qpolygon-utils'
import { normalizeDigits } from '@/util/input-utils'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { CalculatorInput } from '@/components/common/input/CalcInput'
export default function AuxiliarySize(props) {
@ -53,7 +54,9 @@ export default function AuxiliarySize(props) {
}
}
const { saveSnapshot } = useUndoRedo()
const handleSave = () => {
saveSnapshot()
const { x1, y1, x2, y2 } = currentObject
// planeSize actualSize .

View File

@ -8,6 +8,7 @@ import { canvasState } from '@/store/canvasAtom'
import { POLYGON_TYPE } from '@/common/common'
import { useModule } from '@/hooks/module/useModule'
import { useSwal } from '@/hooks/useSwal'
import { useUndoRedo } from '@/hooks/useUndoRedo'
export const PANEL_EDIT_TYPE = {
MOVE: 'move',
@ -30,6 +31,7 @@ export default function PanelEdit(props) {
const canvas = useRecoilValue(canvasState)
const { swalFire } = useSwal()
const { moduleMove, moduleCopy, moduleMultiMove, moduleMultiCopy, moduleMoveAll, moduleCopyAll } = useModule()
const { saveSnapshot } = useUndoRedo()
useEffect(() => {
if (!canvas) {
const isSetupModules = canvas.getObjects().filter((obj) => obj.name === 'module') // selectedObj
@ -52,6 +54,7 @@ export default function PanelEdit(props) {
//
const handleApply = () => {
saveSnapshot()
if (length <= 0) {
swalFire({
title: getMessage('common.message.please.input.over', [1]),

View File

@ -1,3 +1,4 @@
import { useUndoRedo } from '@/hooks/useUndoRedo'
import WithDraggable from '@/components/common/draggable/WithDraggable'
import { useRecoilValue } from 'recoil'
import { contextPopupPositionState } from '@/store/popupAtom'
@ -14,7 +15,9 @@ export default function ColumnInsert(props) {
const [selectedType, setSelectedType] = useState(MODULE_INSERT_TYPE.LEFT)
const { getMessage } = useMessage()
const { moduleColumnInsert } = useModule()
const { saveSnapshot } = useUndoRedo()
const handleApply = () => {
saveSnapshot()
moduleColumnInsert(selectedType)
closePopup(id)
}

View File

@ -1,3 +1,4 @@
import { useUndoRedo } from '@/hooks/useUndoRedo'
import WithDraggable from '@/components/common/draggable/WithDraggable'
import { useRecoilValue } from 'recoil'
import { contextPopupPositionState } from '@/store/popupAtom'
@ -14,6 +15,7 @@ export default function ColumnRemove(props) {
const [selectedType, setSelectedType] = useState(MODULE_REMOVE_TYPE.LEFT)
const { getMessage } = useMessage()
const { moduleColumnRemove } = useModule()
const { saveSnapshot } = useUndoRedo()
const types = [
{ name: getMessage('modal.panel.column.remove.type.left'), value: MODULE_REMOVE_TYPE.LEFT },
{ name: getMessage('modal.panel.column.remove.type.right'), value: MODULE_REMOVE_TYPE.RIGHT },
@ -21,6 +23,7 @@ export default function ColumnRemove(props) {
{ name: getMessage('modal.panel.column.remove.type.none'), value: MODULE_REMOVE_TYPE.NONE },
]
const handleApply = () => {
saveSnapshot()
moduleColumnRemove(selectedType)
closePopup(id)
}

View File

@ -1,3 +1,4 @@
import { useUndoRedo } from '@/hooks/useUndoRedo'
import WithDraggable from '@/components/common/draggable/WithDraggable'
import Image from 'next/image'
import { useState } from 'react'
@ -14,7 +15,9 @@ export default function RowInsert(props) {
const [selectedType, setSelectedType] = useState(MODULE_INSERT_TYPE.TOP)
const { getMessage } = useMessage()
const { muduleRowInsert } = useModule()
const { saveSnapshot } = useUndoRedo()
const handleApply = () => {
saveSnapshot()
muduleRowInsert(selectedType)
closePopup(id)
}

View File

@ -1,3 +1,4 @@
import { useUndoRedo } from '@/hooks/useUndoRedo'
import WithDraggable from '@/components/common/draggable/WithDraggable'
import Image from 'next/image'
import { useState } from 'react'
@ -14,6 +15,7 @@ export default function RowRemove(props) {
const [selectedType, setSelectedType] = useState(MODULE_REMOVE_TYPE.TOP)
const { getMessage } = useMessage()
const { moduleRowRemove } = useModule()
const { saveSnapshot } = useUndoRedo()
const types = [
{ name: getMessage('modal.row.remove.type.up'), value: MODULE_REMOVE_TYPE.TOP },
{ name: getMessage('modal.row.remove.type.down'), value: MODULE_REMOVE_TYPE.BOTTOM },
@ -21,6 +23,7 @@ export default function RowRemove(props) {
{ name: getMessage('modal.row.remove.type.none'), value: MODULE_REMOVE_TYPE.NONE },
]
const handleApply = () => {
saveSnapshot()
// if (apply) apply()
moduleRowRemove(selectedType)
closePopup(id)

View File

@ -21,6 +21,7 @@ import { MENU } from '@/common/common'
import { useTrestle } from '@/hooks/module/useTrestle'
import { useOrientation } from '@/hooks/module/useOrientation'
import { corridorDimensionSelector } from '@/store/settingAtom'
import { useUndoRedo } from '@/hooks/useUndoRedo'
/**
* 메뉴 처리
@ -36,6 +37,7 @@ export default function useMenu() {
const { clear: trestleClear, setAllModuleSurfaceIsComplete } = useTrestle()
const { nextStep } = useOrientation()
const [corridorDimension, setCorridorDimension] = useRecoilState(corridorDimensionSelector)
const { saveSnapshot } = useUndoRedo()
const handleMenu = (type) => {
closeAll()
if (type === 'outline') {

View File

@ -8,6 +8,7 @@ import { useLine } from '@/hooks/useLine'
import { outerLinePointsState } from '@/store/outerLineAtom'
import { usePolygon } from '@/hooks/usePolygon'
import { useText } from '@/hooks/useText'
import { useUndoRedo } from '@/hooks/useUndoRedo'
const ROOF_COLOR = {
0: 'rgb(199,240,213)',
@ -26,6 +27,7 @@ export function useRoofFn() {
const { setPolygonLinesActualSize } = usePolygon()
const { changeCorridorDimensionText } = useText()
const [outerLinePoints, setOuterLinePoints] = useRecoilState(outerLinePointsState)
const { saveSnapshot } = useUndoRedo()
//면형상 선택 클릭시 지붕 패턴 입히기
function setSurfaceShapePattern(polygon, mode = 'onlyBorder', trestleMode = false, roofMaterial, isForceChange = false, isDisplay = false) {
@ -236,6 +238,9 @@ export function useRoofFn() {
})
canvas.on('mouse:down', (event) => {
// checkPolygon 을 캔버스에서 제거한 뒤 스냅샷을 찍어야 빨간 사각형이 포함되지 않음
canvas.remove(checkPolygon)
saveSnapshot()
let mousePos = canvas.getPointer(event.e)
mousePos = { x: Math.round(mousePos.x), y: Math.round(mousePos.y) }

View File

@ -1,10 +1,11 @@
import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, currentObjectState, pitchTextSelector } from '@/store/canvasAtom'
import { useRecoilValue } from 'recoil'
import { useRecoilValue, useSetRecoilState } from 'recoil'
import { currentMenuState } from '@/store/canvasAtom'
import { useEffect, useRef, useState } from 'react'
import { useLine } from '@/hooks/useLine'
import { useMessage } from '@/hooks/useMessage'
import { useEvent } from '@/hooks/useEvent'
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import { LINE_TYPE, MENU, POLYGON_TYPE } from '@/common/common'
import { useMode } from '@/hooks/useMode'
import { usePolygon } from '@/hooks/usePolygon'
import { useSwal } from '@/hooks/useSwal'
@ -20,6 +21,7 @@ export function useRoofShapePassivitySetting(id) {
SHED: 'shed',
}
const canvas = useRecoilValue(canvasState)
const setCurrentMenu = useSetRecoilState(currentMenuState)
const currentAngleType = useRecoilValue(currentAngleTypeSelector)
const pitchText = useRecoilValue(pitchTextSelector)
const { getMessage } = useMessage()
@ -194,10 +196,11 @@ export function useRoofShapePassivitySetting(id) {
}
const handleSave = () => {
saveSnapshot()
saveSnapshot(null, { force: true })
isFix.current = true
handleLineToPolygon()
// 수동설정 완료 → 지붕면 할당 메뉴로 전환 (undo 가드 해제)
setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_ALLOC)
closePopup(id)
}

View File

@ -7,7 +7,9 @@ import { useLine } from '@/hooks/useLine'
import { useSwal } from '@/hooks/useSwal'
import { usePopup } from '@/hooks/usePopup'
import Big from 'big.js'
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { outerLineFixState } from '@/store/outerLineAtom'
import { useUndoRedo } from '@/hooks/useUndoRedo'
// 외벽선 편집 및 오프셋
export function useWallLineOffsetSetting(id) {
@ -17,7 +19,7 @@ export function useWallLineOffsetSetting(id) {
const { closePopup } = usePopup()
const { swalFire } = useSwal()
const { addCanvasMouseEventListener, initEvent } = useEvent()
// const { addCanvasMouseEventListener, initEvent } = useContext(EventContext)
const { saveSnapshot } = useUndoRedo()
const wallLineEditRef = useRef(null)
const length1Ref = useRef(null)
const length2Ref = useRef(null)
@ -46,7 +48,13 @@ export function useWallLineOffsetSetting(id) {
direction: direction,
})
line.attributes = { ...currentWallLineRef.current.attributes }
// 분할된 라인의 실제 길이로 planeSize/actualSize 재계산
const newPlaneSize = calcLinePlaneSize({ x1: point1.x, y1: point1.y, x2: point2.x, y2: point2.y })
line.attributes = {
...currentWallLineRef.current.attributes,
planeSize: newPlaneSize,
actualSize: newPlaneSize,
}
}
const TYPES = {
@ -197,9 +205,9 @@ export function useWallLineOffsetSetting(id) {
const handleSave = () => {
if (currentWallLineRef.current === null) {
// alert('보조선을 먼저 선택하세요')
return
}
saveSnapshot()
switch (type) {
case TYPES.WALL_LINE_EDIT:
handleWallLineEditSave()

View File

@ -27,6 +27,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) {
@ -48,6 +49,7 @@ export function usePlacementShapeDrawing(id) {
const { setSurfaceShapePattern } = useRoofFn()
const { changeSurfaceLineType } = useSurfaceShapeBatch({})
const { handleSelectableObjects } = useObject()
const { saveSnapshot, popLastSnapshot } = useUndoRedo()
const verticalHorizontalMode = useRecoilValue(verticalHorizontalModeState)
const adsorptionRange = useRecoilValue(adsorptionRangeState)
@ -92,13 +94,19 @@ export function usePlacementShapeDrawing(id) {
}, [verticalHorizontalMode, points, adsorptionRange, adsorptionPointMode])
useEffect(() => {
// 배치면 그리기 시작 시 스냅샷 저장 (undo 하면 그리기 전 상태로 복원)
saveSnapshot()
setPoints([])
setType(OUTER_LINE_TYPE.OUTER_LINE)
return () => {
const placementShapeDrawingStartPoint = canvas.getObjects().find((obj) => obj.name === 'placementShapeDrawingStartPoint')
canvas.remove(placementShapeDrawingStartPoint)
// 확정하지 않고 닫은 경우 스냅샷 제거
if (!isFix.current) {
popLastSnapshot()
}
}
}, [])

View File

@ -6,6 +6,7 @@ import { usePopup } from '../usePopup'
import useSWR from 'swr'
import { useSwal } from '../useSwal'
import { useMessage } from '../useMessage'
import { useUndoRedo } from '../useUndoRedo'
const LINE_COLOR = {
EAVES: '#45CD7D',
@ -23,6 +24,7 @@ export function useRoofLinePropertySetting(props) {
const { closePopup } = usePopup()
const { swalFire } = useSwal()
const { getMessage } = useMessage()
const { saveSnapshot, popLastSnapshot } = useUndoRedo()
useEffect(() => {
if (currentObject && currentObject.name === 'cloneRoofLine') {
@ -47,10 +49,20 @@ export function useRoofLinePropertySetting(props) {
}
}, [currentObject])
const isFixed = useRef(false)
useEffect(() => {
// 팝업 열릴 때 스냅샷 저장
saveSnapshot()
return () => {
canvas.remove(...canvas.getObjects().filter((obj) => obj.name === 'cloneRoofLine'))
canvas.renderAll()
// 설정 완료하지 않고 닫은 경우 스냅샷 제거
if (!isFixed.current) {
popLastSnapshot()
}
}
}, [])
@ -149,8 +161,6 @@ export function useRoofLinePropertySetting(props) {
}
const handleFix = () => {
// const roof = canvas.getObjects().find((obj) => currentObject.parentId === obj.id)
// const notSettingLines = roof.lines.filter(
const notSettingLines = canvas
.getObjects()
.filter((obj) => obj.name === 'cloneRoofLine')
@ -187,6 +197,7 @@ export function useRoofLinePropertySetting(props) {
// })
// })
isFixed.current = true
canvas.renderAll()
closePopup(id)
if (setIsHidden) setIsHidden(false)

View File

@ -52,6 +52,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
const { saveSnapshot } = useUndoRedo()
const applySurfaceShape = (surfaceRefs, selectedType, id) => {
saveSnapshot()
let length1, length2, length3, length4, length5
const surfaceId = selectedType?.id
const azimuth = surfaceRefs.azimuth.current
@ -844,6 +845,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
text: getMessage('batch.canvas.delete.all'),
type: 'confirm',
confirmFn: () => {
saveSnapshot()
canvas.clear()
delete canvas.outerLineFix

View File

@ -155,6 +155,7 @@ export function useCanvas(id) {
// 해당 오브젝트 타입의 경우 저장한 값 그대로 불러와야함
OBJECT_PROTOTYPE.forEach((type) => {
const originalToObject = type.toObject
type.toObject = function (propertiesToInclude) {
let source = {}
@ -175,7 +176,9 @@ export function useCanvas(id) {
}
}
return fabric.util.object.extend(this.callSuper('toObject', propertiesToInclude), source)
// 원본 toObject 를 호출하여 Group 의 objects 등 고유 직렬화 로직을 유지
const base = originalToObject ? originalToObject.call(this, propertiesToInclude) : this.callSuper('toObject', propertiesToInclude)
return fabric.util.object.extend(base, source)
}
})

View File

@ -21,7 +21,7 @@ export function useCanvasEvent() {
const currentMenu = useRecoilValue(currentMenuState)
const { changeCorridorDimensionText } = useText()
const { setPolygonLinesActualSize } = usePolygon()
const { undo, redo } = useUndoRedo()
const { undo, redo, saveSnapshot } = useUndoRedo()
useEffect(() => {
canvas?.setZoom(canvasZoom / 100)
@ -291,22 +291,18 @@ export function useCanvasEvent() {
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':

View File

@ -43,6 +43,7 @@ import { useTrestle } from './module/useTrestle'
import { useCircuitTrestle } from './useCirCuitTrestle'
import { usePolygon } from '@/hooks/usePolygon'
import { useText } from '@/hooks/useText'
import { useUndoRedo } from '@/hooks/useUndoRedo'
export function useContextMenu() {
const canvas = useRecoilValue(canvasState)
@ -77,6 +78,7 @@ export function useContextMenu() {
const { isExistCircuit } = useCircuitTrestle()
const { changeCorridorDimensionText } = useText()
const { setPolygonLinesActualSize, drawDirectionArrow } = usePolygon()
const { saveSnapshot } = useUndoRedo()
const currentMenuSetting = () => {
switch (selectedMenu) {
case 'outline':
@ -170,7 +172,10 @@ export function useContextMenu() {
{
id: 'roofMaterialRemoveAll',
name: getMessage('contextmenu.roof.material.remove.all'),
fn: () => removeAllRoofMaterial(),
fn: () => {
saveSnapshot()
removeAllRoofMaterial()
},
},
{
id: 'selectMove',
@ -183,6 +188,7 @@ export function useContextMenu() {
id: 'wallLineRemove',
name: getMessage('contextmenu.wallline.remove'),
fn: (currentMousePos) => {
saveSnapshot()
removeOuterLines(currentMousePos)
},
},
@ -260,7 +266,10 @@ export function useContextMenu() {
{
id: 'roofMaterialRemoveAll',
name: getMessage('contextmenu.roof.material.remove.all'),
fn: () => removeAllRoofMaterial(),
fn: () => {
saveSnapshot()
removeAllRoofMaterial()
},
},
{
id: 'selectMove',
@ -368,6 +377,7 @@ export function useContextMenu() {
name: `${getMessage('contextmenu.auxiliary.remove')}(D)`,
fn: () => {
if (!currentObject) return
saveSnapshot()
const roof = canvas.getObjects().filter((obj) => obj.id === currentObject.attributes.roofId)[0]
if (!roof) {
// 아직 innerLines로 세팅이 안되어있는 line인 경우 제거
@ -386,6 +396,7 @@ export function useContextMenu() {
name: getMessage('contextmenu.auxiliary.vertical.bisector'),
fn: () => {
if (!currentObject) return
saveSnapshot()
const slope = (currentObject.y2 - currentObject.y1) / (currentObject.x2 - currentObject.x1)
const length = currentObject.length
@ -435,6 +446,7 @@ export function useContextMenu() {
swalFire({ text: getMessage('roof.is.not.selected') })
return
}
saveSnapshot()
const innerLines = canvas.getObjects().filter((obj) => obj.id === currentObject.attributes.roofId)[0]?.innerLines
if (innerLines) {
innerLines.forEach((line) => {
@ -702,7 +714,10 @@ export function useContextMenu() {
{
id: 'remove',
name: getMessage('contextmenu.remove'),
fn: () => modulesRemove(),
fn: () => {
saveSnapshot()
modulesRemove()
},
},
{
id: 'move',
@ -900,6 +915,7 @@ export function useContextMenu() {
id: 'moduleRemove',
name: getMessage('contextmenu.module.remove'),
fn: () => {
saveSnapshot()
moduleRoofRemove(currentObject.arrayData ?? [currentObject])
// const moduleSetupSurface = canvas.getObjects().filter((obj) => canvas.getActiveObjects()[0].id === obj.id)[0]

View File

@ -16,7 +16,7 @@ import {
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 { MENU, POLYGON_TYPE, SAVE_KEY } from '@/common/common'
const MAX_HISTORY = 3
@ -182,6 +182,20 @@ export function useUndoRedo() {
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 제거
const parentIds = new Set(objects.filter((obj) => obj.id).map((obj) => obj.id))
const orphans = objects.filter(
@ -258,6 +272,26 @@ export function useUndoRedo() {
const cells = roofs.filter((obj) => obj.cells && obj.cells.length > 0)
setDrewRoofCells(cells)
// loadFromJSON 후 QPolygon/QLine 의 lengthText 가 orphan 제거되었을 수 있으므로 재생성
// (initLines 로 새 QLine 이 만들어지면 기존 lengthText 의 parentId 가 안 맞음)
const qPolygons = objects.filter((obj) => obj.type === 'QPolygon' && obj.name !== 'arrow' && obj.name !== POLYGON_TYPE.MODULE && obj.name !== POLYGON_TYPE.MODULE_SETUP_SURFACE && obj.name !== POLYGON_TYPE.OBJECT_SURFACE)
qPolygons.forEach((polygon) => {
try {
polygon.initLines()
polygon.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) => {
@ -277,7 +311,6 @@ export function useUndoRedo() {
if (!canvas) return null
try {
const rawJSON = canvas.toJSON(SAVE_KEY)
// 순환 참조 제거 후 다시 파싱하여 순수 데이터 객체로 변환
return JSON.parse(safeStringify(rawJSON))
} catch (e) {
console.warn('Canvas snapshot failed:', e)
@ -285,9 +318,12 @@ export function useUndoRedo() {
}
}, [canvas])
const saveSnapshot = useCallback((atomOverrides = null) => {
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))
@ -315,21 +351,28 @@ export function useUndoRedo() {
return newStack
})
setRedoStack([])
}, [canvas, getCanvasSnapshot, getAtomSnapshot])
}, [canvas, currentMenu, getCanvasSnapshot, getAtomSnapshot])
const closeAllPopups = useCallback(() => {
setPopup({ config: [], other: [] })
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
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,
@ -348,6 +391,9 @@ export function useUndoRedo() {
}, [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
@ -377,11 +423,17 @@ export function useUndoRedo() {
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,
}