From 400033947783a534c819deae9c863c0fcab22979 Mon Sep 17 00:00:00 2001 From: jisangs Date: Fri, 22 May 2026 11:00:58 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=99=94=EB=A9=B4=20=EC=98=A4=EB=A5=B8=EC=AA=BD?= =?UTF-8?q?=EC=97=90=20=EC=8A=A4=ED=81=AC=EB=A1=A4=EB=B0=94=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/grid.js | 101 ++++++++++++++++++++++++++++++++++++++++ src/renderer/index.html | 3 ++ src/renderer/style.css | 65 +++++++++++++++++++++++--- 3 files changed, 163 insertions(+), 6 deletions(-) diff --git a/src/renderer/grid.js b/src/renderer/grid.js index 4084783..7479f0a 100644 --- a/src/renderer/grid.js +++ b/src/renderer/grid.js @@ -7,6 +7,8 @@ import { buildSequentialName, getNameFormat } from './utils/file-names.js'; const grid = document.getElementById('grid'); const emptyState = document.getElementById('empty-state'); const gridContainer = document.getElementById('grid-container'); +const gridScrollbar = document.getElementById('grid-scrollbar'); +const gridScrollbarThumb = document.getElementById('grid-scrollbar-thumb'); const fileCount = document.getElementById('file-count'); const selCount = document.getElementById('sel-count'); const inputPrefix = document.getElementById('input-prefix'); @@ -14,6 +16,103 @@ const inputDigits = document.getElementById('input-digits'); const inputPostfix = document.getElementById('input-postfix'); let _cardObserver = null; +let _scrollStateRaf = null; +let _scrollbarDrag = null; + +function updateGridScrollState() { + if (_scrollStateRaf) cancelAnimationFrame(_scrollStateRaf); + _scrollStateRaf = requestAnimationFrame(() => { + _scrollStateRaf = null; + const isScrollable = gridContainer.scrollHeight > gridContainer.clientHeight; + gridContainer.classList.toggle('is-scrollable', isScrollable); + updateGridScrollbarFrame(); + updateGridScrollbar(); + }); +} + +function updateGridScrollbarFrame() { + if (!gridScrollbar) return; + + const rect = gridContainer.getBoundingClientRect(); + const top = rect.top + 20; + const height = Math.max(0, rect.height - 40); + + gridScrollbar.style.setProperty('--grid-scrollbar-top', `${top}px`); + gridScrollbar.style.setProperty('--grid-scrollbar-height', `${height}px`); +} + +function updateGridScrollbar() { + if (!gridScrollbar || !gridScrollbarThumb) return; + + const { clientHeight, scrollHeight, scrollTop } = gridContainer; + const maxScrollTop = scrollHeight - clientHeight; + if (maxScrollTop <= 0) { + gridScrollbarThumb.style.height = ''; + gridScrollbarThumb.style.transform = ''; + return; + } + + const trackHeight = gridScrollbar.clientHeight; + const thumbHeight = Math.max(36, Math.round((clientHeight / scrollHeight) * trackHeight)); + const maxThumbTop = trackHeight - thumbHeight; + const thumbTop = Math.round((scrollTop / maxScrollTop) * maxThumbTop); + + gridScrollbarThumb.style.height = `${thumbHeight}px`; + gridScrollbarThumb.style.transform = `translateY(${thumbTop}px)`; +} + +function scrollGridFromPointer(clientY) { + const trackRect = gridScrollbar.getBoundingClientRect(); + const thumbHeight = gridScrollbarThumb.offsetHeight; + const maxThumbTop = gridScrollbar.clientHeight - thumbHeight; + const maxScrollTop = gridContainer.scrollHeight - gridContainer.clientHeight; + if (maxThumbTop <= 0 || maxScrollTop <= 0) return; + + const targetThumbTop = Math.min( + maxThumbTop, + Math.max(0, clientY - trackRect.top - _scrollbarDrag.thumbOffset), + ); + gridContainer.scrollTop = (targetThumbTop / maxThumbTop) * maxScrollTop; +} + +const _gridResizeObserver = new ResizeObserver(updateGridScrollState); +_gridResizeObserver.observe(gridContainer); +_gridResizeObserver.observe(grid); + +gridContainer.addEventListener('scroll', updateGridScrollbar); +window.addEventListener('resize', updateGridScrollState); + +gridScrollbar.addEventListener('pointerdown', (e) => { + if (!gridContainer.classList.contains('is-scrollable')) return; + + const thumbRect = gridScrollbarThumb.getBoundingClientRect(); + const isThumbPointer = e.target === gridScrollbarThumb; + _scrollbarDrag = { + thumbOffset: isThumbPointer ? e.clientY - thumbRect.top : thumbRect.height / 2, + }; + + gridScrollbar.classList.add('dragging'); + gridScrollbar.setPointerCapture(e.pointerId); + scrollGridFromPointer(e.clientY); + e.preventDefault(); +}); + +gridScrollbar.addEventListener('pointermove', (e) => { + if (!_scrollbarDrag) return; + scrollGridFromPointer(e.clientY); +}); + +gridScrollbar.addEventListener('pointerup', (e) => { + _scrollbarDrag = null; + gridScrollbar.classList.remove('dragging'); + gridScrollbar.releasePointerCapture(e.pointerId); +}); + +gridScrollbar.addEventListener('pointercancel', (e) => { + _scrollbarDrag = null; + gridScrollbar.classList.remove('dragging'); + gridScrollbar.releasePointerCapture(e.pointerId); +}); export function renderGrid() { if (_cardObserver) { _cardObserver.disconnect(); _cardObserver = null; } @@ -44,6 +143,7 @@ export function renderGrid() { refreshCardSelection(); updateNumberBadges(); updateNewNameLabels(); + updateGridScrollState(); } export function createCard(file, idx) { @@ -138,4 +238,5 @@ export function showEmptyGrid() { emptyState.style.display = ''; fileCount.textContent = '0개 파일'; updateSelCount(); + updateGridScrollState(); } diff --git a/src/renderer/index.html b/src/renderer/index.html index 6297e53..c9701a7 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -58,6 +58,9 @@ JPG · PNG · GIF · BMP · TIFF · WEBP · HEIC

+ diff --git a/src/renderer/style.css b/src/renderer/style.css index 4f85ba8..e9284ff 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -24,6 +24,7 @@ --sel-bg: rgba(124,106,247,0.22); --sel-border: #7c6af7; --thumb-size: 250px; + --grid-scroll-track: #282740; } * { box-sizing: border-box; margin: 0; padding: 0; } @@ -234,18 +235,70 @@ main { scrollbar-width: thin; scrollbar-color: var(--border) transparent; } -.file-explorer::-webkit-scrollbar, -#grid-container::-webkit-scrollbar { width: 6px; } +.file-explorer::-webkit-scrollbar { width: 6px; } +#grid-container { scrollbar-width: none; } +#grid-container::-webkit-scrollbar { width: 0; height: 0; } .file-explorer::-webkit-scrollbar-track, -#grid-container::-webkit-scrollbar-track { background: transparent; } -.file-explorer::-webkit-scrollbar-thumb, -#grid-container::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +.file-explorer::-webkit-scrollbar-track { background: transparent; } +.file-explorer::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } /* ── 그리드 컨테이너 ── */ #grid-container { - flex: 1; overflow-y: auto; padding: 20px; + flex: 1; + overflow-x: hidden; + overflow-y: hidden; + padding: 20px; position: relative; } +#grid-container.is-scrollable { + overflow-y: scroll; +} +#grid-scrollbar { + position: fixed; + top: var(--grid-scrollbar-top, 0); + right: 0; + height: var(--grid-scrollbar-height, 0); + z-index: 25; + display: none; + width: 12px; + border-radius: 999px; + background: transparent; + cursor: pointer; +} +#grid-container.is-scrollable #grid-scrollbar { + display: block; +} +#grid-scrollbar:hover, +#grid-scrollbar.dragging { + width: 12px; +} +#grid-scrollbar::before { + content: ""; + position: absolute; + inset: 0 0 0 auto; + width: 6px; + border-radius: 999px; + background: var(--grid-scroll-track); + transition: width .16s ease; +} +#grid-scrollbar:hover::before, +#grid-scrollbar.dragging::before { + width: 12px; +} +#grid-scrollbar-thumb { + position: absolute; + top: 0; + right: 0; + width: 6px; + min-height: 36px; + border-radius: 999px; + background: var(--accent); + transition: width .16s ease, background .16s ease; +} +#grid-scrollbar:hover #grid-scrollbar-thumb, +#grid-scrollbar.dragging #grid-scrollbar-thumb { + width: 12px; +} /* ── 그리드: 고정 열 폭 (--thumb-size) ── */ #grid {