From 5284fb1c6eeafd27a563d389b9925e0ef86deaf9 Mon Sep 17 00:00:00 2001 From: king Date: Fri, 14 Aug 2026 13:03:54 +0900 Subject: [PATCH] =?UTF-8?q?fix(cafe24):=20=EC=86=8C=EC=8A=A4=20=EB=93=A4?= =?UTF-8?q?=EC=97=AC=EC=93=B0=EA=B8=B0=20=EC=A0=95=EC=83=81=ED=99=94=20+?= =?UTF-8?q?=20=EC=A4=84=20=EB=B2=88=ED=98=B8=C2=B7=EA=B0=80=EB=A1=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=ED=8E=B8=EC=A7=91=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 들여쓰기가 안 되던 원인 인라인 요소가 여러 줄에 걸쳐 있으면 그 구간을 하나의 텍스트 덩어리로 다뤄 첫 줄에만 들여쓰기를 붙이고 있었다. 그래서 가 한 줄에 하나씩 적힌 상세페이지에서 두 번째 부터 1열에 붙어 나왔다. 원문 줄바꿈을 살리면서 각 줄을 현재 깊이로 들여쓰도록 고쳤다. 줄 앞 공백은 렌더링에 영향이 없으므로 안전하다. 상세페이지는 를 한 줄에 하나씩 적어두는 경우가 많고 그 모양이 저자의 의도라, 한 줄로 합치는 것보다 살리는 쪽이 읽기 좋다. 주석도 줄을 강제로 나누지 않게 바꿨다. `` 처럼 바로 뒤 요소를 설명하는 주석이 많아서, 나누면 라벨과 대상이 떨어져 오히려 읽기 나빠진다. 빈 줄은 구획 표시로 한 줄까지 유지한다. 이 과정에서 멱등성이 다시 깨지는 것을 테스트가 잡았다 — 텍스트 끝에 남은 "\n " 조각이 매번 빈 줄로 바뀌고 있었고, 양 끝 공백을 함께 제거하도록 고쳤다. 2) 줄 번호와 가로 스크롤 전문 편집기처럼 줄 번호 칸을 넣었다. 줄바꿈을 허용하면 한 논리 줄이 여러 행이 되어 번호가 어긋나므로, 줄바꿈을 끄고(white-space:pre + wrap=off) 가로로 스크롤한다. 번호 칸은 position:sticky 라 가로로 스크롤해도 왼쪽에 남는다. 크기 계산도 단순해졌다.
 가 흐름에 남아 상자 크기를 정하고 textarea 가
inset:0 으로 그 위를 덮는다 — JS 로 높이를 맞추지 않으니 어긋날 여지가 없다.
색상 계열은 그대로 뒀다(태그 초록·속성 갈색·값 남색·주석 회색).

검증: 유닛테스트 45개 통과(신규 4개 — 모든 줄 들여쓰기, 주석과 요소 붙임,
빈 줄 1개 유지, 실제 상세페이지 모양 멱등). 브라우저 실측: 줄 번호 개수가 줄 수와
일치(18/18, 19/19), 번호와 코드의 세로 위치 일치, 긴 줄에서 편집기 안에서만 가로
스크롤(1920 > 716)되고 페이지는 가로 스크롤 없음, 400px 스크롤 후에도 번호 칸 고정,
textarea 내부 스크롤 0(두 층 정렬 유지).

Co-Authored-By: Claude Opus 5 
---
 app/modules/cafe24/store.py                   | 31 ++++++++--
 .../cafe24/templates/cafe24/_editor.html      | 16 +++--
 .../cafe24/templates/cafe24/products.html     | 31 ++++++----
 .../cafe24/templates/cafe24/schedules.html    |  2 +-
 .../cafe24/templates/cafe24/system.html       |  2 +-
 app/modules/cafe24/tests/test_cafe24.py       | 39 ++++++++++++
 app/static/cafe24.css                         | 59 +++++++++++++++----
 docs/CAFE24_MODULE.md                         | 21 +++++--
 8 files changed, 159 insertions(+), 42 deletions(-)

diff --git a/app/modules/cafe24/store.py b/app/modules/cafe24/store.py
index a12eeec..e4fdc80 100644
--- a/app/modules/cafe24/store.py
+++ b/app/modules/cafe24/store.py
@@ -241,12 +241,29 @@ def _format_html(source: str, indent: str) -> str:
         return indent * min(max(level, 0), _MAX_INDENT)
 
     def flush() -> None:
-        """모아둔 인라인/텍스트를 한 줄로 내보낸다(빈 줄은 버린다)."""
+        """모아둔 인라인/텍스트를 내보낸다.
+
+        원문에 이미 있던 줄바꿈은 **그대로 살린다.** 이미지가 한 줄에 하나씩 적혀
+        있으면 그 모양이 저자의 의도이고, 한 줄로 합치면 오히려 읽기 어려워진다.
+        각 줄마다 현재 깊이로 들여쓴다(줄 앞 공백은 렌더링에 영향이 없다).
+        빈 줄은 연속 한 개까지만 남겨 구획을 유지한다.
+        """
         nonlocal buffer
+        # 양 끝 공백을 함께 제거한다. `"\n"` 만 벗기면 끝에 남은 `"\n  "` 조각이
+        # 빈 줄로 바뀌어 실행마다 빈 줄이 하나씩 늘어난다(멱등 깨짐).
+        # 블록 태그 경계의 공백은 렌더링에 영향이 없으므로 제거해도 안전하다.
         text = buffer.strip()
-        if text:
-            lines.append(pad(depth) + text)
         buffer = ""
+        if not text:
+            return
+        for raw_line in text.split("\n"):
+            line = raw_line.strip()
+            if not line:
+                # 문서 맨 앞이나 빈 줄 뒤에는 빈 줄을 더하지 않는다.
+                if lines and lines[-1] != "":
+                    lines.append("")
+                continue
+            lines.append(pad(depth) + line)
 
     position = 0
     while True:
@@ -259,10 +276,12 @@ def _format_html(source: str, indent: str) -> str:
         position = match.end()
         raw = match.group(0)
 
-        # 주석·DOCTYPE 등은 한 줄 차지
+        # 주석·DOCTYPE 등은 흐름에 그대로 둔다.
+        # 상세페이지에는 `` 처럼 바로 뒤 요소를
+        # 설명하는 주석이 많다. 줄을 강제로 나누면 라벨과 대상이 떨어져 오히려
+        # 읽기 나빠진다. 원문에서 줄이 나뉘어 있었다면 flush 가 그 줄바꿈을 살린다.
         if match.group("comment") or match.group("cdata") or match.group("decl"):
-            flush()
-            lines.append(pad(depth) + raw.strip())
+            buffer += raw
             continue
 
         name = (match.group("name") or "").lower()
diff --git a/app/modules/cafe24/templates/cafe24/_editor.html b/app/modules/cafe24/templates/cafe24/_editor.html
index 69ca46e..a19b20d 100644
--- a/app/modules/cafe24/templates/cafe24/_editor.html
+++ b/app/modules/cafe24/templates/cafe24/_editor.html
@@ -65,12 +65,18 @@
   
 
   {# 색칠된 
 위에 투명한 
+    
+ +
+ + +
+
diff --git a/app/modules/cafe24/templates/cafe24/products.html b/app/modules/cafe24/templates/cafe24/products.html index f115039..3b5c4a4 100644 --- a/app/modules/cafe24/templates/cafe24/products.html +++ b/app/modules/cafe24/templates/cafe24/products.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} {% block content %} @@ -148,27 +148,35 @@ function setupCodeEditor() { var ta = document.getElementById("cf24-html-pc"); var hl = document.getElementById("cf24-hl-pc"); + var gutter = document.getElementById("cf24-gutter-pc"); if (!ta || !hl) return; var timer = null; + + function renderGutter(count) { + if (!gutter || gutter.dataset.lines === String(count)) return; + var out = ""; + for (var i = 1; i <= count; i++) out += i + "\n"; + gutter.textContent = out; + gutter.dataset.lines = String(count); + } + function repaint() { // 마지막 줄이 잘리지 않게 개행을 하나 덧붙인다(
 특성).
       if (ta.value.length > HL_LIMIT) hl.textContent = ta.value + "\n";
       else hl.innerHTML = paintHtml(ta.value) + "\n";
-    }
-    function resize() {
-      // 색칠된 
 는 같은 내용·같은 스타일이라 이것이 진짜 콘텐츠 높이다.
-      // textarea 의 scrollHeight 를 쓰면 브라우저마다 한두 줄 더 잡혀 두 층의
-      // 글자 위치가 어긋난다(실측 830 vs 792).
-      var target = hl.parentElement.scrollHeight;
-      if (target > 0) ta.style.height = target + "px";
-      // 높이가 잠깐 부족했던 사이 내부 스크롤이 생겼으면 되돌린다(두 층 정렬 유지).
+      renderGutter(ta.value.split("\n").length);
+      // 크기는 
 가 정하고 textarea 가 그 위를 덮는다. 다시 칠하는 사이
+      // 내부 스크롤이 생겼으면 되돌려 두 층의 정렬을 유지한다.
       ta.scrollTop = 0;
+      ta.scrollLeft = 0;
     }
+
     function refresh() {
-      // 높이는 색칠 결과에서 나오므로 다시 칠한 뒤에 맞춘다.
+      // 짧은 소스는 바로 칠해야 줄 번호·상자 크기가 즉시 따라온다.
+      if (ta.value.length < 50000) { repaint(); return; }
       clearTimeout(timer);
-      timer = setTimeout(function () { repaint(); resize(); }, 60);
+      timer = setTimeout(repaint, 80);
     }
 
     ta.addEventListener("input", function () { dirty = true; refresh(); });
@@ -184,7 +192,6 @@
     });
 
     repaint();
-    resize();
   }
 
   // ── 편집기 조각을 새로 끼워 넣은 뒤 다시 연결 ──
diff --git a/app/modules/cafe24/templates/cafe24/schedules.html b/app/modules/cafe24/templates/cafe24/schedules.html
index e7017f9..dfb77c4 100644
--- a/app/modules/cafe24/templates/cafe24/schedules.html
+++ b/app/modules/cafe24/templates/cafe24/schedules.html
@@ -1,7 +1,7 @@
 {% extends "erp_base.html" %}
 
 {% block head_extra %}
-
+
 {% endblock %}
 
 {% block content %}
diff --git a/app/modules/cafe24/templates/cafe24/system.html b/app/modules/cafe24/templates/cafe24/system.html
index 1562219..ce59fce 100644
--- a/app/modules/cafe24/templates/cafe24/system.html
+++ b/app/modules/cafe24/templates/cafe24/system.html
@@ -1,7 +1,7 @@
 {% extends "erp_base.html" %}
 
 {% block head_extra %}
-
+
 {% endblock %}
 
 {% block content %}
diff --git a/app/modules/cafe24/tests/test_cafe24.py b/app/modules/cafe24/tests/test_cafe24.py
index cb20f0f..2fb4a80 100644
--- a/app/modules/cafe24/tests/test_cafe24.py
+++ b/app/modules/cafe24/tests/test_cafe24.py
@@ -469,6 +469,45 @@ def test_format_is_idempotent():
     assert store.format_html(store.format_html(once)) == once
 
 
+_REAL_DETAIL = """
+ + + + + + +
""" + + +def test_format_indents_every_line_of_a_run(): + """원문 줄바꿈을 살리고 **모든 줄**을 들여쓴다. + + 예전에는 첫 줄만 들여쓰고 나머지가 1열에 붙어 나왔다. + """ + lines = store.format_html(_REAL_DETAIL).split("\n") + img_lines = [line for line in lines if "` 는 붙여둔다 — 나누면 라벨과 대상이 떨어진다.""" + out = store.format_html(_REAL_DETAIL) + assert "\n\n\n\n\n\n\n") + assert "\n\n" in out + assert "\n\n\n" not in out + + +def test_format_real_detail_is_idempotent(): + once = store.format_html(_REAL_DETAIL) + assert store.format_html(once) == once + + def test_format_collapses_short_blocks(): assert store.format_html("1") == "1" # 길면 나눈다. diff --git a/app/static/cafe24.css b/app/static/cafe24.css index 36b6857..8a3d8b0 100644 --- a/app/static/cafe24.css +++ b/app/static/cafe24.css @@ -264,44 +264,81 @@ position: relative; flex: 1 1 auto; min-height: 340px; - overflow: auto; + overflow: auto; /* 가로·세로 스크롤을 여기서 담당 */ border: 1px solid var(--color-subtle-ash, #e5e5e5); border-radius: var(--r-lg, 10px); background: var(--color-canvas-white, #fff); } +.cf24-code-rows { + display: flex; + align-items: stretch; + min-width: max-content; /* 가장 긴 줄에 맞춰 넓어진다(줄바꿈 없음) */ + min-height: 100%; +} + +/* 줄 번호 — 가로로 스크롤해도 왼쪽에 붙어 있게 sticky */ +.cf24-gutter { + position: sticky; + left: 0; + z-index: 2; + flex: 0 0 auto; + padding: var(--sp-12, 12px) var(--sp-8, 8px); + text-align: right; + background: var(--color-ghost-gray, #f6f8fa); + border-right: 1px solid var(--color-subtle-ash, #e5e5e5); + color: #8c959f; + user-select: none; + white-space: pre; +} + +.cf24-code-body { + position: relative; /* textarea 의 기준 박스 — 크기는
 가 정한다 */
+  flex: 1 0 auto;
+}
+
+/* 두 층의 글자가 정확히 겹치려면 아래 속성이 전부 같아야 한다.
+   줄 번호(.cf24-gutter)도 같은 글꼴 지표를 써야 줄이 맞는다. */
+.cf24-gutter,
 .cf24-code-hl,
 .cf24-code-input {
   margin: 0;
-  padding: var(--sp-12, 12px);
   border: 0;
-  width: 100%;
   box-sizing: border-box;
   font-family: var(--font-geist-mono, ui-monospace, "Consolas", monospace);
   font-size: 12px;
   line-height: 1.6;
-  white-space: pre-wrap;
-  overflow-wrap: break-word;
   tab-size: 2;
 }
 
+.cf24-code-hl,
+.cf24-code-input {
+  padding: var(--sp-12, 12px);
+  white-space: pre; /* 줄바꿈하지 않는다 — 줄 번호가 어긋나지 않게 */
+  overflow-wrap: normal;
+}
+
+/* 
 가 흐름에 남아 본문 칸의 크기를 결정한다. */
 .cf24-code-hl {
-  position: absolute;
-  top: 0;
-  left: 0;
-  min-height: 100%;
+  width: max-content;
+  min-width: 100%;
   pointer-events: none;
   color: var(--color-rich-black, #0a0a0a);
 }
 
+/* textarea 는 그 위를 정확히 덮는다(크기 계산을 JS 로 하지 않아도 어긋나지 않음). */
 .cf24-code-input {
-  position: relative;
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
   display: block;
   background: transparent;
   color: transparent;
   caret-color: var(--color-rich-black, #0a0a0a);
   resize: none;
-  overflow: hidden; /* 높이를 내용에 맞추고 스크롤은 .cf24-code 가 담당 */
+  overflow: hidden;
 }
 
 .cf24-code-input:focus {
diff --git a/docs/CAFE24_MODULE.md b/docs/CAFE24_MODULE.md
index a0145f6..b035724 100644
--- a/docs/CAFE24_MODULE.md
+++ b/docs/CAFE24_MODULE.md
@@ -166,12 +166,15 @@ cafe24_oauth_tokens 저장
 **문법 강조** — 색칠된 `
` 위에 **투명한 `