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],