티스토리 뷰

어느 평화로운 날, 새로운 요구사항이 생겼습니다.

관리자 코멘트 입력란에 '링크 삽입' 기능 추가해 주세요.

 

코멘트 입력란은 textarea를 사용 중이었는데, 말 그대로 텍스트만 입력됩니다. 어떻게 구현해야 될지 몰랐지만 일단 된다고 대답하고 좀 더 고민해 봤습니다.

 

링크를 삽입하는 방법이 여러 가지 떠올랐는데,

  1. 링크를 a 태그로 감싸서 텍스트로 저장한 후, HTML로 파싱하여 보여준다.
  2. 코멘트 입력란을 현재 블로그 기능에 사용 중인 웹 에디터로 교체한다.
  3. 마크다운으로 입력 후 JSX로 변환한다.

위의 방법 모두 검색해 보면 관련 라이브러리가 있습니다. 하지만 짧은 코멘트를 위해 라이브러리를 설치한다고? 링크 많이 넣어봤자 2, 3개일 텐데, 마크다운이나 HTML 파싱 기능을 통째로 넣는 걸 납득할 수 있습니까?

 

차마 그러지 못해서 링크 삽입을 직접 구현했습니다.

✏️ 기능 구체화

코멘트를 입력할 때는 textarea를 사용하고, 코멘트를 저장한 후 보여줄 때는 div를 사용했습니다.

  1. 사용자가 텍스트를 붙여넣기(Paste)하면, 입력된 텍스트가 URL인지 아닌지 판단한다.
    1. URL이 아니면 그냥 입력한다.
    2. URL이면 마크다운 문법처럼 (Caption)[URL] 형태로 변환하여 입력한다.
  2. 붙여넣기 시, 사용자가 선택한 텍스트가 있는지 확인한다.
    1. 선택한 텍스트가 있으면, 현재 커서 위치에 (선택한 텍스트)[URL] 형태로 변환하여 입력하고 선택한 텍스트는 삭제한다.
    2. 선택한 텍스트가 없으면, 현재 커서 위치에 (URL)[URL] 형태로 변환하여 입력한다.
  3. USER 단에서 (Caption)[URL]를 Link로 변환하여 보여준다.

💻 구현하기

1️⃣ URL인지 아닌지 판단하기

URL 판단은 URL() 생성자를 사용했습니다. URL 생성자에 URL이 아닌 텍스트를 넣으면 에러를 던지기 때문에 다음과 같이 try-catch문을 사용하여 일반 텍스트일 때와 링크일 때 서로 다른 작업을 할 수 있습니다.

입력한 값은 event.nativeEvent.data에서 가져왔습니다.

<textarea
    value={comment}
    onChange={(e) => {
      try {
        const input = (e.nativeEvent as InputEvent).data;
        new URL(input);
        // URL이면 링크 삽입
      } catch (error) {
        // URL이 아니면
        setComment(e.target.value);
      }
    }}
/>

2️⃣ URL 삽입하기

입력한 텍스트가 URL 형태이면 마크다운에서 링크를 나타내는 문법처럼 텍스트를 입력합니다.

변환한 텍스트를 현재 커서 위치에 입력하기 위해 event.currentTarget.selectionStart 값을 사용했습니다. 텍스트가 랜더링되지 않았어도 selectionStart 값은 입력값의 길이를 포함하고 있기 때문에 그 길이를 뺀 값을 커서 위치로 사용했습니다.

<textarea
    value={comment}
    onChange={(e) => {
      try {
        const input = (e.nativeEvent as InputEvent).data;
        new URL(input);

        // URL이면 링크 삽입
        const inputLength = input?.length ?? 0;
        const link = `(${input})[${input}]`;
        const cursorPosition = e.currentTarget.selectionStart - inputLength;

        setComment(
          (prev) =>
            prev.substring(0, cursorPosition) +
            link +
            prev.substring(cursorPosition),
        );
      } catch (error) {
        // URL이 아니면
        setComment(e.target.value);
      }
    }}
/>

 

textarea에 입력한 모습

3️⃣ 커서 위치 되돌리기

textarea에 URL을 붙여 넣으면 마크다운 문법처럼 잘 입력됐습니다. 하지만 substring을 사용하여 전체 텍스트를 다시 랜더링하니 커서가 맨 마지막으로 이동했습니다. 다음은 커서가 입력한 URL 뒤에 오도록 수정한 과정입니다.

 

useRef를 사용하여 커서 위치를 담을 변수(cursorPositionRef)를 생성합니다. Change 이벤트가 발생하면 이전 커서 위치를 해당 변수에 저장합니다. 그 다음 useEffect를 사용하여 cursorPositionRef 값을 textarea 커서 위치로 설정해줍니다.

 

URL이 아닌 일반 텍스트를 입력할 때도 있으니, 커서 위치를 설정한 후에는 ref를 undefined로 초기화했습니다.

const cursorPositionRef = useRef<number>();

/** 링크 삽입 후 커서가 맨 마지막으로 이동하는 것을 방지 */
useEffect(() => {
    if (!cursorPositionRef.current) return;

    const textarea = document.activeElement; // focused element
    
    (textarea as HTMLTextAreaElement).setSelectionRange(
      cursorPositionRef.current,
      cursorPositionRef.current,
    );
    cursorPositionRef.current = undefined;
}, [editingComment]);
//...

return <textarea
    value={comment}
    onChange={(e) => {
      try {
        const input = (e.nativeEvent as InputEvent).data;
        new URL(input);

        // URL이면 링크 삽입
        const inputLength = input?.length ?? 0;
        const link = `(${input})[${input}]`;
        const cursorPosition = e.currentTarget.selectionStart - inputLength;
        // 커서 위치 저장
        cursorPositionRef.current = cursorPosition + inputLength * 2 + 4;

        setComment(
          (prev) =>
            prev.substring(0, cursorPosition) +
            link +
            prev.substring(cursorPosition),
        );
      } catch (error) {
        // URL이 아니면
        setComment(e.target.value);
      }
    }}
/>

4️⃣ 선택한 텍스트 가져오기

다음은 텍스트를 선택하고 URL을 붙여넣으면 선택한 텍스트가 캡션에 입력되도록 구현한 과정입니다. 사용자의 선택을 감지하기 위해 Select 이벤트를 사용하였습니다.

 const selectionRef = useRef<string>();
 
 return <textarea
    onSelect={() => {
      // 사용자가 선택한 텍스트 저장
      const selected = window.getSelection()?.toString();

      if (selected) selectionRef.current = selected;
      if (selected?.trim() === '') {
        selectionRef.current = undefined;
      }
    }}
    //...
/>

선택한 텍스트를 Change 이벤트가 발생했을 때 사용해야 되기 때문에 커서 위치와 마찬가지로 useRef에 선택한 텍스트를 저장했습니다.

 

Select 이벤트는 입력 필드를 그냥 클릭했을 때도 발생하는데, 이때는 선택한 텍스트가 빈 문자열로 넘어옵니다. 그러면  selectionRef에 undefined를 저장하여 빈 캡션에 URL이 들어가지 않도록 방지했습니다.

 

선택한 텍스트는 Change 핸들러에서 다음과 같이 사용했습니다. 

<textarea
    onSelect={() => {
      // 사용자가 선택한 텍스트 저장
      const selected = window.getSelection()?.toString();

      if (selected) selectionRef.current = selected;
      if (selected?.trim() === '') {
        selectionRef.current = undefined;
      }
    }}
    onChange={(e) => {
      try {
        const input = (e.nativeEvent as InputEvent).data;
        new URL(input ?? '');
        
        const inputLength = input?.length ?? 0;
        const cursorPosition =
          e.currentTarget.selectionStart - inputLength;
        let link = '';
		
        // 선택한 텍스트가 있으면
        if (selectionRef.current) {
          link = `(${selectionRef.current})[${input}]`;
          // 이전 커서 위치 저장
          cursorPositionRef.current =
            cursorPosition +
            inputLength +
            selectionRef.current.length +
            4;
        } else {
          link = `(${input})[${input}]`;
          cursorPositionRef.current =
            cursorPosition + inputLength * 2 + 4;
        }

        setEditingComment(
          (prev) =>
            prev.substring(0, cursorPosition) +
            link +
            prev.substring(
              cursorPosition +
                (selectionRef.current?.length ?? 0),
            ),
        );
      } catch (error) {
        // URL이 아니면
        setEditingComment(e.target.value);
      }
    }}
/>

선택한 텍스트가 있으면 캡션에 input 대신 선택한 텍스트를 넣어서 링크를 만들어줬습니다. 커서 위치도 input 길이 대신 선택한 텍스트 길이를 반영하여 cursorPosition에 저장하였습니다.

 

아래처럼 텍스트가 캡션에 잘 들어가는 걸 확인할 수 있습니다.

텍스트에 링크 삽입

5️⃣ Link로 변환하기

관리자가 입력한 URL을 (Caption)[URL] 형태로 변환하여 저장했으니, 사용자에게 보여줄 때는 (Caption)[URL]를 Link로 변환하여 보여줬습니다. 아래는 String.replace 메소드로 URL을 찾아 Link로 변환하는 함수입니다.

/** 링크는 Link element로 변경 */
const parseLink = (text: string) => {
  const regex = /\((.*?)\)\[(.*?)\]/g; // (Caption)[URL] 형태
  let startIndex = 0;
  const result = [];

  text.replace(regex, (match, caption, url, offset, string) => {
    const element = (
      <Link
      	key={offset}
        to={url}
        className="text-primary underline"
        target="_blank"
        rel="noopener noreferrer"
      >
        {caption}
      </Link>
    );

    result.push(string.substring(startIndex, offset), element);
    startIndex = offset + match.length; // 다음 시작 인덱스 갱신
    return '';
  });
  result.push(text.substring(startIndex)); // 마지막 텍스트 저장

  return result;
};

//...

return <div>{parseLink(comment)}</div>
  1. String.replace 메소드를 사용하여 전체 텍스트에서 (Caption)[URL] 부분을 찾습니다.
  2. replace로 받은 caption과 url로 Link element를 생성하고,
  3. 이전 텍스트와 element를 순서대로 배열에 저장합니다.
  4. 이때 다음 시작 인덱스를 replace의 offset과 match 길이를 더하여 갱신합니다.

'아니, replace에 이런 arguments가 있었어?'라는 생각이 들 정도로 replace가 많은 값들을 넘겨주더라고요. MDN 문서를 살펴보면 replace의 arguments는 다음과 같습니다.

function replacer(match, p1, p2, /* …, */ pN, offset, string, groups) {
  return replacement;
}
  • match: 매칭된 문자열 (예: (네이버)[https://www.naver.com/])
  • p1, p2, /* …, */ pN: regex에 매칭된 문자열에서 n번째 문자열 (예: p1 = 네이버, p2 = https://www.naver.com/)
  • offset: 매칭된 문자열의 시작 인덱스
  • string: 전체 문자열

✨ 결과

위의 과정을 거쳐 관리자가 입력한 링크가 텍스트에 잘 삽입되었습니다. 다른 에디터에 비하면 허접한 링크 삽입 기능이지만, 관리자분들이 불편함 없이 잘 사용하길 바라며.. 이만 줄이겠습니다. 읽어주셔서 감사합니다 :)

USER 페이지 코멘트

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함