-
Notifications
You must be signed in to change notification settings - Fork 0
ContentEditable
MOZI에서 Todo의 Title이나 Description같은 요소를 수정할 때 input으로 할 것인가 div 태그의 contentEditable이라는 속성을 사용할 것인가 고민이 있었다.
contentEditable은 HTML요소를 수정 가능하게 만들어주는 속성이다.
HTML5의 모든 엘리먼트는 contenteditable='true'
로 설정함으로써 해당 엘리먼트 내부에 텍스트를 작성할 수 있다.
contentEditable속성은 열거형(enum) 속성인데, true, false 이외에도 inherit을 가질 수 있다. 기본(default)은 부모요소에서 편집가능 여부를 상속받는 inherit이다.
HTMLElement.isContentEditable
와 같이 접근하여 HTMLElement의 contentEditable 속성을 불리언(boolean) 값으로 받아볼 수 있다.
예를 들어 div에 contentEditable 속성을 true로 설정하면 div 요소도 input처럼 입력을 할 수 있게 된다. (노션 페이지 내부 하나의 블록) contentEditable을 사용하는 이유는 웹 에디터를 쉽게 만들기 위함이다. contentEditable을 사용하면 브라우저가 자체적으로 클립보드, 드래그&드롭, 실행 취소, 서식과 같은 기능을 전부 제공해준다.
또한 특정 내용을 편집모드로 수정하기 쉽다는 장점이 있다. 편집 모드 변경시 input이나 textarea로 수정할 필요가 없이 contentEditable 속성만 추가해줘도 편집 모드로 변경할 수 있다. 때문에 div를 input으로 바꿨을 때 발생할 수 있는 스타일 변경을 신경쓰지 않아도 되어 편리하다.
contentEditable은 React에서 사용할 때 꽤나 문제가 많다. input과 동작 방식이 다르기 때문이다. div 태그에 contentEditable 속성을 주면 아래와 같은 오류가 발생한다.
위 에러는 suppressContentEditableWarning 속성을 추가하는 것으로 해결 가능하다.
div 태그에 contentEditable 속성을 주면 먼저 입력시 change event가 아니라 input event가 동작한다. 또한 input이 아니기 때문에 value 값이 없다. 이 때문에 제어 컴포넌트처럼 리액트가 DOM을 제어할 수 없다.
따라서 비제어 컴포넌트 방식으로 contentEditable을 관리해줘야한다.
export const useContentEditable = (initialContent: string) => {
const $contentEditable = useRef<HTMLDivElement>(null);
const [content, _setContent] = useState(initialContent);
const onInput = (event: ChangeEvent<HTMLDivElement>) => {
_setContent(event.target.innerText);
};
const setContent = (newContent: string) => {
if ($contentEditable.current) {
$contentEditable.current.innerText = newContent;
_setContent(newContent);
}
};
useEffect(() => {
setContent(initialContent);
}, []);
return { content, setContent, onInput, $contentEditable };
};
useContentEditable은 DOM에 접근하기 위한 ref와 상태를 저장하기 위한 useState를 추가한다. 상태에는 contentEditable의 innerText가 저장된다.
비제어 컴포넌트이기 때문에 상태 값을 저장할 필요가 없다고 생각될 수 있지만 입력값을 즉각적으로 validation하기 위해 추가했다.
contentEitable은 아니지만 element.focus() 메서드를 사용하여 포커스를 줄 수 있다. 하지만 이렇게 포커스를 주게 되는 경우, contentEditable에 작성된 내용이 있어도 포커스가 맨 앞쪽에 위치한다. 일반적으로 input에 포커스를 주면 작성된 내용의 가장 뒤로 가는 것과는 대조적이다. 따라서 별도의 foucs 함수를 만들어 사용해야한다.
export const focusContentEditableTextToEnd = (element: HTMLElement) => {
if (element.innerText.length === 0) {
element.focus();
return;
}
const selection = window.getSelection();
const newRange = document.createRange();
newRange.selectNodeContents(element);
newRange.collapse(false);
selection?.removeAllRanges();
selection?.addRange(newRange);
};
만약 contentEditable의 innerText의 길이가 0이라면 작성된 내용이 없다는 것이므로 focus를 사용해도 된다. 하지만 작성된 내용이 있는 경우에는 포커스를 가장 뒤쪽에 붙이기 위해 현재 Caret(커서)의 위치를 변경해줘야한다. caret의 위치를 찾고 contentEditable의 range(드래그 된 영역)를 맨 끝으로 이동시킨다. 마지막으로 기존 range를 삭제하고 새로운 range를 추가함으로써 contentEditable 내용 끝에 커서를 위치 시킬 수 있다.