에디터를 만들다 보면 눈에 잘 안 보이는데 사용자는 매일 겪는 불편이 있어요. 글을 쓰다 커서가 화면 밖으로 사라지거나, 서식 버튼 한 번 눌렀는데 화면이 홱 튀거나, 버튼을 누르니 방금 잡아 둔 선택 영역이 날아가는 일들이죠. 전부 스크롤과 포커스가 얽힌 문제예요. 저도 원인을 몰라 한참 헤맸는데, 알고 보니 몇 가지 원리만 잡으면 깔끔하게 풀리더라고요. 오늘은 그 삽질담을 상황과 코드로 풀어 볼게요.
24.1 커서가 왜 화면 밖으로 사라져요?
긴 글을 쓰다 보면 커서가 화면 아래로 내려가서 편집 영역 밖으로 가려질 때가 있어요. 브라우저가 알아서 따라와 주기도 하지만, 우리가 편집 영역을 직접 만들면 이 자동 추적이 안 될 때가 많거든요. 그래서 지금 커서가 화면 안에 있는지를 우리가 직접 확인해야 해요.
커서 위치는 선택 영역에서 알아낼 수 있어요. window.getSelection으로 현재 선택을 가져오고, 거기서 getBoundingClientRect로 커서의 화면 좌표를 얻는 거죠.
function cursorRect() {
const sel = window.getSelection();
if (!sel.rangeCount) return null;
return sel.getRangeAt(0).getBoundingClientRect();
}
getRangeAt(0)은 지금 커서가 놓인 범위(range, 선택 구간)예요. 그 범위의 getBoundingClientRect는 커서가 화면의 어느 좌표에 있는지 알려 주고요. 이 좌표만 있으면 커서가 화면 아래로 넘어갔는지 계산할 수 있어요.
24.2 커서가 가려지면 어떻게 따라가게 해요?
커서 좌표를 얻었으니, 이제 그게 보이는 영역 밖으로 나갔는지 보고 필요할 때만 스크롤하면 돼요. 화면 높이는 window.innerHeight로 알 수 있어요.
const r = cursorRect();
if (r && r.bottom > window.innerHeight) {
const node = window.getSelection()
.getRangeAt(0).startContainer;
node.parentElement.scrollIntoView({ block: "nearest" });
}
커서의 아래쪽 좌표가 화면 높이보다 크면 화면 밖으로 나갔다는 뜻이라, 그때만 scrollIntoView로 따라가요. 여기서 핵심은 block nearest예요. 이 옵션을 주면 딱 보일 만큼만 최소로 스크롤해요. 옵션 없이 그냥 부르면 커서를 화면 한가운데로 끌어와서 화면이 크게 출렁이거든요. 필요할 때, 최소로. 이게 자동 스크롤의 요령이에요.
24.3 서식 버튼을 누르면 왜 선택이 풀려요?
이건 정말 흔한 함정이에요. 글자를 드래그로 선택해 두고 굵게 버튼을 누르면, 눌리는 순간 선택이 풀려 버려요. 원인은 포커스예요. 버튼을 누르는 순간 포커스가 편집 영역에서 버튼으로 옮겨 가면서, 편집 영역이 blur(포커스 잃음) 되고 선택도 함께 날아가는 거예요.
막는 방법은 놀랄 만큼 간단해요. 버튼이 눌릴 때 포커스가 애초에 옮겨 가지 않게 막으면 돼요.
toolbar.addEventListener("mousedown", (e) => {
e.preventDefault();
});
mousedown(누르는 순간)에서 preventDefault를 부르면, 브라우저가 포커스를 버튼으로 옮기는 기본 동작을 막아요. 그러면 포커스가 편집 영역에 그대로 남아서 선택도 유지되죠. 버튼의 실제 동작은 click에서 처리하니 서식 적용엔 지장이 없어요. 이 한 줄을 툴바 전체에 걸어 두면 모든 서식 버튼이 선택을 안 잃어요.
24.4 링크 입력창을 띄우면 선택이 날아가요
mousedown 막기로 안 되는 경우도 있어요. 링크를 넣으려고 주소 입력창을 띄우면, 사용자가 그 입력창을 클릭하는 순간 포커스가 진짜로 옮겨 가서 편집 영역의 선택이 사라져요. 이럴 땐 선택을 미리 저장해 뒀다가 나중에 복원해야 해요.
let saved = null;
function saveSelection() {
const sel = window.getSelection();
if (sel.rangeCount) saved = sel.getRangeAt(0).cloneRange();
}
function restoreSelection() {
if (!saved) return;
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(saved);
}
입력창을 띄우기 직전에 saveSelection으로 지금 범위를 cloneRange(복제)해서 보관해요. 사용자가 주소를 다 넣고 확인을 누르면 restoreSelection으로 그 범위를 되살리고요. removeAllRanges로 현재 선택을 비운 뒤 addRange로 저장해 둔 범위를 다시 넣는 거예요. 이러면 입력창을 거쳐 와도 원래 선택했던 그 글자에 링크가 걸려요.
24.5 서식을 넣으면 왜 스크롤이 튀어요?
선택을 지키고 서식을 넣었는데 이번엔 화면이 위아래로 홱 튀는 일이 생겨요. 많은 에디터가 서식을 적용한 뒤 커서를 보이게 하려고 스스로 스크롤을 하는데, 이 자동 스크롤이 과해서 멀쩡히 보이던 화면이 출렁이는 거죠. 저는 이걸 막으려고 스크롤 위치를 붙잡아 두는 방법을 써요.
const y = window.scrollY;
applyFormat();
requestAnimationFrame(() => {
window.scrollTo({ top: y });
});
서식을 넣기 직전에 window.scrollY로 지금 스크롤 위치를 기억해 둬요. 서식을 적용하면서 에디터가 화면을 튀게 만들어도, requestAnimationFrame(다음 화면 그림 직전)에서 scrollTo로 원래 위치로 되돌려 놓는 거죠. 그림이 그려지기 직전에 제자리로 스냅백하니, 사용자 눈엔 화면이 아예 안 움직인 것처럼 보여요. 튐이 0이 되는 거예요.
24.6 코드로 포커스를 줄 때도 화면이 튀어요
사용자가 클릭하지 않았는데 우리가 코드로 편집 영역에 포커스를 줘야 할 때가 있어요. 예를 들어 글쓰기 화면이 열리자마자 커서를 편집 영역에 넣어 두는 경우죠. 그런데 그냥 focus를 부르면, 브라우저가 그 요소를 보이게 하려고 화면을 왈칵 스크롤해 버려요. 화면 위쪽에 있던 제목이나 툴바가 순식간에 밀려 올라가는 거예요.
이건 옵션 하나로 막아요.
el.focus({ preventScroll: true });
focus에 preventScroll: true를 넘기면, 포커스는 주되 따라오는 스크롤은 하지 말라는 뜻이에요. 커서는 편집 영역에 얌전히 들어가고 화면은 조금도 안 움직이죠. 페이지를 처음 열 때나, 저장 후 다시 편집으로 돌아올 때처럼 우리가 포커스를 통제하는 순간엔 이 옵션을 습관처럼 붙여요. 작은 옵션인데 화면이 튀지 않아 인상이 확 깔끔해져요.
24.7 오늘 정리
스크롤과 포커스 문제는 원리만 알면 의외로 단순했어요. 커서가 사라지는 건 getSelection과 getBoundingClientRect로 위치를 재서, 화면 밖일 때만 scrollIntoView block nearest로 최소한만 따라가 해결했죠. 서식 버튼에 선택이 풀리는 건 툴바 mousedown에서 preventDefault로 포커스를 지켜 막았고요. 입력창을 거쳐야 하는 경우엔 범위를 cloneRange로 저장했다 addRange로 복원했어요. 서식 적용 뒤 화면이 튀는 건 scrollY를 기억했다 되돌리는 스냅백으로 잡았죠. 전부 눈에 잘 안 띄지만 매일 쓰는 동작이라, 여기 공들이면 에디터의 손맛이 확 달라져요.