
"textarea 영역이 3줄까지 늘어나고 그 이상은 스크롤이 되었으면 좋겠어요!"
UI 요구사항 중 자주 요청받는 기능 중 하나인 특정 입력창 늘리기입니다.
실무에서는 주로 useRef를 사용하는 방식을 주로 활용합니다. 하지만 이 외에도 canvas를 활용하는 방법을 학습하여 기록하고자합니다!
import { ChangeEvent, useRef, useEffect } from "react";
import cx from "./cx"
import { measureLines } from "@/service/util"
const TextBox3 = () => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const cloneRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const elem = textareaRef.current;
const cloneElem = cloneRef.current;
if(!elem || !cloneElem) return;
const handlerInput = () => {
const val = elem.value;
cloneElem.value = val;
//최소 3줄, 최대 15줄
elem.rows = Math.min(Math.max(Math.ceil(cloneElem.scrollHeight / cloneElem.clientHeight), 3), 15);
}
elem.rows = 3;
elem?.addEventListener('input', handlerInput);
return () => {
elem?.removeEventListener('input', handlerInput);
}
},[])
return (
<>
<h3>
#1<sub>controlled. clone elem</sub>
</h3>
<div className={cx('container')}>
<textarea className={cx('clone')} ref={cloneRef} readOnly />
<textarea className={cx('textarea')} ref={textareaRef} />
</div>
</>
)
}
export default TextBox3;
.TextBoxes {
.container {
width: 50vw;
position: relative;
}
.textBox {
box-sizing: border-box;
width: 100%;
resize: none;
overflow-y: scroll;
}
.clone {
box-sizing: border-box;
width: 100%;
resize: none;
overflow-y: scroll;
position: absolute;
top: 0;
left: -9999;
z-index: -1;
visibility: hidden;
opacity: 0;
}
}
import { ChangeEvent, useRef, useEffect } from "react";
import cx from "./cx"
import { measureLines } from "@/service/util"
const TextBox2 = () => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const elem = textareaRef.current;
if(!elem) return;
const handlerInput = () => {
const val = elem.value;
const lines = Math.min(Math.min(measureLines(elem, val),3), 15);
elem.rows = lines;
}
elem.rows = 3;
elem?.addEventListener('input', handlerInput);
return () => {
elem?.removeEventListener('input', handlerInput);
}
},[])
return (
<>
<h3>
#2<sub>uncontrolled. canvas</sub>
</h3>
<div className={cx('container')}>
<textarea ref={textareaRef} className={cx('textarea')} />
</div>
</>
)
}
export default TextBox2;
const measureLines = (elem: HTMLTextAreaElement, val: string) => {
// textarea 요소나 값이 없으면 줄 수는 0
if (!elem || !val) return 0;
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
// textarea의 실제 렌더링 스타일을 가져옴
const style = window.getComputedStyle(elem);
// canvas context를 얻지 못한 경우 안전하게 0 반환
if (!context) return 0;
// textarea와 동일한 폰트 스타일을 canvas에 적용
// → 실제 화면에서의 텍스트 너비와 최대한 동일하게 측정하기 위함
context.font = `${style.getPropertyValue('font-size')} ${style.getPropertyValue('font-family')}`;
// 개행(\n) 기준으로 문자열을 분리한 뒤,
// 각 줄이 textarea 너비를 기준으로 몇 줄로 wrapping 되는지 계산
const measuredLines = val.split('\n').reduce((totalLines, currentLine) => {
// 현재 줄을 한 줄로 쭉 나열했을 때의 픽셀 너비 측정
const textWidth = context.measureText(currentLine).width;
// textarea 너비로 나누어 실제 줄 수 계산
const wrappedLineCount = Math.max(
Math.ceil(textWidth / elem.offsetWidth),
1
);
// 전체 줄 수에 현재 줄에서 발생한 줄 수를 누적
return totalLines + wrappedLineCount;
}, 0);
// 최종 계산된 총 줄 수 반환
return measuredLines;
};
Canvas를 활용한 방식은 DOM에 값을 추가하지 않기 때문에 Reflow를 방지할 수 있다는 장점이 있습니다. 또한 레이아웃 기반의 계산이 아닌 canvas를 통해 계산하기 때문에 이벤트 비용적으로도 효율적입니다.
하지만 ${style.getPropertyValue('font-size')} ${style.getPropertyValue('font-family')}; 에서 요소의 글자 크기와 폰트가 잘못 할당 될 경우, 화면의 요소와 다르게 계산되어 줄넘김이 이상하게 동작할 수 있습니다.