undo redo 작업중
This commit is contained in:
parent
5f4673dd3f
commit
7e08184227
@ -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) {
|
||||
<div className="canvas-side-btn-wrap">
|
||||
{!['estimate', 'simulation'].some((num) => num === selectedMenu) && (
|
||||
<>
|
||||
<div className="undo-redo-wrap">
|
||||
<button
|
||||
className={`undo-btn ${!canUndo ? 'disabled' : ''}`}
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 10h10c4.42 0 8 3.58 8 8v1" />
|
||||
<polyline points="7 14 3 10 7 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`redo-btn ${!canRedo ? 'disabled' : ''}`}
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 10H11C6.58 10 3 13.58 3 18v1" />
|
||||
<polyline points="17 14 21 10 17 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{
|
||||
<div className={`vertical-horizontal ${verticalHorizontalMode ? 'on' : ''}`}>
|
||||
<span>{getMessage('plan.mode.vertical.horizontal')}</span>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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) => {
|
||||
|
||||
369
src/hooks/useUndoRedo.js
Normal file
369
src/hooks/useUndoRedo.js
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user