화살표, Home, End (+Shift) 키보드 입력 기능 추가

This commit is contained in:
2026-05-22 11:55:44 +09:00
parent 4000339477
commit ba0fe2f79f
5 changed files with 132 additions and 3 deletions
+8 -2
View File
@@ -66,7 +66,7 @@ src/
│ └── ghost.js # Drag ghost UI helper │ └── ghost.js # Drag ghost UI helper
├── file-explorer.js # Sidebar directory tree (lazy-load, expand/collapse) ├── file-explorer.js # Sidebar directory tree (lazy-load, expand/collapse)
├── sidebar-resize.js # Pointer-drag resizing for the aside/main split; persisted 340500px width ├── sidebar-resize.js # Pointer-drag resizing for the aside/main split; persisted 340500px 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, 150400px) ├── pinch-zoom.js # Ctrl+scroll to resize thumbnails (CSS --thumb-size, 150400px)
└── image-preview.js # Hover preview overlay; immediate show/hide when enabled/disabled └── 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 (다시하기) - `Cmd+Shift+Z` — Redo (다시하기)
- `Space` — 미리보기 on/off 토글 - `Space` — 미리보기 on/off 토글
- `Backspace` / `Delete` — 선택 파일 삭제 팝업 - `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. 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 — 단독 선택 (재클릭 시 해제) - Click — 단독 선택 (재클릭 시 해제)
- Shift+Click — 범위 선택 - Shift+Click — 범위 선택
- Cmd+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 적용) - Rubber-band drag — 영역 선택 (rAF throttle 적용)
- Shift+Rubber-band — 토글 선택 (드래그 시작 시점 스냅샷 기준으로 선택/해제 반전) - Shift+Rubber-band — 토글 선택 (드래그 시작 시점 스냅샷 기준으로 선택/해제 반전)
- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점 초기화 (grid.js에서 호출) - `resetAnchor()` — 그리드 재렌더 시 Shift 기준점과 키보드 타겟 상태 초기화 (grid.js에서 호출)
**Drag-and-drop** (`drag-drop.js`): **Drag-and-drop** (`drag-drop.js`):
- 그리드 내 카드 순서 재배치 (single/multi) - 그리드 내 카드 순서 재배치 (single/multi)
+1
View File
@@ -200,6 +200,7 @@ export function createCard(file, idx) {
export function refreshCardSelection() { export function refreshCardSelection() {
Array.from(grid.children).forEach((card, i) => { Array.from(grid.children).forEach((card, i) => {
card.classList.toggle('selected', state.selectedIdxs.has(i)); card.classList.toggle('selected', state.selectedIdxs.has(i));
card.classList.toggle('keyboard-target', selection.isKeyboardTarget(i));
}); });
} }
+111 -1
View File
@@ -1,4 +1,4 @@
let _rubberBand, _grid; let _rubberBand, _grid, _gridContainer;
let _state, _cb; let _state, _cb;
let rbActive = false; let rbActive = false;
@@ -8,15 +8,24 @@ let _rafPending = false;
let _lastMoveEvent = null; let _lastMoveEvent = null;
let lastSelectedIdx = null; let lastSelectedIdx = null;
let keyboardTargetIdx = null;
let keyboardExtendBaseSelection = null;
let _rbStartSelection = new Set(); // 러버밴드 시작 시점의 선택 스냅샷 let _rbStartSelection = new Set(); // 러버밴드 시작 시점의 선택 스냅샷
export function resetAnchor() { export function resetAnchor() {
lastSelectedIdx = null; lastSelectedIdx = null;
keyboardTargetIdx = null;
keyboardExtendBaseSelection = null;
}
export function isKeyboardTarget(idx) {
return keyboardTargetIdx === idx;
} }
export function init({ gridContainer, rubberBand, grid, state, callbacks }) { export function init({ gridContainer, rubberBand, grid, state, callbacks }) {
_rubberBand = rubberBand; _rubberBand = rubberBand;
_grid = grid; _grid = grid;
_gridContainer = gridContainer;
_state = state; _state = state;
_cb = callbacks; _cb = callbacks;
@@ -51,10 +60,39 @@ export function onCardClick(e) {
} }
} }
keyboardTargetIdx = idx;
keyboardExtendBaseSelection = null;
_cb.refreshCardSelection(); _cb.refreshCardSelection();
_cb.updateSelCount(); _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) { function _toggle(idx) {
if (_state.selectedIdxs.has(idx)) { if (_state.selectedIdxs.has(idx)) {
_state.selectedIdxs.delete(idx); _state.selectedIdxs.delete(idx);
@@ -63,6 +101,7 @@ function _toggle(idx) {
_state.selectedIdxs.add(idx); _state.selectedIdxs.add(idx);
lastSelectedIdx = idx; lastSelectedIdx = idx;
} }
keyboardExtendBaseSelection = null;
} }
function _selectRange(idx) { function _selectRange(idx) {
@@ -70,6 +109,7 @@ function _selectRange(idx) {
_state.selectedIdxs.clear(); _state.selectedIdxs.clear();
_state.selectedIdxs.add(idx); _state.selectedIdxs.add(idx);
lastSelectedIdx = idx; lastSelectedIdx = idx;
keyboardExtendBaseSelection = null;
return; return;
} }
const start = Math.min(lastSelectedIdx, idx); const start = Math.min(lastSelectedIdx, idx);
@@ -77,6 +117,74 @@ function _selectRange(idx) {
_state.selectedIdxs.clear(); _state.selectedIdxs.clear();
for (let i = start; i <= end; i++) _state.selectedIdxs.add(i); for (let i = start; i <= end; i++) _state.selectedIdxs.add(i);
// anchor(lastSelectedIdx)는 Shift 클릭으로 변경하지 않음 // 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) { function _onStart(e) {
@@ -102,6 +210,8 @@ function _onStart(e) {
_state.selectedIdxs.clear(); _state.selectedIdxs.clear();
_rbStartSelection.clear(); _rbStartSelection.clear();
lastSelectedIdx = null; lastSelectedIdx = null;
keyboardTargetIdx = null;
keyboardExtendBaseSelection = null;
_cb.refreshCardSelection(); _cb.refreshCardSelection();
} }
} }
+8
View File
@@ -1,4 +1,5 @@
import { log } from './logger.js'; import { log } from './logger.js';
import * as selection from './handlers/selection.js';
export function initKeyboardShortcuts({ export function initKeyboardShortcuts({
btnOpen, btnOpen,
@@ -39,10 +40,17 @@ export function initKeyboardShortcuts({
btnPreviewToggle.click(); btnPreviewToggle.click();
} else if (e.code === 'Backspace' || e.code === 'Delete') { } else if (e.code === 'Backspace' || e.code === 'Delete') {
deleteSelected(); 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) { function isEditableTarget(target) {
return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable; return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable;
} }
+4
View File
@@ -338,6 +338,10 @@ main {
background: var(--sel-bg); background: var(--sel-bg);
box-shadow: 0 0 0 1px var(--sel-border), 0 8px 24px rgba(124,106,247,.2); 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.dragging,
.card.multi-dragging { opacity: .3; transform: scale(.96); cursor: grabbing; } .card.multi-dragging { opacity: .3; transform: scale(.96); cursor: grabbing; }
.card.drag-over { border-color: var(--accent) !important; background: var(--drag-over); } .card.drag-over { border-color: var(--accent) !important; background: var(--drag-over); }