From ba0fe2f79f717bfef30ea1bbfaca5636c07fc61c Mon Sep 17 00:00:00 2001 From: jisangs Date: Fri, 22 May 2026 11:55:44 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=94=EC=82=B4=ED=91=9C,=20Home,=20End=20(+?= =?UTF-8?q?Shift)=20=ED=82=A4=EB=B3=B4=EB=93=9C=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 10 ++- src/renderer/grid.js | 1 + src/renderer/handlers/selection.js | 112 ++++++++++++++++++++++++++++- src/renderer/keyboard.js | 8 +++ src/renderer/style.css | 4 ++ 5 files changed, 132 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 30c0613..f5c8323 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ src/ │ └── ghost.js # Drag ghost UI helper ├── file-explorer.js # Sidebar directory tree (lazy-load, expand/collapse) ├── sidebar-resize.js # Pointer-drag resizing for the aside/main split; persisted 340–500px width - ├── selection.js # Rubber-band, Shift-click, Cmd+click, resetAnchor + ├── selection.js # Rubber-band, Shift-click, Cmd+click, keyboard navigation, resetAnchor ├── pinch-zoom.js # Ctrl+scroll to resize thumbnails (CSS --thumb-size, 150–400px) └── image-preview.js # Hover preview overlay; immediate show/hide when enabled/disabled ``` @@ -124,6 +124,9 @@ Prefer narrow utility modules (`utils/file-names.js`, `utils/dom.js`) over broad - `Cmd+Shift+Z` — Redo (다시하기) - `Space` — 미리보기 on/off 토글 - `Backspace` / `Delete` — 선택 파일 삭제 팝업 +- `ArrowLeft` / `ArrowRight` / `ArrowUp` / `ArrowDown` — 해당 방향의 카드를 키보드 타겟으로 이동하고 단독 선택 +- `Home` / `End` — 첫 카드 / 마지막 카드를 키보드 타겟으로 이동하고 단독 선택 (`grid-container` 최상단 / 최하단으로 스크롤) +- `Shift+Arrow*` / `Shift+Home` / `Shift+End` — 기존 선택 목록을 유지한 채 Finder처럼 anchor부터 새 키보드 타겟까지 범위 추가 선택 Global shortcuts are registered in `keyboard.js`. They do not fire while any `.modal-overlay` is open. @@ -148,9 +151,12 @@ Global shortcuts are registered in `keyboard.js`. They do not fire while any `.m - Click — 단독 선택 (재클릭 시 해제) - Shift+Click — 범위 선택 - Cmd+Click — 개별 토글 +- Arrow/Home/End keyboard navigation — `keyboardTargetIdx`를 이동하고 해당 카드를 단독 선택; 위/아래 이동은 현재 그리드의 실제 첫 행 카드 수로 column count를 계산 +- Shift+Arrow/Home/End — Shift 확장 시작 시점의 선택 스냅샷을 보존하고, `lastSelectedIdx` anchor부터 `keyboardTargetIdx`까지의 범위를 추가 선택 +- Home/End keyboard navigation — 선택 타겟 이동 후 `grid-container`를 각각 최상단/최하단으로 스크롤 - Rubber-band drag — 영역 선택 (rAF throttle 적용) - Shift+Rubber-band — 토글 선택 (드래그 시작 시점 스냅샷 기준으로 선택/해제 반전) -- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점 초기화 (grid.js에서 호출) +- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점과 키보드 타겟 상태 초기화 (grid.js에서 호출) **Drag-and-drop** (`drag-drop.js`): - 그리드 내 카드 순서 재배치 (single/multi) diff --git a/src/renderer/grid.js b/src/renderer/grid.js index 7479f0a..3c17a05 100644 --- a/src/renderer/grid.js +++ b/src/renderer/grid.js @@ -200,6 +200,7 @@ export function createCard(file, idx) { export function refreshCardSelection() { Array.from(grid.children).forEach((card, i) => { card.classList.toggle('selected', state.selectedIdxs.has(i)); + card.classList.toggle('keyboard-target', selection.isKeyboardTarget(i)); }); } diff --git a/src/renderer/handlers/selection.js b/src/renderer/handlers/selection.js index a6d85ab..67b8d81 100644 --- a/src/renderer/handlers/selection.js +++ b/src/renderer/handlers/selection.js @@ -1,4 +1,4 @@ -let _rubberBand, _grid; +let _rubberBand, _grid, _gridContainer; let _state, _cb; let rbActive = false; @@ -8,15 +8,24 @@ let _rafPending = false; let _lastMoveEvent = null; let lastSelectedIdx = null; +let keyboardTargetIdx = null; +let keyboardExtendBaseSelection = null; let _rbStartSelection = new Set(); // 러버밴드 시작 시점의 선택 스냅샷 export function resetAnchor() { lastSelectedIdx = null; + keyboardTargetIdx = null; + keyboardExtendBaseSelection = null; +} + +export function isKeyboardTarget(idx) { + return keyboardTargetIdx === idx; } export function init({ gridContainer, rubberBand, grid, state, callbacks }) { _rubberBand = rubberBand; _grid = grid; + _gridContainer = gridContainer; _state = state; _cb = callbacks; @@ -51,10 +60,39 @@ export function onCardClick(e) { } } + keyboardTargetIdx = idx; + keyboardExtendBaseSelection = null; _cb.refreshCardSelection(); _cb.updateSelCount(); } +export function handleKeyboardNavigation(code, { extend = false } = {}) { + if (!_state?.files.length) return false; + + const currentIdx = _getCurrentKeyboardIndex(code); + const nextIdx = _getNextKeyboardIndex(currentIdx, code); + if (nextIdx === null) return false; + + if (extend) { + if (lastSelectedIdx === null) lastSelectedIdx = currentIdx ?? nextIdx; + if (!keyboardExtendBaseSelection) { + keyboardExtendBaseSelection = new Set(_state.selectedIdxs); + } + _selectKeyboardRange(lastSelectedIdx, nextIdx, keyboardExtendBaseSelection); + } else { + _state.selectedIdxs.clear(); + _state.selectedIdxs.add(nextIdx); + lastSelectedIdx = nextIdx; + keyboardExtendBaseSelection = null; + } + + keyboardTargetIdx = nextIdx; + _cb.refreshCardSelection(); + _cb.updateSelCount(); + _scrollKeyboardTargetIntoView(code, nextIdx); + return true; +} + function _toggle(idx) { if (_state.selectedIdxs.has(idx)) { _state.selectedIdxs.delete(idx); @@ -63,6 +101,7 @@ function _toggle(idx) { _state.selectedIdxs.add(idx); lastSelectedIdx = idx; } + keyboardExtendBaseSelection = null; } function _selectRange(idx) { @@ -70,6 +109,7 @@ function _selectRange(idx) { _state.selectedIdxs.clear(); _state.selectedIdxs.add(idx); lastSelectedIdx = idx; + keyboardExtendBaseSelection = null; return; } const start = Math.min(lastSelectedIdx, idx); @@ -77,6 +117,74 @@ function _selectRange(idx) { _state.selectedIdxs.clear(); for (let i = start; i <= end; i++) _state.selectedIdxs.add(i); // anchor(lastSelectedIdx)는 Shift 클릭으로 변경하지 않음 + keyboardExtendBaseSelection = null; +} + +function _selectKeyboardRange(anchorIdx, targetIdx, baseSelection) { + const start = Math.min(anchorIdx, targetIdx); + const end = Math.max(anchorIdx, targetIdx); + _state.selectedIdxs.clear(); + baseSelection.forEach(idx => { + if (_isValidIndex(idx)) _state.selectedIdxs.add(idx); + }); + for (let i = start; i <= end; i++) _state.selectedIdxs.add(i); +} + +function _getCurrentKeyboardIndex(code) { + if (_isValidIndex(keyboardTargetIdx)) return keyboardTargetIdx; + if (_state.selectedIdxs.size) return Math.min(..._state.selectedIdxs); + return null; +} + +function _getNextKeyboardIndex(currentIdx, code) { + const lastIdx = _state.files.length - 1; + + if (currentIdx === null) { + return (code === 'ArrowLeft' || code === 'ArrowUp' || code === 'End') + ? lastIdx + : 0; + } + + if (code === 'Home') return 0; + if (code === 'End') return lastIdx; + if (code === 'ArrowLeft') return Math.max(0, currentIdx - 1); + if (code === 'ArrowRight') return Math.min(lastIdx, currentIdx + 1); + const columnCount = _getColumnCount(); + if (code === 'ArrowUp') return Math.max(0, currentIdx - columnCount); + if (code === 'ArrowDown') return Math.min(lastIdx, currentIdx + columnCount); + return null; +} + +function _getColumnCount() { + const cards = Array.from(_grid.children).filter(card => card.classList.contains('card')); + if (!cards.length) return 1; + + const firstTop = cards[0].getBoundingClientRect().top; + const firstRowCount = cards.findIndex(card => + Math.abs(card.getBoundingClientRect().top - firstTop) > 2 + ); + return firstRowCount === -1 ? cards.length : Math.max(1, firstRowCount); +} + +function _scrollCardIntoView(idx) { + const card = _grid.querySelector(`.card[data-idx="${idx}"]`); + card?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); +} + +function _scrollKeyboardTargetIntoView(code, idx) { + if (code === 'Home') { + _gridContainer.scrollTop = 0; + return; + } + if (code === 'End') { + _gridContainer.scrollTop = _gridContainer.scrollHeight - _gridContainer.clientHeight; + return; + } + _scrollCardIntoView(idx); +} + +function _isValidIndex(idx) { + return Number.isInteger(idx) && idx >= 0 && idx < _state.files.length; } function _onStart(e) { @@ -102,6 +210,8 @@ function _onStart(e) { _state.selectedIdxs.clear(); _rbStartSelection.clear(); lastSelectedIdx = null; + keyboardTargetIdx = null; + keyboardExtendBaseSelection = null; _cb.refreshCardSelection(); } } diff --git a/src/renderer/keyboard.js b/src/renderer/keyboard.js index 1247bc5..727cf3e 100644 --- a/src/renderer/keyboard.js +++ b/src/renderer/keyboard.js @@ -1,4 +1,5 @@ import { log } from './logger.js'; +import * as selection from './handlers/selection.js'; export function initKeyboardShortcuts({ btnOpen, @@ -39,10 +40,17 @@ export function initKeyboardShortcuts({ btnPreviewToggle.click(); } else if (e.code === 'Backspace' || e.code === 'Delete') { deleteSelected(); + } else if (isNavigationKey(e.code) && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault(); + selection.handleKeyboardNavigation(e.code, { extend: e.shiftKey }); } }); } +function isNavigationKey(code) { + return ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(code); +} + function isEditableTarget(target) { return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable; } diff --git a/src/renderer/style.css b/src/renderer/style.css index e9284ff..34f8045 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -338,6 +338,10 @@ main { background: var(--sel-bg); box-shadow: 0 0 0 1px var(--sel-border), 0 8px 24px rgba(124,106,247,.2); } +.card.keyboard-target { + outline: 2px solid rgba(255, 255, 255, .72); + outline-offset: 2px; +} .card.dragging, .card.multi-dragging { opacity: .3; transform: scale(.96); cursor: grabbing; } .card.drag-over { border-color: var(--accent) !important; background: var(--drag-over); }