[Canvas] 이미지 에디터 만들기 5 : Redo, Undo기능 구현

profile
마릴린벅시
2024. 8. 26. 18:41프론트엔드


완성!

⭐️ 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

 

 

 

반응형