feat(cafe24): HTML 편집기 줄 이동·복사 단축키

Alt+↑/↓ 로 커서가 있는 줄(선택이 있으면 그 줄들)을 위/아래로 옮기고,
Alt+Shift+↑/↓ 로 같은 줄을 하나 더 만든다. 상세페이지는 <img> 한 줄이 곧
한 구획이라 순서 바꾸기·반복이 잦다.

- 이동은 윗줄/아랫줄과 통째로 자리를 바꾸는 방식이라 줄 수가 변하지 않는다.
  첫 줄 위·마지막 줄 아래에서는 아무 것도 하지 않는다.
- 복사는 위로 하면 커서가 원래 자리(위쪽 사본)에, 아래로 하면 새로 생긴
  아래쪽 사본으로 간다.
- 주석 토글과 같은 줄 범위 규칙을 쓰도록 lineRange()/applyEdit() 로 묶었다.
  선택이 개행에서 끝나면 다음 줄은 대상이 아니다.
- execCommand("insertText") 라 Ctrl+Z 로 되돌아간다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 22:17:18 +09:00
parent 233c8db631
commit 024653dc8e
2 changed files with 82 additions and 29 deletions
@@ -78,7 +78,7 @@
<input type="hidden" name="list_query" value="{{ list_query }}" /> <input type="hidden" name="list_query" value="{{ list_query }}" />
<div class="cf24-editor-bar"> <div class="cf24-editor-bar">
<span class="cf24-muted">상세설명 HTML · {{ desc.description | length }}자 · PC/모바일 공통 · Ctrl+/ 주석</span> <span class="cf24-muted">상세설명 HTML · {{ desc.description | length }}자 · PC/모바일 공통 · Ctrl+/ 주석 · Alt+↑↓ 줄 이동</span>
<span class="cf24-editor-bar-right"> <span class="cf24-editor-bar-right">
<input class="cf24-memo" type="text" name="memo" maxlength="200" <input class="cf24-memo" type="text" name="memo" maxlength="200"
placeholder="변경 메모 (버전 이력에 남습니다)" /> placeholder="변경 메모 (버전 이력에 남습니다)" />
@@ -100,7 +100,7 @@
<pre class="cf24-code-hl" id="cf24-hl-pc" aria-hidden="true"></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" spellcheck="false" autocapitalize="off" autocorrect="off"
title="Tab 들여쓰기 · Ctrl+/ 주석 토글(선택한 줄 전체, 선택이 없으면 커서 줄)">{{ html_pc }}</textarea> title="Tab 들여쓰기 · Ctrl+/ 주석 토글 · Alt+↑/↓ 줄 이동 · Alt+Shift+↑/↓ 줄 복사 (선택이 없으면 커서가 있는 줄)">{{ html_pc }}</textarea>
</div> </div>
</div> </div>
</form> </form>
@@ -186,6 +186,37 @@
timer = setTimeout(repaint, 80); timer = setTimeout(repaint, 80);
} }
/* ── 편집 단축키 공용 헬퍼 ──
lineRange() : 지금 선택(없으면 커서)이 걸친 **줄 전체** 범위.
applyEdit() : 그 범위를 새 문자열로 바꾸고 선택을 다시 잡는다. */
function lineRange() {
var value = ta.value;
var start = ta.selectionStart, end = ta.selectionEnd;
// 선택이 개행에서 끝나면 그 다음 줄은 대상이 아니다(드래그로 줄 끝까지
// 끌었을 때 아래 줄까지 딸려오는 것을 막는다).
if (end > start && value.charAt(end - 1) === "\n") end -= 1;
var from = value.lastIndexOf("\n", start - 1) + 1;
var to = value.indexOf("\n", end);
if (to === -1) to = value.length;
return { value: value, start: start, end: end, from: from, to: to };
}
function applyEdit(from, to, text, selStart, selEnd) {
// execCommand 로 넣어야 브라우저의 실행취소(Ctrl+Z) 이력에 남는다.
// 지원하지 않으면 value 를 직접 바꾼다(되돌리기만 안 될 뿐 동작은 같다).
var value = ta.value;
ta.selectionStart = from;
ta.selectionEnd = to;
var inserted = false;
try { inserted = document.execCommand("insertText", false, text); } catch (err) { inserted = false; }
if (!inserted) ta.value = value.slice(0, from) + text + value.slice(to);
ta.selectionStart = selStart;
ta.selectionEnd = selEnd;
dirty = true;
refresh();
}
/* Ctrl+/ (Mac ⌘+/) 로 HTML 주석 토글. /* Ctrl+/ (Mac ⌘+/) 로 HTML 주석 토글.
- 선택이 있으면 그 선택이 걸친 **줄 전체**가 대상이다(반쯤 걸친 줄이 - 선택이 있으면 그 선택이 걸친 **줄 전체**가 대상이다(반쯤 걸친 줄이
잘려 태그가 깨지지 않게). 잘려 태그가 깨지지 않게).
@@ -196,20 +227,11 @@
var COMMENT_MARK = /<!--[ \t]?|[ \t]?-->/g; var COMMENT_MARK = /<!--[ \t]?|[ \t]?-->/g;
function toggleComment() { function toggleComment() {
var value = ta.value; var r = lineRange();
var start = ta.selectionStart, end = ta.selectionEnd; var block = r.value.slice(r.from, r.to);
// 선택이 개행에서 끝나면 그 다음 줄은 대상이 아니다(드래그로 줄 끝까지
// 끌었을 때 아래 줄까지 주석되는 것을 막는다).
if (end > start && value.charAt(end - 1) === "\n") end -= 1;
var lineStart = value.lastIndexOf("\n", start - 1) + 1;
var lineEnd = value.indexOf("\n", end);
if (lineEnd === -1) lineEnd = value.length;
var block = value.slice(lineStart, lineEnd);
if (!block.trim()) return; // 빈 줄에서는 아무 것도 하지 않는다 if (!block.trim()) return; // 빈 줄에서는 아무 것도 하지 않는다
var caretIn = start - lineStart; // 블록 안에서의 커서 위치 var caretIn = r.start - r.from; // 블록 안에서의 커서 위치
var shift = 0, out; var shift = 0, out;
COMMENT_MARK.lastIndex = 0; COMMENT_MARK.lastIndex = 0;
@@ -225,23 +247,45 @@
shift = caretIn >= indent.length ? 5 : 0; // "<!-- " 길이 shift = caretIn >= indent.length ? 5 : 0; // "<!-- " 길이
} }
// execCommand 로 넣어야 브라우저의 실행취소(Ctrl+Z) 이력에 남는다. if (r.start === r.end) {
// 지원하지 않으면 value 를 직접 바꾼다(그때는 되돌리기가 안 될 뿐 동작은 같다). var pos = r.from + Math.min(out.length, Math.max(0, caretIn + shift));
ta.selectionStart = lineStart; applyEdit(r.from, r.to, out, pos, pos);
ta.selectionEnd = lineEnd;
var inserted = false;
try { inserted = document.execCommand("insertText", false, out); } catch (err) { inserted = false; }
if (!inserted) ta.value = value.slice(0, lineStart) + out + value.slice(lineEnd);
if (start === end) {
var pos = lineStart + Math.min(out.length, Math.max(0, caretIn + shift));
ta.selectionStart = ta.selectionEnd = pos;
} else { } else {
ta.selectionStart = lineStart; // 바꾼 범위를 계속 선택해 둔다 // 바꾼 범위를 계속 선택해 둔다(연달아 다시 누르면 그대로 되돌아온다).
ta.selectionEnd = lineStart + out.length; applyEdit(r.from, r.to, out, r.from, r.from + out.length);
} }
dirty = true; }
refresh();
/* Alt+↑/↓ — 커서가 있는 줄(선택이 있으면 그 줄들)을 위/아래로 옮긴다.
윗줄/아랫줄과 통째로 자리를 바꾸는 방식이라 줄 수는 그대로다. */
function moveLines(down) {
var r = lineRange();
var block = r.value.slice(r.from, r.to);
if (!down) {
if (r.from === 0) return; // 첫 줄 위로는 못 간다
var prevFrom = r.value.lastIndexOf("\n", r.from - 2) + 1;
var prev = r.value.slice(prevFrom, r.from - 1);
var up = -(prev.length + 1);
applyEdit(prevFrom, r.to, block + "\n" + prev, r.start + up, r.end + up);
} else {
if (r.to >= r.value.length) return; // 마지막 줄 아래로는 못 간다
var nextTo = r.value.indexOf("\n", r.to + 1);
if (nextTo === -1) nextTo = r.value.length;
var next = r.value.slice(r.to + 1, nextTo);
var dn = next.length + 1;
applyEdit(r.from, nextTo, next + "\n" + block, r.start + dn, r.end + dn);
}
}
/* Alt+Shift+↑/↓ — 같은 줄을 하나 더 만든다.
위로 복사하면 커서는 원래 자리(위쪽 사본)에, 아래로 복사하면 새로 생긴
아래쪽 사본으로 옮긴다(VS Code 와 같은 느낌). */
function duplicateLines(down) {
var r = lineRange();
var block = r.value.slice(r.from, r.to);
var text = block + "\n" + block;
var shift = down ? block.length + 1 : 0;
applyEdit(r.from, r.to, text, r.start + shift, r.end + shift);
} }
ta.addEventListener("scroll", sync); ta.addEventListener("scroll", sync);
@@ -254,6 +298,15 @@
toggleComment(); toggleComment();
return; return;
} }
// Alt+↑/↓ 줄 이동, Alt+Shift+↑/↓ 줄 복사.
var isUp = e.key === "ArrowUp" || e.code === "ArrowUp";
var isDown = e.key === "ArrowDown" || e.code === "ArrowDown";
if (e.altKey && !e.ctrlKey && !e.metaKey && (isUp || isDown)) {
e.preventDefault();
if (e.shiftKey) duplicateLines(isDown);
else moveLines(isDown);
return;
}
// Tab 은 포커스 이동이 아니라 들여쓰기로 쓴다. // Tab 은 포커스 이동이 아니라 들여쓰기로 쓴다.
if (e.key !== "Tab") return; if (e.key !== "Tab") return;
e.preventDefault(); e.preventDefault();