티스토리 뷰
어느 평화로운 날, 새로운 요구사항이 생겼습니다.
관리자 코멘트 입력란에 '링크 삽입' 기능 추가해 주세요.
코멘트 입력란은 textarea를 사용 중이었는데, 말 그대로 텍스트만 입력됩니다. 어떻게 구현해야 될지 몰랐지만 일단 안된다고 대답하고 좀 더 고민해 봤습니다.
링크를 삽입하는 방법이 여러 가지 떠올랐는데,
- 링크를 a 태그로 감싸서 텍스트로 저장한 후, HTML로 파싱하여 보여준다.
- 코멘트 입력란을 현재 블로그 기능에 사용 중인 웹 에디터로 교체한다.
- 마크다운으로 입력 후 JSX로 변환한다.
위의 방법 모두 검색해 보면 관련 라이브러리가 있습니다. 하지만 짧은 코멘트를 위해 라이브러리를 설치한다고? 링크 많이 넣어봤자 2, 3개일 텐데, 마크다운이나 HTML 파싱 기능을 통째로 넣는 걸 납득할 수 있습니까?
차마 그러지 못해서 링크 삽입을 직접 구현했습니다.
✏️ 기능 구체화
코멘트를 입력할 때는 textarea를 사용하고, 코멘트를 저장한 후 보여줄 때는 div를 사용했습니다.
- 사용자가 텍스트를 붙여넣기(Paste)하면, 입력된 텍스트가 URL인지 아닌지 판단한다.
- URL이 아니면 그냥 입력한다.
- URL이면 마크다운 문법처럼 (Caption)[URL] 형태로 변환하여 입력한다.
- 붙여넣기 시, 사용자가 선택한 텍스트가 있는지 확인한다.
- 선택한 텍스트가 있으면, 현재 커서 위치에 (선택한 텍스트)[URL] 형태로 변환하여 입력하고 선택한 텍스트는 삭제한다.
- 선택한 텍스트가 없으면, 현재 커서 위치에 (URL)[URL] 형태로 변환하여 입력한다.
- 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 형태이면 마크다운에서 링크를 나타내는 문법처럼 텍스트를 입력합니다.
- 문법: (Caption)[URL]
- 예시: (네이버)[https://www.naver.com/]
변환한 텍스트를 현재 커서 위치에 입력하기 위해 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);
}
}}
/>
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>
- String.replace 메소드를 사용하여 전체 텍스트에서 (Caption)[URL] 부분을 찾습니다.
- replace로 받은 caption과 url로 Link element를 생성하고,
- 이전 텍스트와 element를 순서대로 배열에 저장합니다.
- 이때 다음 시작 인덱스를 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: 전체 문자열
✨ 결과
위의 과정을 거쳐 관리자가 입력한 링크가 텍스트에 잘 삽입되었습니다. 다른 에디터에 비하면 허접한 링크 삽입 기능이지만, 관리자분들이 불편함 없이 잘 사용하길 바라며.. 이만 줄이겠습니다. 읽어주셔서 감사합니다 :)
'💪 챌린지' 카테고리의 다른 글
이미지 크기를 조정하고 Base64로 인코딩하기 (0) | 2024.11.01 |
---|---|
[후기] 면접 단골 질문 - 이벤트 루프 다시 돌아보기 (2) | 2024.09.30 |
[2024 우테코] 그래서, 프리코스에서 뭘 배웠니 | 후기 (0) | 2023.11.16 |
- Total
- Today
- Yesterday
- 코어자바스크립트
- Unsplash
- python
- 백트래킹
- p5js
- React
- 이벤트루프
- 코테
- 다이나믹프로그래밍
- backtracking
- Spotify
- React-native
- React.js
- flutter
- 알고리즘
- 코드분석
- rn
- nodeJS
- 문제풀이
- 비동기
- dfs
- fetch
- DP
- node.js
- 코딩테스트
- javascript
- 파이썬
- 백준
- Python3
- 프로그래머스
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |