냄궁밈수 (밈 생성기)

프리뷰

🧤 프로젝트 개요

원하는 사진을 고른 뒤, 센스 있는 문구를 넣어 자신만의 재밌는 밈을 생성할 수 있는 웹 페이지


📲 링크

Git. https://github.com/hoonjoo-park/Namgoong-Meme-Soo

배포 주소. https://namgoong-meme-soo.vercel.app/


🤷 사이트 이름이 왜 냄궁밈수인가..?

사실 특별한 이유는 없다.. 그냥 친구들과 얘기를 나누던 중 ‘‘이 들어가는 사이트 이름 작명을 부탁해봤다.

그러다가 그냥 우스갯 소리로, 영화 설국열차에서 크리스 에반스의 인상 깊은 대사였던(?) “Are You 냄궁밈수?”라는 헛소리가 나왔다. 그래서 친구들과 웃다가 “그래~ 그냥 그걸로 하자~”하며 사이트 이름이 냄궁밈수가 되었다.


🚚 밈 텍스트 박스 드래그 이동 기능

moving

입력한 밈 텍스트가 마우스 드래깅을 통해 원하는 위치로 이동될 수 있도록 기능을 구현했다.

밈 제작의 기본 중 기본은, 텍스트 엘리먼트를 마우스 드래깅을 통해 위치를 자유자재로 조정할 수 있도록 하는 것이라 생각했다. 이에 따라 여러 고민을 하던 중, 예전에 드래그&슬라이드 기능을 구현했던 방법과 유사한 접근 방법을 사용하여 엘리먼트 이동 기능을 구현해보기로 결정했다.

onMouseDown(시작) → onMouseMove(이동) → onMouseUp(종료)

이와 같은 로직을 따르면 될 것 같았기에 위의 세 이벤트 리스너와 핸들링 함수를 사용했다.

onMouseDown

우선, 텍스트 박스 위에서 mouseDown이 됐을 때 저장해야 하는 값들이 꽤 많았다.

  • 마우스의 초기 좌표값 : e.pageX, e.pageY
  • 텍스트 박스의 초기 위치값 : offsetLeft, offsetTop
  • 마우스가 다운됐는지 여부 : isDown(boolean)

이러한 값들을 handleMouseDown이라는 이벤트 핸들러 함수에서 이하와 같이 처리해줬다.

1
2
3
4
5
6
7
8
const handleMouseDown = (e: React.MouseEvent<HTMLElement>) => {
// 이러한 값들이 왜 필요한지는 아래의 설명을 읽다보면 이해가 될 것이다.
setIsDown(true);
setStartX(e.pageX);
setStartY(e.pageY);
setStartTop(e.currentTarget.offsetTop);
setStartLeft(e.currentTarget.offsetLeft);
};

onMouseMove

mousemove 이벤트의 경우에는, 마우스가 텍스트 박스 위에서 움직일 때를 리스닝 하는 것이 아닌, window 위에서 마우스가 움직이는 것을 리스닝 하도록 처리해줘야 했다. 박스 위에서의 mousemove를 리스닝하도록 하면, 마우스가 박스 밖으로 나갔을 때 텍스트박스가 이동을 멈추는 문제들이 발생했기 때문이다.

따라서, 위에서 사용했던 isDown을 활용해 windowaddEventListener를 통해 mousemove 이벤트 리스너를 추가해줬다.

1
2
3
4
5
6
7
8
9
// isResizing은 아래에서 설명할 텍스트박스 리사이징 기능에서 활용되는 state다.
// 미리 간략하게 설명하자면, 리사이징 중에도 mousemove 이벤트를 활용하기에
// 리사이징이 진행중일 때는 텍스트박스가 이동되지 않도록 미리 방지해준 것이다.

useEffect(() => {
if (isResizing) return;
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleStopMoving);
}, [isDown, isResizing]);

즉, mouseDown이 됐을 때 → useEffect를 통해 windowmousemove 이벤트 리스너가 활성화 된다.

이제 마우스가 움직일 때를 핸들링하는 함수 handleMouseMove에 대해 다뤄보도록 하겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const handleMouseMove = (e: MouseEvent) => {
// 마우스가 다운되지 않았을 때나, 리사이징 중일 때에는 해당 함수가 작동돼선 안된다!
if (!isDown || isResizing) return;
// toMoveTop과 toMoveLeft는 텍스트박스 엘리먼트가 움직여야 할 위치값을 나타낸다.
const toMoveTop = e.pageY - startY + startTop;
const toMoveLeft = e.pageX - startX + startLeft;
// moveOptions는 텍스트박스가 이미지 밖으로 이동되는 것을 방지하기 위한 조건문 옵션이다.
const moveOptions =
toMoveTop + 10 <= 0 ||
toMoveTop + inputRef.current!.offsetHeight - 10 >= textBoundary!.bottom ||
toMoveLeft - inputRef.current!.offsetWidth / 2 + 10 <= 0 ||
toMoveLeft + inputRef.current!.offsetWidth / 2 - 10 >=
textBoundary!.right;
// moveOptions가 true라면 이미지 범위 밖으로 이동됨을 의미하기 때문에 textMover 실행을 방지해야 한다.
if (moveOptions) return;
textMover(toMoveTop, toMoveLeft);
};

좀 더 쉽게 설명하자면, 위의 사진과 같이 마우스를 클릭한 뒤,마우스가 움직인 거리만큼 텍스트박스가 이동되는 것이다.

조금 더 자세히 toMoveLeft와 연관지어 풀어 써보자면, 아래의 사진과 같이 표현할 수 있을 것이다.

이 때문에 위에서 const toMoveLeft = e.pageX - startX + startLeft 라는 계산식이 도출된 것이다.

이를 다시 풀어서 설명하면,
마우스의 현재위치(e.pageX) - 처음 mouseDown 됐을 때의 위치(startX) = 마우스가 이동한 거리
startLeft = 원래 offsetLeft 위치

따라서 마우스가 이동한 거리 + 원래 offsetLeft 값을 더해주면 → 마우스 드래그에 따른 박스의 위치(X)값이 계산되는 것이다.

이제 값을 구했으니 textMover라는 함수를 사용해 실제 박스를 해당 위치로 이동시켜주기만 하면 된다.

1
2
3
4
5
6
7
// inputRef는 text박스를 의미한다
const textMover = (top: number, left: number) => {
// useRef를 활용하여 inputRef.current의 top과 left값을 위에서 계산해준 값으로 넣어주면 된다.
inputRef.current!.style.top = `${top}px`;
inputRef.current!.style.left = `${left}px`;
return;
};

onMouseUp

이제 텍스트 이동이 끝났을 때 이벤트들을 모두 remove 해주기만 하면 기능 구현이 모두 끝난다.

만약, onMouseUp에 따른 이벤트 리무빙을 해주지 않는다면, 텍스트가 계속해서 마우스를 따라다닐 것이다.

따라서 아래와 같이 이벤트 리스너들을 제거해줘야 한다.

1
2
3
4
5
const handleStopMoving = () => {
setIsDown(false);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleStopMoving);
};

이렇게 까지만 구현해주면, 텍스트 박스가 마우스 드래깅에 따라 원하는 위치로 이동되도록 하는 기능 구현이 모두 끝났다!

물론, 추후에 코드들을 조금 더 간결하고 가독성이 좋도록 리팩토링을 더 진행해볼 계획이다.


🕹 밈 텍스트 박스 리사이징 기능

fontSize

사용자 입장에서 생각했을 때, 밈 텍스트의 위치조정 뿐만 아니라 텍스트 사이즈도 자유롭게 조절될 수 있으면 훨씬 더 편하게 밈을 만들 수 있을 것이라 생각했다.

따라서 텍스트 박스 엘리먼트의 크기가 조절될 수 있도록 함과 동시에, 폰트 사이즈 또한 박스 크기에 비례하여 줄어들고 늘어날 수 있도록 기능을 구현해보고자 했다.

resizer 버튼 배치하기

위의 gif에 나타나 있듯이, 텍스트 박스 모서리에 네 개의 리사이저를 배치했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Text
className={isResizing ? 'active' : ''}
onMouseDown={(e) => handleMouseDown(e)}
onTouchStart={(e) => handleTouchStart(e)}
ref={inputRef}
fontSize={fontSize}
>
{text['text']}
// 각 리사이저들 (span태그를 사용했음)
<Resizer className='resizer leftTop'></Resizer>
<Resizer className='resizer rightTop'></Resizer>
<Resizer className='resizer rightBottom'></Resizer>
<Resizer className='resizer leftBottom'></Resizer>
</Text>

mouseDown 이벤트 활용하기

그리고 각 리사이저들에 onMouseDown 이벤트 핸들러를 할당해줬다.

onMouseDown에 할당된 resizeStart 함수는 위에서 사용했던 handleMouseDown 핸들러 함수와 굉장히 유사하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const resizeStart = (e: React.MouseEvent, type: string) => {
// 하지만 한 가지 다른 점은, 각 리사이저별로 마우스 이동 방향 및 거리에 따른 사이즈 조절 기준이 다르기 때문에 type을 담아서 보내줬다.
setResizer(type); // leftTop, rightTop, rightBottom, leftBottom 등등
setIsResizing(true);
// 마우스의 XY 시작점
setResizeStartX(e.clientX);
setResizeStartY(e.clientY);
// 패딩값을 제외한 순수 width height값을 getComputedStyle을 통해 반환받아 활용
const inputStyle = getComputedStyle(inputRef.current!);
const inputWidth = parseFloat(inputStyle.width);
const inputHeight = parseFloat(inputStyle.height);
setStartWidth(inputWidth);
setStartHeight(inputHeight);
};

그리고 위와 동일하게, useEffect를 활용하여 리사이징이 시작됐을 때(onMouseDown) window에 전역적으로 mousemovemouseup 이벤트 핸들러를 할당해 줬다.

1
2
3
4
5
6
7
useEffect(() => {
// setState는 비동기적 특성을 갖기 때문에,
// 위에서 초기에 setResizer가 실행 됐어도, 첫 렌더링 때에는 resizer가 null이여서 아래와 같은 조건문을 명시
if (!resizer || !isResizing) return;
window.addEventListener('mousemove', mouseResizing);
window.addEventListener('mouseup', stopResizing);
}, [resizer, isResizing]);

mouseMove 이벤트를 통해 박스 크기 조절하기

가장 어려웠던 부분이다.

처음에는 그냥 마우스의 이동 거리를 매 이벤트마다 재할당 해주며 사이즈를 줄이거나 늘려주면 될 것이라 생각했지만, mousemove 이벤트를 setState가 따라가지 못해서 정확한 사이징이 이루어지지 않았다.

따라서, 차라리 계속 변경되는 엘리먼트의 사이즈를 계속 업데이트 해주는 것 보다는, 초기 width값을 고정시켜두고 마우스의 매 이동거리가 아닌, 총 이동거리를 계속 차감해주는 방식을 선택했다.

코드는 이하와 같다.

1
2
3
4
5
6
7
8
9
const mouseResizing = (e: MouseEvent) => {
// movedX, movedY는 그냥 마우스의 이동 거리다.
const movedX = resizeStartX - e.clientX;
const movedY = resizeStartY - e.clientY;
// 폰트 사이즈는 아래에서 설명하도록 하겠다.
handleFontSize();
// 그리고 handleResize에 해당 값들을 인자로 전달해준다.
handleResize(movedX, movedY);
};

이 경우는 그냥 음수값만큼 startWidth에 더해주면 저절로 사이즈가 줄어들도록 값을 줄 수 있다.

이 경우는 그냥 음수값만큼 startWidth에 더해주면 저절로 사이즈가 줄어들도록 값을 줄 수 있다.

하지만 이 경우는 movedX가 양수임에도, 의도한 기능은 width가 줄어들어야 하기 때문에 startWidth에서 해당 값을 차감해줘야 한다.

하지만 이 경우는 movedX가 양수임에도, 의도한 기능은 width가 줄어들어야 하기 때문에 startWidth에서 해당 값을 차감해줘야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const handleResize = (movedX: number, movedY: number) => {
switch (resizer) {
case 'leftTop':
// 초기 width, height값에 움직인 만큼의 값을 매 이벤트마다 계산하여 사이즈값을 준다.
// 하지만 위의 사진에서 설명했듯이, 리사이저마다 계산되어야 하는 방식이 다름에 유의해야 한다.
inputRef.current!.style.width = `${startWidth + movedX}px`;
inputRef.current!.style.height = `${startHeight + movedY}px`;
break;
case 'rightTop':
inputRef.current!.style.width = `${startWidth - movedX}px`;
inputRef.current!.style.height = `${startHeight + movedY}px`;
break;
case 'rightBottom':
inputRef.current!.style.width = `${startWidth - movedX}px`;
inputRef.current!.style.height = `${startHeight - movedY}px`;
break;
case 'leftBottom':
inputRef.current!.style.width = `${startWidth + movedX}px`;
inputRef.current!.style.height = `${startHeight - movedY}px`;
break;
default:
return;
}
};

onMouseUp

위에서 사용한 mouseUp 핸들링 함수와 거의 유사하다.

1
2
3
4
5
6
const stopResizing = () => {
setIsResizing(false);
// mouse가 up 됐을 때 -> window에 등록된 두 이벤트 핸들러를 삭제해 준다.
window.removeEventListener('mousemove', mouseResizing);
window.removeEventListener('mouseup', stopResizing);
};

💾 DOM을 이미지로 저장하기

dom-to-image와 file-saver 라이브러리를 활용했다.

설치

1
$ npm install dom-to-image file-saver

코드 활용 로직

useRef로 이미지 박스 DOM에 직접적으로 접근한다 → dom-to-image를 통해 해당 DOM을 스크린샷 찍은 후 파일 url을 얻는다 → 해당 url을 file-saver에 전달하여 로컬에 사진이 저장될 수 있도록 한다.

1
2
3
4
5
6
7
const saveImage = async () => {
const image = imageRef.current;
// domtoimage를 활용하여 원하는 DOM을 스크린샷 찍은 후 해당 이미지의 blob을 받는다.
const png = await domtoimage.toBlob(image);
// 그리고 file-saver를 통해 해당 blob(이미지)을 다운 받는다.
saveAs(png, 'meme.png');
};

프로젝트 회고

음… 가벼운 마음으로 시작했지만, 굉장히 손도 많이 가고 꼼꼼함이 필요했던 프로젝트였던 것 같다.

그리고 처음으로 배포를 한 뒤에, 주변 지인들에게 사이트 활용을 부탁했고, 지인들로부터 보완사항 및 피드백을 받아 git issue에 등록했다. 그리고 매일 한 두개씩의 이슈들을 해결하려 노력했다.

특히 아래의 세 가지 기능들이 지인들로 부터 받은 피드백이었고, 해당 기능들을 추가 및 보완하기 위해 굉장히 애를 썼다.

  • 텍스트 추가 및 삭제 기능
  • 텍스트별 폰트 색상 적용 기능
  • 텍스트박스 및 폰트 리사이징 기능

“우선 배포해라, 어차피 고쳐야 할 것들은 산더미다” 라는 말이 굉장히 와닿았던 프로젝트였다.

앞으로 다른 프로젝트를 할 때에도, 최대한 유저 입장에서 고민하며 개발을 하고, 배포 후에도 유저들의 피드백에 귀 기울일 수 있는 개발자가 되도록 노력해야겠다.

굉장히 유의미한 경험이었다. 🧏‍♂️


Author

Hoonjoo

Posted on

2022-03-04

Updated on

2022-03-04

Licensed under

Comments