fix(cafe24): 편집기 커서 위치가 어긋나던 문제 — 스크롤 동기화로 전환

증상: 줄 끝에 커서를 두고 엔터를 치면 커서보다 앞쪽에서 줄이 나뉘었다.

원인은 두 층의 크기를 맞추는 방식이었다. 색칠된 <pre> 가 상자 크기를 정하고
textarea 가 그 위를 덮게 했는데, flex 자식에서 `width: max-content` 가 기대대로
적용되지 않아 화면보다 긴 줄이 있으면 textarea 만 내부 스크롤이 가능한 상태가 됐다
(실측 scrollWidth 706 vs clientWidth 686). 캐럿을 따라 textarea 가 내부적으로
스크롤되면 색칠 층은 제자리에 남아, 보이는 글자와 실제 문자 오프셋이 어긋난다.
그래서 커서를 둔 곳과 다른 위치에 개행이 들어갔다.

크기를 맞추려는 시도를 버리고 스크롤 주체를 textarea 로 두고 색칠 층과 줄 번호를
transform 으로 같은 양만큼 이동시킨다. 크기 계산이 아예 없으므로 어긋날 여지가
없다. 줄 번호 칸도 코드 영역 왼쪽의 독립 박스로 바꿔(sticky 제거) 가로 스크롤과
무관해졌다.

검증(브라우저 실측): 가로 스크롤 33px·세로 200px 에서 색칠 층과 줄 번호가 정확히
같은 양만큼 이동, 스크롤 0 에서 두 층의 텍스트 원점 일치, 줄 번호 개수가 줄 수와
일치(10/10, 70/70). 유닛테스트 45개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 13:41:09 +09:00
parent 5284fb1c6e
commit b60690d3e1
5 changed files with 49 additions and 33 deletions
@@ -69,15 +69,13 @@
줄바꿈을 하지 않고(wrap=off) 가로로 스크롤하므로 줄 번호가 항상 맞는다. 줄바꿈을 하지 않고(wrap=off) 가로로 스크롤하므로 줄 번호가 항상 맞는다.
두 요소의 폰트·여백이 다르면 글자가 어긋난다 — CSS 에서 함께 관리한다. #} 두 요소의 폰트·여백이 다르면 글자가 어긋난다 — CSS 에서 함께 관리한다. #}
<div class="cf24-code" id="cf24-code-pc"> <div class="cf24-code" id="cf24-code-pc">
<div class="cf24-code-rows"> <div class="cf24-gutter" aria-hidden="true"><div class="cf24-gutter-inner" id="cf24-gutter-pc"></div></div>
<div class="cf24-gutter" id="cf24-gutter-pc" aria-hidden="true"></div>
<div class="cf24-code-body"> <div class="cf24-code-body">
<pre class="cf24-code-hl" aria-hidden="true"><code id="cf24-hl-pc"></code></pre> <pre class="cf24-code-hl" id="cf24-hl-pc" aria-hidden="true"></pre>
<textarea id="cf24-html-pc" class="cf24-code-input" name="html" wrap="off" <textarea id="cf24-html-pc" class="cf24-code-input" name="html" wrap="off"
spellcheck="false" autocapitalize="off" autocorrect="off">{{ html_pc }}</textarea> spellcheck="false" autocapitalize="off" autocorrect="off">{{ html_pc }}</textarea>
</div> </div>
</div> </div>
</div>
</form> </form>
{% if desc.separated_mobile or desc.mobile_differs %} {% if desc.separated_mobile or desc.mobile_differs %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814g" /> <link rel="stylesheet" href="/static/cafe24.css?v=20260814h" />
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -161,24 +161,30 @@
gutter.dataset.lines = String(count); gutter.dataset.lines = String(count);
} }
// 스크롤 주체는 textarea 다. 색칠 층과 줄 번호를 같은 양만큼 이동시켜
// 글자 위치를 맞춘다(크기를 계산해 맞추는 방식은 긴 줄에서 어긋났다).
function sync() {
var x = ta.scrollLeft, y = ta.scrollTop;
hl.style.transform = "translate(" + -x + "px," + -y + "px)";
if (gutter) gutter.style.transform = "translateY(" + -y + "px)";
}
function repaint() { function repaint() {
// 마지막 줄이 잘리지 않게 개행을 하나 덧붙인다(<pre> 특성). // 마지막 줄이 잘리지 않게 개행을 하나 덧붙인다(<pre> 특성).
if (ta.value.length > HL_LIMIT) hl.textContent = ta.value + "\n"; if (ta.value.length > HL_LIMIT) hl.textContent = ta.value + "\n";
else hl.innerHTML = paintHtml(ta.value) + "\n"; else hl.innerHTML = paintHtml(ta.value) + "\n";
renderGutter(ta.value.split("\n").length); renderGutter(ta.value.split("\n").length);
// 크기는 <pre> 가 정하고 textarea 가 그 위를 덮는다. 다시 칠하는 사이 sync();
// 내부 스크롤이 생겼으면 되돌려 두 층의 정렬을 유지한다.
ta.scrollTop = 0;
ta.scrollLeft = 0;
} }
function refresh() { function refresh() {
// 짧은 소스는 바로 칠해야 줄 번호·상자 크기가 즉시 따라온다. // 짧은 소스는 바로 칠해야 줄 번호가 즉시 따라온다.
if (ta.value.length < 50000) { repaint(); return; } if (ta.value.length < 50000) { repaint(); return; }
clearTimeout(timer); clearTimeout(timer);
timer = setTimeout(repaint, 80); timer = setTimeout(repaint, 80);
} }
ta.addEventListener("scroll", sync);
ta.addEventListener("input", function () { dirty = true; refresh(); }); ta.addEventListener("input", function () { dirty = true; refresh(); });
// Tab 은 포커스 이동이 아니라 들여쓰기로 쓴다. // Tab 은 포커스 이동이 아니라 들여쓰기로 쓴다.
ta.addEventListener("keydown", function (e) { ta.addEventListener("keydown", function (e) {
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814g" /> <link rel="stylesheet" href="/static/cafe24.css?v=20260814h" />
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814g" /> <link rel="stylesheet" href="/static/cafe24.css?v=20260814h" />
{% endblock %} {% endblock %}
{% block content %} {% block content %}
+30 -18
View File
@@ -260,30 +260,30 @@
않는다. 아래 두 선택자에 붙은 속성을 바꿀 때는 반드시 함께 바꿀 것. 않는다. 아래 두 선택자에 붙은 속성을 바꿀 때는 반드시 함께 바꿀 것.
외부 라이브러리를 쓰지 않는다(자체 호스팅 원칙 + CDN 의존 제거). 외부 라이브러리를 쓰지 않는다(자체 호스팅 원칙 + CDN 의존 제거).
════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════ */
/* 스크롤은 **textarea 가** 담당하고, 색칠 층과 줄 번호가 transform 으로 따라간다.
폭·높이를 계산해 맞추는 방식은 flex 안에서 `width: max-content` 가 기대대로
동작하지 않아 실패했다 — 긴 줄이 있으면 textarea 만 내부 스크롤되고 색칠 층은
제자리에 남아, 커서 위치와 보이는 글자가 어긋났다(실측 706 vs 686).
스크롤 동기화 방식은 크기 계산이 아예 필요 없어 어긋날 여지가 없다. */
.cf24-code { .cf24-code {
position: relative; position: relative;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 340px; min-height: 340px;
overflow: auto; /* 가로·세로 스크롤을 여기서 담당 */ overflow: hidden;
border: 1px solid var(--color-subtle-ash, #e5e5e5); border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-lg, 10px); border-radius: var(--r-lg, 10px);
background: var(--color-canvas-white, #fff); background: var(--color-canvas-white, #fff);
} }
.cf24-code-rows { /* 줄 번호 — 코드 영역 왼쪽에 고정된 별도 칸(가로 스크롤과 무관) */
display: flex;
align-items: stretch;
min-width: max-content; /* 가장 긴 줄에 맞춰 넓어진다(줄바꿈 없음) */
min-height: 100%;
}
/* 줄 번호 — 가로로 스크롤해도 왼쪽에 붙어 있게 sticky */
.cf24-gutter { .cf24-gutter {
position: sticky; position: absolute;
left: 0; left: 0;
z-index: 2; top: 0;
flex: 0 0 auto; bottom: 0;
padding: var(--sp-12, 12px) var(--sp-8, 8px); width: 42px;
overflow: hidden;
padding: var(--sp-12, 12px) var(--sp-8, 8px) 0 0;
text-align: right; text-align: right;
background: var(--color-ghost-gray, #f6f8fa); background: var(--color-ghost-gray, #f6f8fa);
border-right: 1px solid var(--color-subtle-ash, #e5e5e5); border-right: 1px solid var(--color-subtle-ash, #e5e5e5);
@@ -292,9 +292,17 @@
white-space: pre; white-space: pre;
} }
.cf24-gutter-inner {
will-change: transform; /* 세로 스크롤을 따라 움직인다 */
}
.cf24-code-body { .cf24-code-body {
position: relative; /* textarea 의 기준 박스 — 크기는 <pre> 가 정한다 */ position: absolute;
flex: 1 0 auto; left: 43px; /* 줄 번호 칸 + 경계선 */
right: 0;
top: 0;
bottom: 0;
overflow: hidden;
} }
/* 두 층의 글자가 정확히 겹치려면 아래 속성이 전부 같아야 한다. /* 두 층의 글자가 정확히 겹치려면 아래 속성이 전부 같아야 한다.
@@ -318,15 +326,19 @@
overflow-wrap: normal; overflow-wrap: normal;
} }
/* <pre> 가 흐름에 남아 본문 칸의 크기를 결정한다. */ /* 색칠 층 — textarea 의 스크롤량만큼 transform 으로 이동한다. */
.cf24-code-hl { .cf24-code-hl {
position: absolute;
top: 0;
left: 0;
width: max-content; width: max-content;
min-width: 100%; min-width: 100%;
pointer-events: none; pointer-events: none;
color: var(--color-rich-black, #0a0a0a); color: var(--color-rich-black, #0a0a0a);
will-change: transform;
} }
/* textarea 는 그 위를 정확히 덮는다(크기 계산을 JS 로 하지 않아도 어긋나지 않음). */ /* textarea 가 실제 스크롤 주체다. */
.cf24-code-input { .cf24-code-input {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -338,7 +350,7 @@
color: transparent; color: transparent;
caret-color: var(--color-rich-black, #0a0a0a); caret-color: var(--color-rich-black, #0a0a0a);
resize: none; resize: none;
overflow: hidden; overflow: auto;
} }
.cf24-code-input:focus { .cf24-code-input:focus {