fix(project): 자체 스크롤 패널에 항상 보이는 스크롤 막대를 JS로 직접 그림

DevTools 박스모델 확인 결과 내 업무 패널은 정상 스크롤 컨테이너였지만
스크롤바 두께가 0 — 이 환경(엣지 오버레이 스크롤바)에선 ::-webkit-scrollbar
커스텀도 scrollbar-width 표준 속성도 모두 무시돼 CSS 로는 막대를 항상
보이게 할 방법이 없었다. 네이티브 막대는 숨기고 attachScrollIndicator()
가 내 업무/좌측 프로젝트 목록/홈 프로젝트 목록/보드 패널 안에 스크롤
위치를 따라 움직이는 막대를 그린다(sticky anchor + 절대배치 track/thumb,
드래그·트랙 클릭 지원, MutationObserver/ResizeObserver 로 갱신). 막대
자리로 각 패널 padding-right 14px 확보, 보드의 scrollbar-gutter 제거.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 23:47:05 +09:00
parent 17731a195a
commit 41b6165144
5 changed files with 102 additions and 38 deletions
+70
View File
@@ -2470,6 +2470,75 @@
});
}
// ── 항상 보이는 스크롤 막대 ──
// 윈도우/엣지가 오버레이(자동숨김) 스크롤바를 쓰면 네이티브 막대는 마우스를
// 올리거나 스크롤 중일 때만 잠깐 보이고, ::-webkit-scrollbar 커스텀도
// 무시된다(DevTools 박스모델에서 두께 0 확인). 그래서 자체 스크롤하는 패널은
// 네이티브 막대를 CSS 로 숨기고(.pj-vbar-* 참고) 여기서 막대를 직접 그린다.
// 휠·키보드 스크롤은 그대로 동작하고, 막대 드래그/트랙 클릭도 지원한다.
function attachScrollIndicator(scroller) {
if (!scroller || scroller.dataset.pjVbar) return;
scroller.dataset.pjVbar = "1";
const anchor = document.createElement("div");
anchor.className = "pj-vbar-anchor";
anchor.innerHTML = '<div class="pj-vbar-track" hidden><div class="pj-vbar-thumb"></div></div>';
scroller.insertBefore(anchor, scroller.firstChild);
const track = anchor.firstChild;
const thumb = track.firstChild;
let thumbH = 0, thumbTop = 0;
function update() {
const sh = scroller.scrollHeight, ch = scroller.clientHeight;
if (ch <= 0 || sh <= ch + 1) { if (!track.hidden) track.hidden = true; return; }
if (track.hidden) track.hidden = false;
thumbH = Math.max(28, Math.round(ch * ch / sh));
thumbTop = Math.round((ch - thumbH) * scroller.scrollTop / (sh - ch));
// 같은 값이면 안 쓴다 — MutationObserver 가 자기 변경에 반응해 도는 걸 막는다.
const trackH = ch + "px", th = thumbH + "px", tf = "translateY(" + thumbTop + "px)";
if (track.style.height !== trackH) track.style.height = trackH;
if (thumb.style.height !== th) thumb.style.height = th;
if (thumb.style.transform !== tf) thumb.style.transform = tf;
}
scroller.addEventListener("scroll", update, { passive: true });
scroller.addEventListener("load", update, true); // 아바타 이미지 로드로 높이가 바뀔 때
window.addEventListener("resize", update);
if (window.ResizeObserver) new ResizeObserver(update).observe(scroller);
if (window.MutationObserver) {
new MutationObserver(function (records) {
for (let i = 0; i < records.length; i++) {
if (!anchor.contains(records[i].target)) { update(); return; }
}
}).observe(scroller, { childList: true, subtree: true, attributes: true, characterData: true });
}
let dragging = false, startY = 0, startTop = 0;
thumb.addEventListener("mousedown", function (e) {
e.preventDefault(); e.stopPropagation();
dragging = true; startY = e.clientY; startTop = scroller.scrollTop;
thumb.classList.add("is-dragging");
});
document.addEventListener("mousemove", function (e) {
if (!dragging) return;
const sh = scroller.scrollHeight, ch = scroller.clientHeight, room = ch - thumbH;
if (room <= 0) return;
scroller.scrollTop = startTop + (e.clientY - startY) * (sh - ch) / room;
});
document.addEventListener("mouseup", function () {
if (dragging) { dragging = false; thumb.classList.remove("is-dragging"); }
});
track.addEventListener("mousedown", function (e) {
if (e.target !== track) return;
e.preventDefault();
const y = e.clientY - track.getBoundingClientRect().top;
scroller.scrollTop += (y < thumbTop ? -1 : 1) * scroller.clientHeight * 0.9;
});
update();
}
function initScrollIndicators() {
document.querySelectorAll(".pj-home-side, .pj-home-main, .pj-side, .pj-board")
.forEach(attachScrollIndicator);
}
document.addEventListener("DOMContentLoaded", function () {
initHome();
initProject();
@@ -2477,5 +2546,6 @@
initInbox();
initLiveRefresh();
wireMyTreeToggles();
initScrollIndicators();
});
})();