feat(cafe24): 문법 강조 편집기 + 소스 자동 정리, 목록 550px

1) 검색 입력란이 거대했던 버그

.cf24-filters 가 세로 flex 인데 .cf24-search 에 flex:0 1 320px 을 줬다. 세로
방향에서는 flex-basis 가 '높이'로 적용돼 입력란이 320px 짜리 상자가 됐다.
height:32px 로 한 줄에 고정했고, 그만큼 목록이 더 보인다.

2) 목록 550px

요청대로 왼쪽을 550px 로 넓혔다. 남은 폭(약 290px)이 상품명 몫이라 대부분 한 줄에
들어가고, 수정일도 월-일 시:분까지 보여준다. 컬럼은 번호·상품명·진열·판매·수정 5개
그대로다.

3) 문법 강조 편집기

색칠된 <pre> 위에 투명한 <textarea> 를 겹치는 방식으로 직접 구현했다. 외부
라이브러리를 쓰지 않는 이유는 자체 호스팅 원칙이다(CDN 의존 금지). 태그·속성이름·
속성값·주석·기호를 색으로 구분하고 Tab 은 들여쓰기로 쓴다.

두 층의 글자가 어긋나지 않으려면 폰트·줄높이·padding·줄바꿈 규칙이 완전히 같아야
한다. 특히 높이는 <pre> 의 scrollHeight 를 기준으로 textarea 에 지정한다 —
textarea 의 scrollHeight 를 쓰면 두 줄쯤 더 잡혀 어긋난다(실측 830 vs 792,
브라우저에서 확인 후 수정). 20만 자를 넘으면 강조를 끈다.

4) 소스 정리(포맷)와 저장 반영

store.format_html 을 추가했다. 화면 표시와 저장에 같은 함수를 쓰므로 화면에서 본
정리된 소스가 그대로 카페24에 저장된다.

렌더링을 바꾸지 않는 것을 최우선으로 했다. HTML 에서 공백은 의미가 있어서 인라인
요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다 — 이미지 사이가 벌어지는 고전적인
사고다. 그래서 블록 요소 경계에서만 줄을 나누고 img·br·span·a 는 블록 목록에서
일부러 뺐다. <style>·<script>·<pre>·<textarea> 안쪽은 한 글자도 건드리지 않는다.
내용이 한 줄뿐인 짧은 블록은 다시 한 줄로 합친다.

멱등성을 테스트로 고정했다. 처음 구현은 <style> 안 빈 줄이 실행마다 한 줄씩 늘어나
멱등이 깨졌고(테스트가 잡음), 앞뒤 빈 줄을 버리도록 고쳤다. 편집하지 않고 다시
적용해도 저장값이 계속 달라지면 버전 이력이 의미를 잃는다.

닫는 태그가 빠진 HTML 이 흔하므로 들여쓰기 상한(12)을 뒀고, 어떤 이유로든 실패하면
원본을 그대로 돌려준다.

검증: 유닛테스트 41개 통과(신규 8개 — 블록 분리·인라인 보존(이미지 붙음)·style
원문 보존·멱등·짧은 블록 합치기·깨진 HTML 내성·속성값 미변경·정리+인코딩 왕복).
브라우저 실측: 검색란 32px, 목록 550px/편집기 750px, 오버레이 두 층 높이 일치
(편집 전 792=792, 20줄 추가 후 1175=1175), 토큰 색상 적용, 가로 스크롤 없음.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:48:23 +09:00
parent 119a128ee0
commit 1d3dac3bec
9 changed files with 480 additions and 31 deletions
@@ -38,8 +38,14 @@
모바일은 PC와 동일 설정이라 적용 시 <strong>함께 반영</strong>됩니다.
{% endif %}
{% if desc.mobile_differs %}<span class="cf24-warn">현재 PC/모바일 내용이 다릅니다.</span>{% endif %}
<br />
소스는 <strong>태그마다 줄을 나눠 정리</strong>해서 보여주며, <strong>적용하면 정리된 소스가
그대로 카페24에 저장</strong>됩니다. 줄바꿈·들여쓰기만 바뀌고 태그 구조는 그대로이며,
이미지가 벌어지지 않도록 <code>img</code>·<code>span</code> 같은 인라인 요소와
<code>&lt;style&gt;</code> 안쪽은 건드리지 않습니다.
<br />
이미지 경로의 한글 파일명은 카페24에 <code>%EC%9A%A9…</code> 로 저장되어 있습니다.
여기서는 읽기 쉽게 한글로 보여주고, 적용할 때 원래 형식으로 되돌립니다.
여기서는 한글로 보여주고, 적용할 때 원래 형식으로 되돌립니다.
</p>
<form class="cf24-editor-form" method="post"
@@ -58,8 +64,14 @@
</span>
</div>
<textarea id="cf24-html-pc" class="cf24-html cf24-html-main" name="html"
spellcheck="false">{{ html_pc }}</textarea>
{# 색칠된 <pre> 위에 투명한 <textarea> 를 겹쳐 문법 강조를 만든다.
두 요소의 글자 위치가 어긋나면 안 되므로 폰트·여백은 CSS 에서 함께 관리한다. #}
<div class="cf24-code" id="cf24-code-pc">
<pre class="cf24-code-hl" aria-hidden="true"><code id="cf24-hl-pc"></code></pre>
<textarea id="cf24-html-pc" class="cf24-code-input" name="html"
spellcheck="false" autocapitalize="off" autocorrect="off"
wrap="soft">{{ html_pc }}</textarea>
</div>
</form>
{% if desc.separated_mobile or desc.mobile_differs %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814e" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260814f" />
{% endblock %}
{% block content %}
@@ -69,8 +69,8 @@
{% if r.selling %}<span class="cf24-dot cf24-dot-on" title="판매중"></span>
{% else %}<span class="cf24-dot" title="판매중지"></span>{% endif %}
</td>
{# 좁은 칸이라 월-일만. 전체 값은 title 로 확인 #}
<td class="cf24-col-date" title="{{ r.updated_date }}">{{ r.updated_date[5:10] }}</td>
{# 연도는 생략(월-일 시:분). 전체 값은 title 로 확인 #}
<td class="cf24-col-date" title="{{ r.updated_date }}">{{ r.updated_date[5:16] }}</td>
</tr>
{% endfor %}
</tbody>
@@ -105,9 +105,92 @@
var listQuery = {{ list_query | tojson }};
var dirty = false;
/* ── 문법 강조 ──────────────────────────────────────────────
색칠된 <pre> 를 투명한 <textarea> 뒤에 겹쳐 놓는 방식. 외부 라이브러리를
쓰지 않는다(자체 호스팅 원칙). 태그·속성이름·속성값·주석·기호를 구분한다. */
var TOKEN_RE = /(<!--[\s\S]*?-->)|(<![^>]*>)|(<\/?)([a-zA-Z][\w:.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)(>)/g;
var ATTR_RE = /([\w:.-]+)(?:(\s*=\s*)("[^"]*"|'[^']*'|[^\s"'>]+))?/g;
// 이 길이를 넘으면 강조를 끈다 — 타이핑마다 다시 칠하면 느려진다.
var HL_LIMIT = 200000;
function esc(text) {
return text.replace(/[&<>]/g, function (c) {
return c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;";
});
}
function paintAttrs(text) {
return text.replace(ATTR_RE, function (whole, name, eq, val) {
if (!name) return esc(whole);
var out = '<span class="cf24-t-attr">' + esc(name) + "</span>";
if (eq) out += '<span class="cf24-t-pun">' + esc(eq) + "</span>";
if (val) out += '<span class="cf24-t-val">' + esc(val) + "</span>";
return out;
});
}
function paintHtml(src) {
var out = "", last = 0, m;
TOKEN_RE.lastIndex = 0;
while ((m = TOKEN_RE.exec(src)) !== null) {
out += esc(src.slice(last, m.index));
last = TOKEN_RE.lastIndex;
if (m[1]) { out += '<span class="cf24-t-com">' + esc(m[1]) + "</span>"; continue; }
if (m[2]) { out += '<span class="cf24-t-doc">' + esc(m[2]) + "</span>"; continue; }
out += '<span class="cf24-t-pun">' + esc(m[3]) + "</span>" +
'<span class="cf24-t-tag">' + esc(m[4]) + "</span>" +
paintAttrs(m[5]) +
'<span class="cf24-t-pun">' + esc(m[6]) + "</span>";
}
return out + esc(src.slice(last));
}
function setupCodeEditor() {
var ta = document.getElementById("cf24-html-pc");
var hl = document.getElementById("cf24-hl-pc");
if (!ta || !hl) return;
var timer = null;
function repaint() {
// 마지막 줄이 잘리지 않게 개행을 하나 덧붙인다(<pre> 특성).
if (ta.value.length > HL_LIMIT) hl.textContent = ta.value + "\n";
else hl.innerHTML = paintHtml(ta.value) + "\n";
}
function resize() {
// 색칠된 <pre> 는 같은 내용·같은 스타일이라 이것이 진짜 콘텐츠 높이다.
// textarea 의 scrollHeight 를 쓰면 브라우저마다 한두 줄 더 잡혀 두 층의
// 글자 위치가 어긋난다(실측 830 vs 792).
var target = hl.parentElement.scrollHeight;
if (target > 0) ta.style.height = target + "px";
// 높이가 잠깐 부족했던 사이 내부 스크롤이 생겼으면 되돌린다(두 층 정렬 유지).
ta.scrollTop = 0;
}
function refresh() {
// 높이는 색칠 결과에서 나오므로 다시 칠한 뒤에 맞춘다.
clearTimeout(timer);
timer = setTimeout(function () { repaint(); resize(); }, 60);
}
ta.addEventListener("input", function () { dirty = true; refresh(); });
// Tab 은 포커스 이동이 아니라 들여쓰기로 쓴다.
ta.addEventListener("keydown", function (e) {
if (e.key !== "Tab") return;
e.preventDefault();
var start = ta.selectionStart, end = ta.selectionEnd;
ta.value = ta.value.slice(0, start) + " " + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + 2;
dirty = true;
refresh();
});
repaint();
resize();
}
// ── 편집기 조각을 새로 끼워 넣은 뒤 다시 연결 ──
window.cf24BindEditor = function () {
dirty = false;
setupCodeEditor();
pane.querySelectorAll("[data-copy]").forEach(function (btn) {
btn.addEventListener("click", function () {
@@ -134,10 +217,6 @@
dirty = false;
});
}
var box = document.getElementById("cf24-html-pc");
if (box && !box.readOnly) {
box.addEventListener("input", function () { dirty = true; });
}
};
function confirmLeave() {
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814d" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260814f" />
{% endblock %}
{% block content %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814d" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260814f" />
{% endblock %}
{% block content %}