시트별 상태관리
어려웠던 점
처음에는 여러 개의 시트를 고려하지 않고 시트가 1개인 엑셀 에디터를 제작했다.
초기 상태는 다음과 같다.
- cells(표 각 cell의 정보), historyInfo(작업 내역 스택), selectedArea(드래그한 cell영역), selectedCell(클릭하여 선택한 cell), copiedArea(복사한 영역)
해당 상태들을 context에 담아서 하위 컴포넌트에서 사용했다.
엑셀 에디터를 시트가 여러 개인 형태로 변경하면서 최대한 기존 구조를 유지하면서 시트 여러개 활용(ms excel처럼)하도록 변경하는 것이 어려웠다.
해결
각 시트별로 해당 상태를 저장해야 했는데, 최대한 기본 구조 수정을 하지 않으면서 변경하기 위해서 context위에 recoil을 뒀다.
recoil에서는 File이라는 하나의 atom을 저장했다.
export interface IFile {
title: string
lastEditTime: string | null
sheets: ISheet[]
currentSheetIdx: number
}
File
은 각 시트의 데이터와 현재 작업중 시트의 idx 등을 저장한다.
편집기 하단의 시트 탭의 항목을 클릭하여 시트를 변경하면,
File
의currentSheetIdx
가 변경된다.currentSheetIdx
가 변경되면 useEffect가 실행되고 context에 담긴 cells, historyInfo 등이 변경된다.
/// useFile.ts
useEffect(() => {
// 시트 바뀔 시 해당 시트의 정보를 불러온다.
const curSheet = file.sheets[file.currentSheetIdx]
// 1. cells
setCells(curSheet.cells)
// 2. history
setHistoryInfo(curSheet.historyInfo)
// 3. 스크롤 위치
const domSheet = document.querySelector('.sheet')
const { x, y } = curSheet.scrollPosition
domSheet?.scrollTo({ top: y, left: x, behavior: 'smooth' })
// 4. 셀렉트 박스 정보
setSelectedCell(curSheet.selectedCell)
// 5. 셀렉트 에어리어 정보
setSelectedArea(curSheet.selectedArea)
}, [file.currentSheetIdx, file.sheets, setCells, setHistoryInfo, setSelectedCell, setSelectedArea])
File
에는AtomEffect
를 걸어놓아서 localStorage에 파일데이터를 저장했다. 이렇게 함으로써 별도의 로그인, 저장 없이 작업데이터가 local에 저장되게 된다.
/// store.ts
const localStorageEffect: (key: string) => AtomEffect<IFile> =
key =>
({ setSelf, onSet }) => {
const savedValue = localStorage.getItem(key)
if (savedValue != null) {
setSelf(JSON.parse(savedValue))
}
onSet((newValue, _, isReset) => {
isReset ? localStorage.removeItem(key) : localStorage.setItem(key, JSON.stringify(newValue))
})
}
이런 식으로 해서 기존 구조의 변경없이 위에 recoil만 얹어서 여러개의 시트를 사용할 수 있도록 변경할 수 있었다.
셀 드래그 기능
어려웠던 점
셀을 드래그하여 선택된 영역은 처음 클릭된 영역은 input창이 뜨고 내용 편집이 가능해야 하고,
처음 클릭된 영역을 제외한 나머지 영역은 회색으로 선택되었다는 표시가 되고, 상단과 좌측의 헤더에도 회색 표시를 하여 어디부터 어디까지 선택되었다는 표시가 되어야 한다. 또한 상단 도구상자를 이용해 선택된 영역 전체에 대한 속성 편집이 가능해야 한다.
해결
onMouseDown
,onMouseOver
,onMouseUp
이벤트를 활용해서 드래그시 시작 지점, 종료 지점, 드래그 여부를selectedArea
에 저장했다. 해당 이벤트는 시트 전체에 1번만 걸었다. (이벤트 위임)- 이 selectedArea의 setter를 도구 상자 컴포넌트에서 context를 통해 받아서 사용함으로써 선택된 영역 전체의 속성 편집이 가능하도록 했다.
/// useSelectArea.ts
export const useSelectArea = (): UseSelectAreaReturns => {
const { selectedArea } = useEditorValues()
const { setSelectedArea } = useEditorActions()
const [selectedAreaRect, setSelectedAreaRect] = useState<SelectedAreaRect>(defaultSelectedAreaRect)
const isDragging = useRef(false)
const onCellDragStart: ReactEventHandler = useCallback(
e => {
isDragging.current = true
const target = e.target as HTMLElement
if (target.id) {
const { i, j } = parseCellId(target.id)
setSelectedArea({ si: i, sj: j, ei: i, ej: j, active: true })
}
},
[setSelectedArea]
)
const onCellDragging: ReactEventHandler = useCallback(
e => {
if (!isDragging.current) {
return
}
const target = e.target as HTMLElement
if (target.id) {
const { i, j } = parseCellId(target.id)
setSelectedArea((prev: SelectedArea) => ({ ...prev, ei: i, ej: j }))
}
},
[setSelectedArea]
)
const onCellDragEnd: ReactEventHandler = useCallback(() => {
isDragging.current = false
}, [])
const selectedAreaSorted = useMemo(() => {
const { si, sj, ei, ej } = selectedArea
return { si: Math.min(si, ei), sj: Math.min(sj, ej), ei: Math.max(si, ei), ej: Math.max(sj, ej) }
}, [selectedArea])
const calcSelectedAreaRect = useCallback(() => {
const { si, sj, ei, ej } = selectedArea
const rect = getAreaRect(si, sj, ei, ej)
if (rect) {
setSelectedAreaRect(rect)
}
}, [selectedArea, setSelectedAreaRect])
useEffect(() => {
calcSelectedAreaRect()
}, [calcSelectedAreaRect])
return {
selectedArea,
selectedAreaSorted,
selectedAreaRect,
onCellDragStart,
onCellDragging,
onCellDragEnd,
calcSelectedAreaRect,
}
}
- 화면상에서 회색으로 보이는 부분은 드래그 시작 cell과 종료 cell의 위치를 기준으로 해서 회색 영역 상자의 높이와 너비, 위치를 계산한 후
selectedAreaRect
에 저장했다.
/// SheetUtils.ts
export function getAreaRect(
si: number,
sj: number,
ei: number,
ej: number
): {
width: number
height: number
top: number
left: number
} | null {
const sCellEl = document.getElementById(`${si}-${sj}`)
const eCellEl = document.getElementById(`${ei}-${ej}`)
if (sCellEl && eCellEl) {
const [sOffsetWidth, sOffsetHeight, sOffsetTop, sOffsetLeft] = getCellRectInfo(sCellEl)
const [eOffsetWidth, eOffsetHeight, eOffsetTop, eOffsetLeft] = getCellRectInfo(eCellEl)
const width = Math.abs(eOffsetLeft - sOffsetLeft) + (sOffsetLeft > eOffsetLeft ? sOffsetWidth : eOffsetWidth)
const height = Math.abs(eOffsetTop - sOffsetTop) + (sOffsetTop > eOffsetTop ? sOffsetHeight : eOffsetHeight)
const top = Math.min(sOffsetTop, eOffsetTop)
const left = Math.min(sOffsetLeft, eOffsetLeft) + defaultCellWidth + 1
return { width, height, top, left }
}
return null
}
- 선택영역이 사용자에게 보이게 하기 위해서 따로 컴포넌트를 만들어 줬다. 드래그 중이거나 드래그 후 다른 동작을 하지 않았을 때만 회색 영역이 보이도록 했다.
export function SelectArea() {
const { selectedArea, selectedAreaRect } = useSelectArea()
return (
<>{selectedArea.active && <div className="select-area" data-testid="select-area" style={selectedAreaRect} />}</>
)
}
- 해당 영역은 따로 event에 동작하는 것이 없고 시트의 mouseEvent 동작에 이상이 없도록 하기 위해 css 속성
pointer-events: none;
을 사용하여 마우스이벤트가 발생하지 않도록 했다.
.select-area {
border: 1px solid $active-color;
position: absolute;
background-color: $selected-cell-color;
z-index: 3;
pointer-events: none;
}
'프로젝트 회고 > 엑셀 편집기 개발' 카테고리의 다른 글
5. 정리 - 엑셀 편집기 (0) | 2023.09.13 |
---|---|
4. 성능 측정, 최적화 (0) | 2023.09.12 |
3. 테스트 자동화 (0) | 2023.09.11 |
1. 개요, 기본 구조 (0) | 2023.08.21 |