From 41b61651440a3c73dc5eb9bf285415a2bd389e9c Mon Sep 17 00:00:00 2001 From: king Date: Tue, 15 Sep 2026 23:47:05 +0900 Subject: [PATCH] =?UTF-8?q?fix(project):=20=EC=9E=90=EC=B2=B4=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=ED=8C=A8=EB=84=90=EC=97=90=20=ED=95=AD?= =?UTF-8?q?=EC=83=81=20=EB=B3=B4=EC=9D=B4=EB=8A=94=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EB=A7=89=EB=8C=80=EB=A5=BC=20JS=EB=A1=9C=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EA=B7=B8=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../project/templates/project/inbox.html | 4 +- .../project/templates/project/index.html | 4 +- .../project/templates/project/project.html | 4 +- app/static/project.css | 58 +++++++-------- app/static/project.js | 70 +++++++++++++++++++ 5 files changed, 102 insertions(+), 38 deletions(-) diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html index 6cd617b..b53123e 100644 --- a/app/modules/project/templates/project/inbox.html +++ b/app/modules/project/templates/project/inbox.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} @@ -45,5 +45,5 @@ {% endif %} - + {% endblock %} diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html index 96a8029..ce9ca08 100644 --- a/app/modules/project/templates/project/index.html +++ b/app/modules/project/templates/project/index.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} @@ -380,5 +380,5 @@ - + {% endblock %} diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html index 67e17e0..9b3168b 100644 --- a/app/modules/project/templates/project/project.html +++ b/app/modules/project/templates/project/project.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + @@ -392,5 +392,5 @@ - + {% endblock %} diff --git a/app/static/project.css b/app/static/project.css index 1a0dafd..1a3b4e5 100644 --- a/app/static/project.css +++ b/app/static/project.css @@ -53,7 +53,7 @@ display: grid; grid-template-columns: minmax(0, 1fr) 300px; grid-template-rows: minmax(0, 1fr); gap: 28px; align-items: stretch; } -.pj-home-main { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; padding-right: 4px; } +.pj-home-main { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; padding-right: 14px; } @media (max-width: 980px) { .erp-page > .pj-wrap:not(.pj-project-view) { display: block; } .pj-home { display: block; } @@ -133,7 +133,7 @@ 확정하고 자기 안에서만 스크롤. 300px 는 padding 포함(border-box). */ .pj-home-side { width: 300px; box-sizing: border-box; min-height: 0; height: 100%; overflow-y: auto; - border-left: 1px solid #eef0f2; padding-left: 24px; padding-right: 4px; + border-left: 1px solid #eef0f2; padding-left: 24px; padding-right: 14px; } @media (max-width: 980px) { .pj-home-side { border-left: none; padding-left: 0; height: auto; overflow: visible; } } .pj-mytask-list { list-style: none; margin: 0; padding: 0; } @@ -162,33 +162,28 @@ display: grid; grid-template-columns: 230px 1fr 300px; grid-template-rows: minmax(0, 1fr); gap: 22px; height: 100%; min-height: 0; align-items: stretch; } -.pj-side { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; padding-right: 4px; } +.pj-side { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; padding-right: 14px; } .pj-main { min-width: 0; min-height: 0; height: 100%; display: flex; flex-direction: column; } -.pj-project-mytasks { min-width: 0; min-height: 0; width: 300px; height: 100%; overflow-y: auto; padding-right: 4px; } +.pj-project-mytasks { min-width: 0; min-height: 0; width: 300px; height: 100%; overflow-y: auto; padding-right: 14px; } -/* 휠 스크롤은 되는데 막대가 안 보인다 — OS/브라우저의 오버레이(자동숨김) - 스크롤바 때문(호버해야만 잠깐 보이는 스타일). 여기 자체 스크롤 패널들은 - 막대가 항상 보이도록 커스텀 스크롤바를 그린다(윈도우 설정과 무관하게 - 고정 표시). */ -/* 주의: Chromium(크롬/엣지) 121+ 는 scrollbar-width/scrollbar-color 가 지정돼 - 있으면 ::-webkit-scrollbar 커스텀 스타일을 통째로 무시하고 OS 오버레이 - (자동숨김) 스크롤바로 돌아간다 — 그래서 표준 속성은 ::-webkit-scrollbar 를 - 지원하지 않는 브라우저(Firefox)에만 준다. */ -@supports not selector(::-webkit-scrollbar) { - .pj-home-side, .pj-home-main, .pj-project-mytasks, .pj-side, .pj-board { - scrollbar-width: thin; scrollbar-color: #b9bcc4 #f1f2f4; - } -} -.pj-home-side::-webkit-scrollbar, .pj-home-main::-webkit-scrollbar, .pj-project-mytasks::-webkit-scrollbar, -.pj-side::-webkit-scrollbar, .pj-board::-webkit-scrollbar { width: 9px; height: 9px; } -.pj-home-side::-webkit-scrollbar-track, .pj-home-main::-webkit-scrollbar-track, .pj-project-mytasks::-webkit-scrollbar-track, -.pj-side::-webkit-scrollbar-track, .pj-board::-webkit-scrollbar-track { background: #f1f2f4; border-radius: 999px; } -.pj-home-side::-webkit-scrollbar-thumb, .pj-home-main::-webkit-scrollbar-thumb, .pj-project-mytasks::-webkit-scrollbar-thumb, -.pj-side::-webkit-scrollbar-thumb, .pj-board::-webkit-scrollbar-thumb { - background-color: #b9bcc4; border-radius: 999px; -} -.pj-home-side::-webkit-scrollbar-thumb:hover, .pj-home-main::-webkit-scrollbar-thumb:hover, .pj-project-mytasks::-webkit-scrollbar-thumb:hover, -.pj-side::-webkit-scrollbar-thumb:hover, .pj-board::-webkit-scrollbar-thumb:hover { background-color: #979ba5; } +/* ── 자체 스크롤 패널(내 업무/좌측 목록/홈 프로젝트 목록/보드)의 스크롤 막대 ── + 윈도우/엣지가 오버레이(자동숨김) 스크롤바를 쓰는 환경에선 네이티브 막대가 + 마우스를 올리거나 스크롤 중일 때만 잠깐 보이고, ::-webkit-scrollbar / + scrollbar-width 커스텀도 무시된다(DevTools 박스모델에서 두께 0 확인). + 그래서 네이티브 막대는 아예 숨기고, JS(attachScrollIndicator)가 패널 안에 + 스크롤 위치에 맞춰 움직이는 막대를 직접 그려 어떤 환경에서도 항상 + 보이게 한다. 휠·키보드·드래그 스크롤은 그대로 동작. 막대가 들어갈 자리로 + 각 패널의 padding-right 를 14px 확보한다. */ +.pj-home-side, .pj-home-main, .pj-side, .pj-board { scrollbar-width: none; } +.pj-home-side::-webkit-scrollbar, .pj-home-main::-webkit-scrollbar, +.pj-side::-webkit-scrollbar, .pj-board::-webkit-scrollbar { display: none; width: 0; height: 0; } +/* anchor = 스크롤러의 첫 자식(높이 0, sticky) → 화면에 보이는 영역의 맨 위에 + 항상 붙어 있고, 그 안의 track 이 보이는 높이만큼 오른쪽 padding 자리에 걸린다. */ +.pj-vbar-anchor { position: sticky; top: 0; height: 0; z-index: 5; } +.pj-vbar-track { position: absolute; top: 0; right: -11px; width: 8px; border-radius: 999px; background: #e6e8ec; } +.pj-vbar-thumb { position: absolute; left: 0; top: 0; width: 100%; border-radius: 999px; background: #b3b7bf; cursor: grab; } +.pj-vbar-thumb:hover, .pj-vbar-thumb.is-dragging { background: #8f949e; } +.pj-vbar-thumb.is-dragging { cursor: grabbing; } @media (max-width: 1280px) { .pj-layout { grid-template-columns: 230px 1fr; } .pj-project-mytasks { display: none; } @@ -276,13 +271,12 @@ 높이를 정해준다). pj-board-canvas = 매이슨리 절대배치 캔버스 — 스크롤 컨테이너 자체 높이와 콘텐츠(캔버스) 높이 계산을 분리해야 스크롤이 올바르게 생긴다(캔버스만 커지고 뷰포트는 flex 크기를 유지). */ -/* scrollbar-gutter:stable — 스크롤바 자리를 항상 미리 비워둔다. 이게 없으면 - 렌더 시점에 스크롤바가 아직 안 생긴 상태로 clientWidth를 재서 열 너비를 - 계산했다가, 막상 스크롤바가 생기면 그만큼 폭이 줄어 마지막 열이 스크롤바 - 에 가려 보였다. */ +/* 네이티브 스크롤바는 숨기고 JS 가 그리는 막대를 쓴다(위 .pj-vbar-* 참고). + 막대 자리는 padding-right 14px — 매이슨리 열 너비는 canvas(=padding 안쪽) + 기준으로 계산되므로 열이 막대에 가려지지 않는다. */ .pj-board { flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden; - scrollbar-gutter: stable; padding-right: 4px; padding-bottom: 8px; + padding-right: 14px; padding-bottom: 8px; } .pj-board-canvas { position: relative; } /* 세션(보드 컬럼) — 배경이 페이지와 거의 같으면 구분이 안 되므로(#f6f5fb ≈ diff --git a/app/static/project.js b/app/static/project.js index a3c27ce..b160998 100644 --- a/app/static/project.js +++ b/app/static/project.js @@ -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 = ''; + 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(); }); })();