dev #931

Merged
ysCha merged 95 commits from dev into prd-deploy 2026-06-23 17:47:25 +09:00
21 changed files with 581 additions and 31 deletions
Showing only changes of commit 7e08184227 - Show all commits

View File

@ -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>

View File

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

View File

@ -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)
}

View File

@ -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()

View File

@ -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 = []

View File

@ -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

View File

@ -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()

View File

@ -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,
})

View File

@ -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

View File

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

View File

@ -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 },

View File

@ -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) => {

View File

@ -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()

View File

@ -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: {

View File

@ -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

View File

@ -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')

View File

@ -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
View 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,
}
}

View File

@ -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,
})

View File

@ -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;

View File

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