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