Enter로 새 문단을 만들었는데도 캐럿이 돌아간 이유 (2차 재현)
전에 쓴 글에서 이 버그를 Enter 키 하나로 고쳤다고 정리했다. 그런데 그다음 실제로 발행한 글 2건에서 똑같은 증상이 그대로 나왔다.
다시 재현된 증상
발행된 글의 HTML을 직접 열어 확인했다.
<p class="se-text-paragraph ...">
<span>...절차를 빠뜨리지 않아야 합니다.</span>
<span><a href="https://blog.naver.com/...">https://blog.naver.com/...</a></span>
<span>이 글은 일반적인 정보이며 법률 자문이 아닙니다...</span>
</p>
세 개의 <span>(앞 문장·링크·면책 문구)이 여전히 <p> 태그 하나에 다 들어 있었다. Enter를 눌러서 문단을 나눴다고 생각했는데, 결과물은 1차 수정 전과 똑같았다.
코드를 다시 따라가 본다
문제의 함수는 pasteLink()였다.
await cdp([...caretToEndActions(), { a: "key", key: "Enter" }, { a: "wait", ms: 200 }]);
await cdp([...caretToEndActions(), { a: "paste" }, { a: "wait", ms: 1500 }]);
Enter를 누른 다음, 붙여넣기 전에 caretToEndActions()를 다시 호출하고 있었다. 이 함수는 이렇게 생겼다.
function caretToEndActions() {
const last = lastBodyParagraph() ?? pick("body");
if (!last) return [];
const p = caretEndPoint(last.el);
return [
{ a: "click", x: p.x, y: p.y },
{ a: "wait", ms: 200 },
{ a: "endOfDoc" },
{ a: "wait", ms: 150 },
];
}
lastBodyParagraph()가 진짜 원인이었다.
function lastBodyParagraph() {
let last = null;
for (const { doc } of allDocs().docs) {
for (const wrap of deepQueryAll(doc, ".se-components-wrap")) {
for (const p of wrap.querySelectorAll(
".se-component:not(.se-documentTitle) .se-text-paragraph",
)) {
if (p.getBoundingClientRect().height > 0) last = { el: p, doc };
}
}
}
return last;
}
getBoundingClientRect().height > 0인 문단만 “마지막”으로 인정한다. Enter로 막 만든 빈 문단은 아직 레이아웃이 반영되기 전이라 이 시점에 높이가 0으로 측정될 수 있다 — 그러면 이 함수는 그 문단을 건너뛰고, Enter 이전의 문단을 다시 “마지막 문단”으로 골라버린다. 그 문단 끝을 다시 클릭하니 캐럿이 원래 자리로 돌아가고, 그 상태로 붙여넣으니 링크가 다시 앞 문장에 이어붙는 것이다.
Enter는 이미 제 역할을 하고 있었다
Enter 키는 CDP로 보내는 신뢰된(isTrusted) 입력이라, 브라우저가 실제 키보드 입력과 똑같이 처리한다. 즉 Enter를 누르는 순간 에디터의 실제 캐럿(선택 영역)은 이미 새 문단 안으로 정확히 옮겨져 있다. 문제는 그다음 caretToEndActions()를 또 불러서, DOM 쿼리로 다시 계산한 “마지막 문단”이 그 정확한 캐럿 위치를 덮어써버린 것이다. 즉 캐럿은 이미 맞는 자리에 있었는데, 코드가 그걸 믿지 않고 다시 찾으러 가서 문제를 만들었다.
수정 — 재호출을 없앤다
await cdp([...caretToEndActions(), { a: "key", key: "Enter" }, { a: "wait", ms: 300 }]);
await cdp([{ a: "paste" }, { a: "wait", ms: 1500 }]);
Enter 다음에는 caretToEndActions()를 부르지 않고, 이미 옮겨진 캐럿 위치에 그대로 붙여넣는다. 재클릭이 없으니 lastBodyParagraph()의 높이 필터가 끼어들 여지 자체가 없다.
왜 단위 테스트로 못 잡았나
이 코드는 chrome.debugger(CDP)와 실제 네이버 에디터의 DOM에 의존한다. 문법 검사와 기존 테스트 22개는 전부 통과했지만, 이 함수를 직접 실행해서 검증하는 테스트는 없다 — 브라우저 확장 컨텍스트와 로그인된 페이지 상태를 그대로 흉내 낼 수 없기 때문이다. 그래서 이 수정이 실제로 통하는지는 다음 실제 발행에서 결과 HTML을 다시 열어봐야 확인된다. 이 글을 쓰는 시점에서는 아직 실전 검증 전이다.
정리
| 1차 수정 | 2차 수정 | |
|---|---|---|
| 시도 | Enter 키 삽입 | Enter 다음 재호출 제거 |
| 실제 발행 결과 | 2건 연속 재현(실패) | 검증 대기 |
| 원인 | Enter 자체가 아니라 그 뒤에 또 부른 캐럿 재계산 함수 | — |
“고쳤다”는 확신은 코드를 눈으로 읽었을 때가 아니라, 실제 발행 결과를 다시 열어봤을 때 생긴다는 걸 이번에 다시 확인했다. 같은 파일에 있던 기존 패턴(Enter 키 삽입)을 재사용하는 것도, 그 패턴을 어디에 어떻게 이어붙이는지에 따라 완전히 다른 결과를 낼 수 있다.
자주 묻는 질문
1차 수정은 왜 효과가 없었나요?
1차 수정은 Enter 키를 눌러 새 문단을 만드는 것까지는 맞았습니다. 문제는 그다음이었습니다 — Enter를 누른 뒤에도 '마지막 문단'을 다시 계산하는 함수를 한 번 더 호출했는데, 이 함수가 방금 만든 빈 문단을 인식하지 못해 엉뚱한 위치를 다시 캐럿 위치로 지정해버렸습니다.
왜 방금 만든 문단의 높이가 0으로 측정되나요?
브라우저가 새 <p> 요소를 DOM에 추가한 시점과, 그 요소가 실제로 레이아웃을 계산해 화면에 픽셀로 반영되는 시점 사이에는 시간차가 있습니다. 코드가 요소 추가 직후 곧바로 높이를 확인하면, 아직 레이아웃이 반영되지 않아 0으로 나올 수 있습니다. 코드에는 200ms 대기가 있었지만 이 경우엔 충분하지 않았던 것으로 보입니다.
왜 재클릭 없이 붙여넣어도 안전한가요?
Enter 키 입력은 신뢰된(isTrusted) CDP 이벤트로 보내집니다. 브라우저가 이 입력을 실제 키보드 입력과 동일하게 처리하므로, 에디터의 실제 캐럿(선택 영역)이 새 문단 안으로 정확히 이동합니다. 이 캐럿 위치는 페이지 상태로 유지되므로, 그 뒤에 별도의 클릭 없이 붙여넣기만 보내도 같은 위치에 들어갑니다.