[Canvas] 이미지 에디터 만들기 5 : Redo, Undo기능 구현
⭐️ redo, undo 상태 관리를 위한 hook 만들기
먼저 lodash를 설치해주자.
npm i lodash @types/lodash -D
이제 redo, undo를 하기 위해 상태를 관리해줄 특수한 hook을 만들자.
hook의 재사용성을 위해 관리할 데이터의 타입은 제네릭으로 받아 동적으로 할당되도록 한다.
setState함수로 T타입의 새 데이터가 들어오면 states에 저장해주고, index도 함께 업데이트해준다.
states, index가 업데이트 될 때마다 state는 자동으로 states의 index번째 값으로 할당된다.
import { isEqual } from 'lodash';
import { useMemo, useState } from 'react';
//관리할 데이터의 타입을 동적으로 받기 위해 제네릭을 받도록 함
export default function useUndoableState<T>() {
const [states, setStates] = useState<Array<T>>([]);
const [index, setIndex] = useState(0);
const state = useMemo(() => states[index], [states, index]);
const setState = (value: T) => {
if (isEqual(state, value)) {
return;
}
const newVal = states.concat(value);
setStates(newVal);
setIndex(newVal.length - 1);
};
const resetState = () => {
setIndex(0);
if (states.length) setStates([states[0]]);
};
const goBack = (steps = 1) => {
// undo를 할 때 -index가 되지 않도록 Math.max로 0과 돌아갈 step을 비교하여
// 더 큰 값을 선택하도록 한다.
// 이렇게 하면 만약 index - steps가 -1이 되어도 0으로 돌아가게 된다.
setIndex(Math.max(0, Math.max(0, index - steps)));
};
const goForward = (steps = 1) => {
//goBack과 같은 원리로
//redo를 할 때 존재하는 states보다 더 앞으로 가지 못하도록
//Math.min함수를 사용
setIndex(Math.min(states.length - 1, index + steps));
};
return {
state,
setState,
resetState,
index,
lastIndex: states.length - 1,
goBack,
goForward,
};
}
⭐️ redo, undo 구현하기
위에서 구현한 useUndoableState를 이용해서 redo, undo를 구현한다.
중요 포인트는
1. 캔버스 위 이미지 객체가 변경 될 때마다 useUndoableState의 setState함수를 호출하여 데이터를 업데이트 해준다.
2. redo, undo를 할 상태가 없는 경우 redo, undo버튼을 비활성화 시켜 줄 값을 추가하여 UX를 개선한다.
3. 조작 버튼이 아닌 캔버스 위에서도 이미지를 이동하고 회전할 수 있으므로 캔버스에 onPointerUp이벤트가 발생하면
useUndoableState의 setState함수를 호출하여 변경된 이미지 데이터를 저장해준다.
import { useCallback, useEffect, useRef, useState } from 'react';
import Konva from 'konva';
import { Stage } from 'konva/lib/Stage';
import { Layer } from 'konva/lib/Layer';
import { Node } from 'konva/lib/Node';
import { Transformer } from 'konva/lib/shapes/Transformer';
import {
CanvasObjType,
getCanvasObjectData,
rotateAroundCenter,
} from '../utils/image.utils';
import useUndoableState from '../hooks/useUndoableState';
const CANVAS_SIZE = 512;
export default function Canvas2() {
const source = '/images/bottle.jpeg';
const stageRef = useRef<Stage | null>(null);
const layerRef = useRef<Layer | null>(null);
const imageRef = useRef<Node | null>(null);
const transformerRef = useRef<Transformer | null>(null);
const [degree, setDegree] = useState(0);
const [data, setData] = useState<CanvasObjType | null>(null);
const currentDegree = useRef(0);
//1. useUndoableState를 import하고 제네릭 타입을 전달해준다.
const {
state: imageState,
setState: setImageState,
index: stateIndx,
lastIndex: lastStateIdx,
goBack,
goForward,
} = useUndoableState<CanvasObjType | null>();
//2. imageState는 redo, unde를 할 때마다 변경된다.
//imageState가 현재 보여주고자 하는 데이터이므로, 이 값이 변경될 때 마다 자동으로
//캔버스의 이미지 객체의 속성을 imageState와 동일하게 업데이트 시켜준다.
useEffect(() => {
if (!imageRef.current) return;
imageRef.current.setAttrs(imageState);
}, [imageState]);
//3. 캔버스 위의 이미지 위치, 각도, 크기 등이 변경될 때 마다 이 데이터를 useUndoableState의
//state에 저장해줄 함수를 추가해주었다.
const saveImageState = useCallback(() => {
if (!imageRef.current) return;
const data = getCanvasObjectData(imageRef.current);
setImageState(data);
}, [setImageState]);
//redo 또는 undo할 내용이 없을 경우 버튼을 비활성화 시켜 줄 값
const canUndo = stateIndx > 0;
const canRedo = stateIndx < lastStateIdx;
const initialize = useCallback(() => {
const stage = new Konva.Stage({
container: 'container',
width: CANVAS_SIZE,
height: CANVAS_SIZE,
});
stageRef.current = stage;
const layer = new Konva.Layer();
layerRef.current = layer;
stage.add(layer);
}, []);
const drawImage = useCallback(async () => {
if (layerRef.current) {
const imageObj = new Image();
imageObj.src = source;
try {
await new Promise((resolve, reject) => {
imageObj.onload = resolve;
imageObj.onerror = reject;
});
const imageOpt = {
width: CANVAS_SIZE,
height: CANVAS_SIZE,
image: imageObj,
id: 'imageId',
draggable: true,
scale: {
x: 0.9,
y: 0.9,
},
};
const defaultImage = new Konva.Image(imageOpt);
imageRef.current = defaultImage;
layerRef?.current?.add(defaultImage);
} catch (error) {
alert('이미지 로드에 실패했다!');
}
}
}, []);
function addTransformer() {
const transformer = new Konva.Transformer({
anchorSize: 10,
flipEnabled: false,
borderStroke: '#3bbc55d6',
anchorStroke: '#3bbc55d6',
rotateAnchorOffset: 20,
rotateAnchorCursor: 'grab',
enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
anchorStyleFunc: (anchor) => {
if (anchor.hasName('rotater')) {
anchor.fill('#3bbc55d6');
// @ts-ignore
anchor?.cornerRadius(10);
}
},
});
transformerRef.current = transformer;
layerRef?.current?.add(transformer);
}
function onFocus() {
if (!transformerRef.current) return;
transformerRef.current.nodes([imageRef.current as Node]);
}
function onFocusOut() {
if (!transformerRef.current) return;
transformerRef.current.nodes([]);
}
function rotateImage() {
if (imageRef.current) {
rotateAroundCenter(imageRef.current, degree + 90);
setDegree(degree + 90 === 360 ? 0 : degree + 90);
//5. 이미지가 회전 된 데이터를 저장한다. (이미지 객체가 변경되었으니)
saveImageState();
}
}
function centerImage() {
if (imageRef.current) {
const width = imageRef.current.width() * imageRef.current.scaleX();
const height = imageRef.current.height() * imageRef.current.scaleY();
currentDegree.current = imageRef.current.rotation();
imageRef.current.setAttrs({
rotation: 0,
});
imageRef.current.setAttrs({
x: (CANVAS_SIZE - width) / 2,
y: (CANVAS_SIZE - height) / 2,
});
rotateAroundCenter(imageRef.current, currentDegree.current);
//5. 이미지가 중앙정렬 된 데이터를 저장한다. (이미지 객체가 변경되었으니)
saveImageState();
}
}
function getData() {
if (!imageRef.current) return null;
const data = getCanvasObjectData(imageRef.current);
setData(data);
return data;
}
useEffect(() => {
if (!source) return;
initialize();
drawImage().then(() => {
addTransformer();
centerImage();
//4. 이미지가 처음 그려졌을 때의 초기 상태를 저장해준다.
//useUndoableState내부의 states[0]에 저장 될 것이다.
saveImageState();
});
}, [initialize, drawImage]);
return (
<section id='canvas_container'>
<div
id='container'
onMouseEnter={onFocus}
onMouseLeave={onFocusOut}
onTouchStart={onFocus}
//5. 캔버스에서 포인터(마우스나 터치)가 떨어지는 이벤트 발생 시 마다
//그 때의 이미지 객체 정보를 저장해준다.
//터치나 마우스로 이미지를 이동시키거나 회전시켰을 경우 발생하는 데이터 변경을 저장한다.
onPointerUp={saveImageState}
></div>
<div className='btn_container'>
<div className='control_btn'>
<button className='rotate_btn' onClick={rotateImage}>
회전하기
</button>
<button className='rotate_btn center' onClick={centerImage}>
중앙정렬
</button>
<button className='rotate_btn data' onClick={getData}>
데이터 추출
</button>
</div>
<div className='control_btn'>
<button
className='rotate_btn redo'
disabled={!canRedo}
onClick={() => goForward()}
>
Redo
</button>
<button
className='rotate_btn undo'
disabled={!canUndo}
onClick={() => goBack()}
>
Undo
</button>
</div>
</div>
<div className='imageData' style={{ display: !data ? 'none' : 'block' }}>
{data &&
Object.entries(data).map(([key, value]) => (
<p key={key}>
{key}:
{typeof value === 'object'
? `x : ${value.x}, y : ${value.y}`
: value}
</p>
))}
</div>
</section>
);
}
⭐️ 최종 코드 깃헙에서 보기
아래의 레포를 clone받은 뒤 8fa18e3커밋으로 체크아웃 하면 최종 완성된 코드를 볼 수 있다.
https://github.com/seoulsaram/canvas-search
GitHub - seoulsaram/canvas-search
Contribute to seoulsaram/canvas-search development by creating an account on GitHub.
github.com
'프론트엔드' 카테고리의 다른 글
[아키텍처] 기존 프로젝트 구조를 모노레포로 바꾸기 (1) | 2024.09.04 |
---|---|
[Canvas] 이미지 에디터 만들기 6 : 이미지 저장하기 (0) | 2024.08.26 |
[Canvas] 이미지 에디터 만들기 4 : 이미지 중앙정렬 기능 구현 (0) | 2024.08.26 |
[Canvas] 이미지 에디터 만들기 3 : 이미지 회전 기능 구현 (1) | 2024.08.26 |
[Canvas] 이미지 에디터 만들기 2 : 이미지 바운딩 박스 구현 (0) | 2024.08.26 |