feat(project): 좌/가운데/우 3칸 독립 스크롤 + 화면 양끝 배치, 설명 이미지 편의개선

- 프로젝트 상세 화면을 휴가/쿠팡 모듈처럼 "한 화면 채움" 레이아웃으로 변경.
  좌측 프로젝트 목록/가운데 세션·리스트·달력·타임라인/우측 내 업무가 각자
  안에서만 스크롤되고 서로 얽히지 않는다. 이 화면에서만 페이지 폭 제한을
  풀어 좌측은 화면 맨 왼쪽, 우측 내 업무는 화면 맨 오른쪽에 붙는다.
- 설명란 이미지: 파일 업로드 버튼으로 넣을 때도 커서 위치에 삽입되도록
  선택범위를 기억했다가 복원(그동안은 항상 맨 끝에 붙었음). 이미지를
  선택한 상태에서 Delete/Backspace 키로도 삭제 가능.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 22:44:10 +09:00
parent 022a161dda
commit 04c33e34e4
5 changed files with 84 additions and 31 deletions
+38 -8
View File
@@ -1102,13 +1102,16 @@
}
function renderBoard() {
const board = el("pj-board");
board.innerHTML = "";
// pj-board(바깥)는 overflow-y:auto 스크롤 뷰포트, pj-board-canvas(안쪽)가
// 실제 매이슨리 절대배치 캔버스 — 스크롤 컨테이너와 콘텐츠 높이 계산을
// 분리해야 pj-board 의 높이가 부모(flex) 크기를 그대로 따라간다.
const canvas = el("pj-board-canvas");
canvas.innerHTML = "";
const vis = visibleTasks();
if (boardAddStageBtn) boardAddStageBtn.hidden = !(viewScope === "project" && canManage);
if (viewScope === "project") renderBoardByStage(board, vis);
else renderBoardByName(board, vis);
layoutMasonry(board);
if (viewScope === "project") renderBoardByStage(canvas, vis);
else renderBoardByName(canvas, vis);
layoutMasonry(canvas);
}
// 세션(보드 컬럼) 매이슨리 배치 — 열은 왼쪽부터 순서대로 고정 배정하고,
@@ -1137,7 +1140,7 @@
window.addEventListener("resize", function () {
if (currentView !== "board") return;
clearTimeout(boardResizeTimer);
boardResizeTimer = setTimeout(function () { layoutMasonry(el("pj-board")); }, 120);
boardResizeTimer = setTimeout(function () { layoutMasonry(el("pj-board-canvas")); }, 120);
});
function renderBoardByName(board, vis) {
@@ -2050,6 +2053,29 @@
positionImgTools();
});
document.addEventListener("mouseup", function () { dragging = false; });
document.addEventListener("keydown", function (e) {
if (!selectedImg) return;
if (e.key !== "Delete" && e.key !== "Backspace") return;
const active = document.activeElement;
if (active && ["INPUT", "TEXTAREA", "SELECT"].indexOf(active.tagName) >= 0) return;
e.preventDefault();
selectedImg.remove();
selectImg(null);
});
// 업로드 버튼 클릭 → 파일 선택창이 뜨는 동안 contenteditable의 선택 범위가
// 풀린다 — 마지막으로 박스 안에 있던 커서 위치를 기억해뒀다가 삽입 직전에
// 되살린다(안 그러면 이미지가 항상 맨 끝에 붙어버린다).
let lastRange = null;
function saveSelection() {
const sel = window.getSelection();
if (sel && sel.rangeCount && box.contains(sel.anchorNode)) {
lastRange = sel.getRangeAt(0).cloneRange();
}
}
box.addEventListener("keyup", saveSelection);
box.addEventListener("mouseup", saveSelection);
box.addEventListener("blur", saveSelection);
function setTaskId(id) {
taskId = id;
@@ -2063,10 +2089,13 @@
function insertHtmlAtCursor(html) {
box.focus();
const sel = window.getSelection();
if (lastRange && (!sel.rangeCount || !box.contains(sel.anchorNode))) {
try { sel.removeAllRanges(); sel.addRange(lastRange); } catch (_) { lastRange = null; }
}
let inserted = false;
try { inserted = document.execCommand("insertHTML", false, html); } catch (_) { inserted = false; }
if (!inserted) {
const sel = window.getSelection();
if (sel && sel.rangeCount && box.contains(sel.anchorNode)) {
const range = sel.getRangeAt(0);
range.deleteContents();
@@ -2077,6 +2106,7 @@
box.innerHTML += html;
}
}
saveSelection();
}
// 삽입 전 원본 크기를 확인해, 설명란보다 넓으면 90%로 줄여서 넣는다
@@ -2133,7 +2163,7 @@
return {
setTaskId: setTaskId,
setHtml: function (html) { selectImg(null); box.innerHTML = html || ""; },
setHtml: function (html) { selectImg(null); lastRange = null; box.innerHTML = html || ""; },
getHtml: function () { return box.innerHTML; },
};
}