화살표, Home, End (+Shift) 키보드 입력 기능 추가
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user