새소식

반응형
Front-end

[kakao tech bootcamp] React에서 드래그한 텍스트를 관리 하는 방법 (text selection, getSelection API)

2024.11.08
  • -
반응형
본 포스팅은 '카카오 테크 부트캠프'에서 진행한 프로젝트인 '깃트폴리오'를 진행하면서 겪었던 트러블 중 text selection과 관련된 내용을 바탕으로 작성하였습니다.

 

1. 들어가며

 

깃트폴리오의 궁극적인 목표는 이력서를 생성해주는 데에서 그치지 않고 AI와 커뮤니케이션을 통해 나에게 맞는 최적의 이력서를 뽑아내는 데에 있습니다. 그렇기 때문에 '내 이력서'를 확인하는 화면에서는 이력서의 내용뿐만 아니라 AI에게 이력서의 특정 부분을 수정 요청할 수 있도록 우측에 마련한 '채팅 사이드바'가 존재하는데요.

 

기획 단계에서 이 부분을 구현하는 방식에 있어서 팀원들끼리 다양한 의견이 오고 갔었습니다.

 

물론 가장 이상적인 상황은 요구사항만 딱 보내도 AI가 알잘딱하게 알아서 수정해주는 것일 것입니다. 하지만 해당 이력서에 대한 맥락에 대한 정보가 없고 정확히 어느 지점에 대해 수정을 요구하는 것인지 알려주지 않으면 AI가 내가 원하지 않은 곳을 수정할 수도 있다는 문제가 존재합니다.

 

그래서 마치 ChatGPT의 회신(reply) 기능처럼 이 부분을 수정해달라고 콕 집어서 요청을 하면 정말 그 부분만을 수정해주는 것을 기대할 수 있기 때문에 수정을 원하는 부분을 같이 포함하여 요구사항과 함께 보내는 방식으로 진행하기로 결정하였습니다.

 

이력서의 내용 중에서 특정 부분을 선택하여 보낼 때 그 길이가 만약 짧다면 다른 부분과 겹치는 부분이 생기면 다른 부분까지 바꿔버릴 수도 있을 것이라는 생각이 들었습니다. 또한 구현 방식도 잠깐 알아봤을 때 굉장히 복잡해 보였기 때문에 조금 다른 방식을 생각해보기로 했습니다.

 

라인 별로 수정 사항을 보내보면 어떨까?

github 커밋 중 코드 부분

이에 첫 번째로 떠올린 방법은 앞서 말한 방식의 구현적 어려움때문에 고려된 방식인데, 마치 Github에서 프로젝트를 하다보면 위와 같은 코드 리뷰 화면을 마주할 수가 있는데 이 방식을 차용해서 이력서의 내용을 각 라인별로 구분한 다음 해당 라인을 선택하여 요청사항을 보내도록 하는 방식이었습니다.

이러한 방식으로 하게되면 특정 라인에 대한 인덱스를 정확히 집어낼 수가 있기 때문에 전체 이력서에서 해당 부분을 찾아 수정하는 것이 더욱 정확해질 수 있게 되는 논리입니다.

 

하지만 이 방식은 백엔드 쪽에서 데이터를 보낼 때 데이터를 라인별로 보내야 하며, 프론트 쪽에서도 이를 보여주는 방식이 굉장히 까다로울 수 있기 때문에 아이디어 단계에서 끝이 났습니다.

 

ChatGPT는 어떻게 처리하고 있지?

위 아이디어가 사실상 불가능 함을 알게되자 문득, 맨 처음에 생각했던 ChatGPT는 그 특정 부분을 정확히 위치까지 인지하고 있는지가 궁금해졌습니다.

 

수미 상관 구조의 시에서 중복되는 줄을 드래그 한 다음 '이 부분만 살짝 바꿔서 다시 시를 보내줘'라고 요청을 해보았습니다.

 

 

예상한대로(?) 저는 마지막 문단의 "차가운 바람은 나무 사이를 지나네."라는 부분을 선택한 것인데 GPT는 첫 번째 문단 중에서 제가 선택한 문장과 같은 문장까지도 수정을 해버렸습니다.

두 부분 모두 "서늘한 바람은 나무 사이를 스치네"로 변한 모습

 

이렇듯 다른 경우에 대해서도 테스트를 진행해보았는데, 결론은 ChatGPT에서도 정확히 그 문장 혹은 단어의 위치까지 고려가 되지는 않는 다는 것을 알게되었고 이 방식으로 진행함에 있어서 더욱 자신감을 얻어 이 방식으로 우선 개발을 진행하고, 해당 문제는 이후에 고려하기로 결정하였습니다.

 

2. 요구사항 정리

이 방식으로 진행함에 있어서 다른 여러 UI/UX적인 요소를 고려해볼 수도 있겠지만, 사실 ChatGPT가 이미 잘 표현을 하고 있는 부분이라고 생각을 했습니다.

 

UI적으로도 깔끔하고 직관적이며, UX적으로 봤을 때 드래그를 하면 버튼이 나타나고 이 버튼을 눌렀을 때 자동으로 입력값에 반영이 되기 때문에 굉장히 사용자 입장에서 편하다고 할 수 있습니다.

 

요구사항을 간단히 정리해보면 다음과 같습니다.

사용자가 수정하고 싶은 텍스트를 드래그하여 선택하면 입력창 부분에 표시되고 전송버튼을 누르면 입력값과 함께 선택한 내용이 같이 보내진다.

 

3. 첫번째 시도: text-selection-react

이전에 이것과 비슷한 기능조차 구현해본 적이 없었기 때문에 검색을 조금 해 보았는데 생각보다 관련된 레퍼런스를 많이 찾을 수 없었습니다.

 

겨우 겨우 찾아낸 라이브러리는 'text-selection-react'라는 라이브러리였는데 Github 스타수는 34개 남짓이었으며 4년 전에 업데이트를 멈춘 라이브러리 였습니다. 그렇기 때문에 사용하기가 조금 그랬긴 하지만 막상 구현된 데모를 보니 하이라이트 기능도 있고 드래그 시 팝업으로 뜨는 버튼도 괜찮아 도입하기에 좋아보여 우선 사용해보았습니다.

 

해당 라이브러리에서 제공하는 handler 속성이 있어 드래그 한 텍스트 내용을 상태로 관리하여 입력 칸에 표시될 수 있도록 구현하였습니다.

 

여기까지만 봤을 때 상당히 만족스러운 기능을 하는 것처럼 보일 수 있지만 몇가지 문제가 존재했습니다.

 

(1) 하이라이트가 남아있는 문제

한 번 드래그 후 해당 텍스트를 선택하면 형광펜으로 칠한 효과를 주게 되는데 다른 텍스트를 선택하게 되었을 때 하이라이트가 그대로 남아있는 문제가 있었습니다.

다른 줄을 선택하고 다시 다른 줄을 선택해도 하이라이트가 지워지지 않음

 

(2) 예전 라이브러리이기 때문에 @types 패키지가 존재하지 않아 생기는 import 오류

해당 문제는 해당 라이브러리에서 타입스크립트를 위한 라이브러리를 별도로 마련해주지 않아 생기는 타입오류였기 때문에 아래와 같이 문제를 해결할 수는 있었습니다.

declare module "text-selection-react" {
  import { ReactNode } from "react";

  export interface TextSelectorEvent {
    text: string;
    handler: (element: HTMLElement, selectedText: string) => void;
  }

  export interface TextSelectorProps {
    events: TextSelectorEvent[];
    color?: string;
    colorText?: boolean;
    unmark?: boolean;
    unmarkText?: string;
  }

  const TextSelector: React.FC<TextSelectorProps>;

  export default TextSelector;
}

 

 

(3) 페이지 렌더링 시 생기는 오류

 

(1), (3)번 문제는 사실 라이브러리에서 제공하는 컴포넌트 속성을 조정하여 하이라이트 기능을 그냥 꺼버리는 것도 가능했겠지만 사용자가 수정하려는 부분이 어디인지 hint를 주는 것 자체는 괜찮은 것 같아 이 기능은 그대로 가져가고 싶은 욕심이 있었고 해당 라이브러리를 커스텀해서 사용하기에는 너무 코스트가 큰 것 같아 그냥 아예 다른 라이브러리가 있는지 모색하는 쪽으로 방향을 바꾸었습니다.

 

4. 두 번째 시도: JavaScript API - getSelection()

사실 'text selection'과 관련된 모든 라이브러리들은 결국 모두 텍스트를 선택하여 관리하는 Web API를 사용하고 있을 것이라는 생각이 문득 들었고 이와 관련하여 찾아보았더니 getSelection()이라는 Web API가 해당 기능을 수행해주는 것 같았습니다.

 

먼저 아무래도 라이브러리가 아닌 Web API를 사용하는 것이기 때문에 더 정교한 요구사항 정리가 필요할 것 같습니다.

  1. 사용자가 드래그한 부분에 대한 정보를 가져온다.
  2. 해당 정보로부터 텍스트만 추출한다.
  3. 드래그 위치에 대한 정보를 추출하여 해당 위치에 팝오버 버튼 컴포넌트를 띄운다.
  4. 버튼을 누르면 해당 텍스트를 setState하여 해당 상태를 보여주는 곳에서 재렌더링이 되면 그 글씨가 보여지게 된다.
  5. 현재 선택된 텍스트에 대해 하이라이트를 적용한다.
  6. 다시 다른 텍스트를 드래그하여 선택하거나 메세지 입력 쪽에서 삭제 버튼을 누르면 상태가 초기화되고 하이라이트 색칠이 사라지게 한다.

 

사용법의 경우 MDN 문서에 굉장히 친절하게 설명되어 있어 곧바로 만들어보도록 하겠습니다.

 

자 우선 선택된 텍스트를 상태로 관리를 해주어야 하기 때문에 이를 위한 상태를 하나 만들어줍니다.

const [selectedText, setSelectedText] = useState<string>();

 

그리고 getSelection()을 사용하기 위해서 컴포넌트 마운트 시에 등록할 이벤트 리스터를 useEffect() 안에 넣어주도록 하겠습니다. 내용은 우선 가장 기본 예제를 참고하여 만들었습니다.

useEffect(() => {
  document.addEventListener("selectionchange", () => {
    const activeSelection = document.getSelection();
    const text = activeSelection?.toString();
    setSelectedText(text);
  });
}, []);
  
console.log(selectedText);

 

그러면 콘솔을 찍어봐서 데이터가 잘 저장되고 있는지 확인해봅시다.

 

텍스트 드래그를 마우스 이벤트로 계속 감지하고 있다가 드래그 이벤트가 변경될 때마다 상태 변경이 적용이 되어 콘솔을 봤을 때 데이터가 잘 업데이트 되고 있음을 볼 수 있습니다. 여기서 저는 엄청난 가능성을 보고 작업을 이어갈 수 있었습니다.

 

텍스트를 가져오는 것이 어떻게 보면 가장 중요한 것이기 때문에 이 부분이 사실 가능하면 다른 기능은 이후에 고려해도 됩니다.

 

그럼 이제 드래그 된 텍스트의 위치 정보와 하이라이트로 칠하는 기능이 구현된다면 해당 요구 기능은 마무리 될 수 있을 것 같습니다.

 

드래그 정보로부터 텍스트만을 추출할 수 있었던 이유는 getSelection()을 통해 가져온 객체에 toString() 적용하여 텍스트로 가져왔던 것이었는데 그렇다면 getSelection()으로 가져온 객체 자체에 대한 내용을 한 번 확인해 보도록 하겠습니다.

getSelection()으로 얻어진 정보에 대한 콘솔 로그

 

예상대로 Selection이라는 객체에 대한 정보를 담고 있는 내용을 포함하고 있었습니다. 하지만 위 내용을 보면 알 수 있는데 내가 선택한 부분의 위치(position)와 관련된 정보는 현재 제시된 정보 중에서는 딱히 이렇다 할 만한 속성이 없다는 것을 알게되었습니다.

 

또 Web API에서 이와 관련되어 이것저것 찾아보던 중 selection 기능 중에 getRangeAt() 이라는 메서드가 존재하는데 이것이 우리의 문제를 해결해줄 수 있는 부분이 있는 것 같아 이를 사용해보기로 했습니다.

 

드래그를 여러 군데 할 수 있는 브라우저가 아니라면 getRangeAt(0)과 같이 사용해야 현재 선택된 범위를 나타내는 객체를 반환받을 수 있습니다. 그럼 콘솔을 찍어봐서 어떤 정보를 리턴해주는지 확인해봅시다.

console.log(activeSelection?.getRangeAt(0));

 

아직까지는 크게 의미있는 정보를 확인할 수는 없습니다.

 

여기서 체이닝 메서드 중에 getBoundingClientRect()라는 메서드로 드래그 박스에 대한 정보를 가져올 수 있습니다!

console.log(activeSelection?.getRangeAt(0).getBoundingClientRect());

 

드디어 제가 원하는 값을 찾아내었습니다!

 

이 값들이 정확히 어떤 정보를 의미하는지 알아내기 위해 드래그를 이렇게도 해보고 저렇게도 해보면서 콘솔에 찍힌 값을 확인해 보았는데 드래그를 왼쪽에서부터 할 때와 오른쪽에서부터 할 때 값이 유의미하게 달라지는 것을 확인하였습니다.

 

오른쪽에서부터 드래그를 하면 x값이 감소하지만 왼쪽에서부터 시작하면 x값이 그대로 있는 것을 확인할 수 있었습니다. 이것이 의미하는 바는 x 값은 내가 드래그를 한 박스가 있다고 했을 때 왼쪽 부분의 x좌표 인 것이고 그렇다면 나머지 y, top, width 등의 값들은 너무 뻔한 것들이게 됩니다.

 

 

4. 드래그 위치에 Popover 버튼 컴포넌트 띄우기

자 이제 위치 정보를 얻어내는 방법을 알아냈으니 해당 위치에 팝오버 버튼을 띄우는 것을 구현해보도록 하겠습니다.

 

일단 버튼 구현체를 먼저 만들어주겠습니다.

 

버튼의 상세 요구는 아래와 같습니다.

  • 마우스를 올려놓으면 ToolTip이 나오면서 특정 문구가 뜨도록 한다.(ex. 수정)
  • 클릭 시 setSelectionText()가 동작할 수 있는 핸들러 함수를 추가한다.
  • 버튼에는 아이콘이 들어가며 "수정"과 관련된 연필 아이콘을 사용한다.

 

위 요구사항을 반영하는 컴포넌트 모습은 아래와 같습니다.

<p className="w-[44px] h-[30px] ">
    <Button className="flex items-center w-full h-full px-2 text-black bg-white border shadow-2xl rounded-3xl hover:bg-gray-100">
      <PencilLine className="w-5 h-5" />
    </Button>
</p>

 

툴팁을 구현하기 위해 shadcn으로부터 가져와 사용하려고 했으나 툴팁의 위치가 화면 상단 왼쪽에 고정되는 문제가 있어 이 부분은 보류하기로 하였습니다.

<TooltipProvider>
  <Tooltip>
    <TooltipTrigger>
      <p className="w-[44px] h-[30px] ">
        <Button className="flex items-center w-full h-full px-2 text-black bg-white border shadow-2xl rounded-3xl hover:bg-gray-100">
          <PencilLine className="w-5 h-5" />
        </Button>
      </p>
    </TooltipTrigger>
    <TooltipContent>
      <p>수정하기</p>
    </TooltipContent>
  </Tooltip>
</TooltipProvider>

 

구현된 모습은 아래와 같습니다.

 

자 이제 이 버튼의 위치를 앞서 얻은 position 정보를 기반으로 절대적 위치에 위치할 수 있도록 해보겠습니다.

{selectedText && position && (
  <div
    style={{
      transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
    }}
    className="absolute top-0 left-0 w-[44px] h-[30px] "
  >
    <PencilLine className="flex items-center w-full h-full px-2 text-black bg-white border shadow-2xl rounded-3xl hover:bg-gray-100" />
  </div>
)}

 

이제 아래와 같이 팝오버 버튼이 잘 뜨는 것을 확인할 수 있습니다.

 

 

하지만 여전히 몇 가지 문제가 남아있습니다.

 

1. 드래그하기 시작한 줄의 첫 글자의 x값 위치를 가져오는 것이 아니라 영역 전체를 따졌을 때 가장 왼쪽에 위치 x 값을 가져오기 때문에 아래 사진과 같이 버튼이 원치 않는 곳에 위치하게 되는 문제

 

 

2. 스크롤을 반영하지 않아 스크롤을 내려 드래그를 하면 정확한 위치에 버튼이 위치하지 못하고 있는 문제

 

 

1번 문제를 해결하기 위해서는 버튼의 x 값을 영역 중간에 설정하는 방식을 생각해볼 수 있습니다. 실제로 ChatGPT를 제외한 많은 다른 곳에서는 이렇게 띄워주는 것을 볼 수 있었습니다. 이때 버튼 자체의 길이를 고려해 주어야 하며 설정한 x값에 width/2의 값을 더해주면 됩니다.

  setPosition({
    x: rect.left + rect.width / 2 - 44 / 2,
    y: rect.top,
    width: rect.width,
    height: rect.height,
});

 

 

그리고 2번 문제를 해결해 주기 위해서 우선 position을 설정하는 부분에다가 현재 스크롤을 고려하도록 하는 부분을 추가해주면 됩니다.

 

그리고 마지막으로 지금 현재는 계속 마우스 드래그를 감지하면서 값을 조정하고 있기 때문에 성능을 조금 개선하기 위해 드래그를 시작하는 이벤트와 마우스를 놓는 이벤트를 감지하여 이를 기반으로 값을 조정할 수 있도록 수정해보겠습니다.

 

  useEffect(() => {
    document.addEventListener('selectstart', () => {
      setDragState('selecting');
      setSelectedText(undefined);
    })
    
    document.addEventListener('mouseup', () => {
      const activeSelection = document.getSelection();
      if (!activeSelection) return;

      const text = activeSelection?.toString();
      if (!text) {
        setDragState("ready");
      }

      const rect = activeSelection.getRangeAt(0).getBoundingClientRect();

      setSelectedText(text);
      setPosition({
        x: rect.left + rect.width / 2 - 44 / 2,
        y: rect.top + window.scrollY - 30,
        width: rect.width,
        height: rect.height,
      });
    })
  }, []);

 

위 코드를 보면 selectstart와 mouseup 이벤트를 설정하여 원하는 대로 이벤트 감지를 딱 두 시점에만 하도록 할 수 있었습니다.

 

 

자 그럼 이제 버튼을 눌렀을 때 setSelectedText가 설정되도록 하는 기능과 하이라이트 기능만 넣어주면 됩니다.

 

그 전에 코드를 조금 정리해주도록 하겠습니다.

  useEffect(() => {
    document.addEventListener("selectstart", onSelectStart);
    document.addEventListener("mouseup", onMouseUp);
  }, []);

 

별도의 함수로 빼서 콜백 함수를 등록해주는 방식으로 코드를 정리해주었고, useEffect에서 이벤트 리스너를 사용할 때 중요한 것이 컴포넌트가 언마운트 될 때 해당 이벤트 리스너 등록을 해제해주어야 하는 부분을 반드시 추가해주어야 합니다.

 

그렇게 하지 않으면 해당 컴포넌트가 사용되지 않는 다른 페이지로 넘어가도 해당 이벤트 리스너가 계속해서 돌아가고 있는 상황이 발생할 수도 있고 그렇기에 자원을 계속해서 잡고 있는 문제 또한 존재합니다.

 

  useEffect(() => {
    document.addEventListener("selectstart", onSelectStart);
    document.addEventListener("mouseup", onMouseUp);

    return () => {
      document.removeEventListener("selectstart", onSelectStart);
      document.removeEventListener("mouseup", onMouseUp);
    };
  }, []);

 

위와 같이 useEffect의 cleanup 함수를 사용하여 자원이 언마운트 시마다 cleanup 되도록 합니다.

버튼을 눌렀을 때 selection의 텍스트가 인풋 쪽에 표시되도록 하려고 했는데 useState를 써도 useRef를 써도 버튼을 누르는 순간 다시 초기화가 돼서 표시가 되지 않는 문제가 있었습니다.

 

그 이유는 toggleRefuseRef를 사용하고 있어서 리렌더링을 트리거하지 않기 때문입니다. useRef의 변경은 리렌더링을 유도하지 않기 때문에 UI가 즉시 업데이트되지 않는 것입니다.

 

 

 

 

 

 

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.