From 77e3f688c6ea5190394af7a170721693df27c942 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Wed, 15 Apr 2026 17:37:49 +0900 Subject: [PATCH] =?UTF-8?q?redo=20undo=20=EC=9E=91=EC=97=85=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modal/auxiliary/AuxiliaryEdit.jsx | 7 +- .../modal/auxiliary/AuxiliarySize.jsx | 3 + .../floor-plan/modal/module/PanelEdit.jsx | 3 + .../modal/module/column/ColumnInsert.jsx | 3 + .../modal/module/column/ColumnRemove.jsx | 3 + .../floor-plan/modal/module/row/RowInsert.jsx | 3 + .../floor-plan/modal/module/row/RowRemove.jsx | 3 + src/hooks/common/useMenu.js | 2 + src/hooks/common/useRoofFn.js | 5 ++ .../roofcover/useRoofShapePassivitySetting.js | 11 ++-- .../roofcover/useWallLineOffsetSetting.js | 14 +++- src/hooks/surface/usePlacementShapeDrawing.js | 10 ++- .../surface/useRoofLinePropertySetting.js | 15 ++++- src/hooks/surface/useSurfaceShapeBatch.js | 2 + src/hooks/useCanvas.js | 5 +- src/hooks/useCanvasEvent.js | 6 +- src/hooks/useContextMenu.js | 22 ++++++- src/hooks/useUndoRedo.js | 64 +++++++++++++++++-- 18 files changed, 152 insertions(+), 29 deletions(-) diff --git a/src/components/floor-plan/modal/auxiliary/AuxiliaryEdit.jsx b/src/components/floor-plan/modal/auxiliary/AuxiliaryEdit.jsx index d7696a84..2f68f3ad 100644 --- a/src/components/floor-plan/modal/auxiliary/AuxiliaryEdit.jsx +++ b/src/components/floor-plan/modal/auxiliary/AuxiliaryEdit.jsx @@ -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' })) }} > @@ -121,14 +122,12 @@ export default function AuxiliaryEdit(props) { className={`direction left ${arrow2 === '←' ? 'act' : ''}`} onClick={() => { setArrow2('←') - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })) }} > diff --git a/src/components/floor-plan/modal/auxiliary/AuxiliarySize.jsx b/src/components/floor-plan/modal/auxiliary/AuxiliarySize.jsx index 6b0abda1..09091011 100644 --- a/src/components/floor-plan/modal/auxiliary/AuxiliarySize.jsx +++ b/src/components/floor-plan/modal/auxiliary/AuxiliarySize.jsx @@ -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의 사잇각을 구한다. diff --git a/src/components/floor-plan/modal/module/PanelEdit.jsx b/src/components/floor-plan/modal/module/PanelEdit.jsx index baf4b101..1c9d0e75 100644 --- a/src/components/floor-plan/modal/module/PanelEdit.jsx +++ b/src/components/floor-plan/modal/module/PanelEdit.jsx @@ -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]), diff --git a/src/components/floor-plan/modal/module/column/ColumnInsert.jsx b/src/components/floor-plan/modal/module/column/ColumnInsert.jsx index 0c160656..869e7032 100644 --- a/src/components/floor-plan/modal/module/column/ColumnInsert.jsx +++ b/src/components/floor-plan/modal/module/column/ColumnInsert.jsx @@ -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) } diff --git a/src/components/floor-plan/modal/module/column/ColumnRemove.jsx b/src/components/floor-plan/modal/module/column/ColumnRemove.jsx index b9079642..2aafb954 100644 --- a/src/components/floor-plan/modal/module/column/ColumnRemove.jsx +++ b/src/components/floor-plan/modal/module/column/ColumnRemove.jsx @@ -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) } diff --git a/src/components/floor-plan/modal/module/row/RowInsert.jsx b/src/components/floor-plan/modal/module/row/RowInsert.jsx index e9fb799c..ff7cb813 100644 --- a/src/components/floor-plan/modal/module/row/RowInsert.jsx +++ b/src/components/floor-plan/modal/module/row/RowInsert.jsx @@ -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) } diff --git a/src/components/floor-plan/modal/module/row/RowRemove.jsx b/src/components/floor-plan/modal/module/row/RowRemove.jsx index 5cf95b3f..cdfeebd5 100644 --- a/src/components/floor-plan/modal/module/row/RowRemove.jsx +++ b/src/components/floor-plan/modal/module/row/RowRemove.jsx @@ -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) diff --git a/src/hooks/common/useMenu.js b/src/hooks/common/useMenu.js index 40c1b6a0..8cdfeb1b 100644 --- a/src/hooks/common/useMenu.js +++ b/src/hooks/common/useMenu.js @@ -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') { diff --git a/src/hooks/common/useRoofFn.js b/src/hooks/common/useRoofFn.js index 4721d581..19b6daa9 100644 --- a/src/hooks/common/useRoofFn.js +++ b/src/hooks/common/useRoofFn.js @@ -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) } diff --git a/src/hooks/roofcover/useRoofShapePassivitySetting.js b/src/hooks/roofcover/useRoofShapePassivitySetting.js index b693fe41..bbd9dcaf 100644 --- a/src/hooks/roofcover/useRoofShapePassivitySetting.js +++ b/src/hooks/roofcover/useRoofShapePassivitySetting.js @@ -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) } diff --git a/src/hooks/roofcover/useWallLineOffsetSetting.js b/src/hooks/roofcover/useWallLineOffsetSetting.js index 31d8f95f..6b254473 100644 --- a/src/hooks/roofcover/useWallLineOffsetSetting.js +++ b/src/hooks/roofcover/useWallLineOffsetSetting.js @@ -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() diff --git a/src/hooks/surface/usePlacementShapeDrawing.js b/src/hooks/surface/usePlacementShapeDrawing.js index 892f5f1a..2f08713e 100644 --- a/src/hooks/surface/usePlacementShapeDrawing.js +++ b/src/hooks/surface/usePlacementShapeDrawing.js @@ -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() + } } }, []) diff --git a/src/hooks/surface/useRoofLinePropertySetting.js b/src/hooks/surface/useRoofLinePropertySetting.js index 6115f76e..febd84b2 100644 --- a/src/hooks/surface/useRoofLinePropertySetting.js +++ b/src/hooks/surface/useRoofLinePropertySetting.js @@ -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) diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js index ac9d5fd1..a5fa5232 100644 --- a/src/hooks/surface/useSurfaceShapeBatch.js +++ b/src/hooks/surface/useSurfaceShapeBatch.js @@ -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 diff --git a/src/hooks/useCanvas.js b/src/hooks/useCanvas.js index 9a6fb14b..3880174a 100644 --- a/src/hooks/useCanvas.js +++ b/src/hooks/useCanvas.js @@ -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) } }) diff --git a/src/hooks/useCanvasEvent.js b/src/hooks/useCanvasEvent.js index d15eb05d..351fb8cc 100644 --- a/src/hooks/useCanvasEvent.js +++ b/src/hooks/useCanvasEvent.js @@ -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': diff --git a/src/hooks/useContextMenu.js b/src/hooks/useContextMenu.js index 25dd2a1a..74fd31ad 100644 --- a/src/hooks/useContextMenu.js +++ b/src/hooks/useContextMenu.js @@ -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] diff --git a/src/hooks/useUndoRedo.js b/src/hooks/useUndoRedo.js index 21bfc500..cd1ef27b 100644 --- a/src/hooks/useUndoRedo.js +++ b/src/hooks/useUndoRedo.js @@ -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, }