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 {