Compare commits
3 Commits
19ec96c544
...
bce79867e4
| Author | SHA1 | Date | |
|---|---|---|---|
| bce79867e4 | |||
| e39cda9ec4 | |||
| 94d100675e |
@@ -189,11 +189,14 @@
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (e.key === 'ArrowLeft' || e.key === 'k') {
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
// Letter keys are matched by physical position (e.code) so j/k/e work on any
|
||||
// keyboard layout; arrows and Escape are layout-independent already.
|
||||
if (e.key === 'ArrowLeft' || e.code === 'KeyK') {
|
||||
if (prevId) onNavigate(prevId);
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'j') {
|
||||
} else if (e.key === 'ArrowRight' || e.code === 'KeyJ') {
|
||||
if (nextId) onNavigate(nextId);
|
||||
} else if (e.key === 'e') {
|
||||
} else if (e.code === 'KeyE') {
|
||||
e.preventDefault();
|
||||
jumpToTags();
|
||||
} else if (e.key === 'Escape') {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
['↑ ↓ ← →', 'Move focus between files'],
|
||||
['Enter', 'Open the focused file'],
|
||||
['Space / x', 'Select / deselect'],
|
||||
['Shift+Space / Shift+x', 'Select a range from the anchor'],
|
||||
['e', 'Edit tags (focus the tag filter)'],
|
||||
['p', 'Add to pool'],
|
||||
['Del', 'Move to trash'],
|
||||
|
||||
@@ -57,13 +57,15 @@
|
||||
let helpOpen = $state(false);
|
||||
|
||||
// g-then-letter and 1–5 jump between sections; both honour the remembered
|
||||
// per-section URL so you land back on the same filter/scroll.
|
||||
// per-section URL so you land back on the same filter/scroll. Keyed by
|
||||
// KeyboardEvent.code (physical key) so the shortcuts work the same on any
|
||||
// layout — on a Russian layout the `f` key still triggers Files, etc.
|
||||
const G_MAP: Record<string, string> = {
|
||||
c: '/categories',
|
||||
t: '/tags',
|
||||
f: '/files',
|
||||
p: '/pools',
|
||||
s: '/settings'
|
||||
KeyC: '/categories',
|
||||
KeyT: '/tags',
|
||||
KeyF: '/files',
|
||||
KeyP: '/pools',
|
||||
KeyS: '/settings'
|
||||
};
|
||||
const NUM_MAP = navItems.map((it) => it.match); // 1→categories … 5→settings
|
||||
|
||||
@@ -90,7 +92,8 @@
|
||||
if (isEditable(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (isLogin) return;
|
||||
|
||||
if (e.key === '?') {
|
||||
// Help: `?` by character, or Shift+/ by position (covers non-US layouts).
|
||||
if (e.key === '?' || (e.code === 'Slash' && e.shiftKey)) {
|
||||
helpOpen = !helpOpen;
|
||||
e.preventDefault();
|
||||
return;
|
||||
@@ -98,8 +101,8 @@
|
||||
|
||||
// Focus the page's search box. Pages with a persistent one (Tags/Categories/
|
||||
// Pools) are handled here; Files has no always-on input, so its own handler
|
||||
// opens the filter instead.
|
||||
if (e.key === '/') {
|
||||
// opens the filter instead. Match `/` by character or by Slash position.
|
||||
if (!e.shiftKey && (e.key === '/' || e.code === 'Slash')) {
|
||||
const input = document.querySelector<HTMLInputElement>('input[type="search"]');
|
||||
if (input) {
|
||||
e.preventDefault();
|
||||
@@ -108,28 +111,30 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// The remaining shortcuts are unshifted letters/digits, matched by physical
|
||||
// position so the layout (Latin or not) doesn't matter.
|
||||
if (e.shiftKey) return;
|
||||
|
||||
if (pendingG) {
|
||||
pendingG = false;
|
||||
clearTimeout(gTimer);
|
||||
const dest = G_MAP[e.key.toLowerCase()];
|
||||
const dest = G_MAP[e.code];
|
||||
if (dest) {
|
||||
e.preventDefault();
|
||||
go(dest);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'g') {
|
||||
if (e.code === 'KeyG') {
|
||||
pendingG = true;
|
||||
clearTimeout(gTimer);
|
||||
gTimer = setTimeout(() => (pendingG = false), 1000);
|
||||
return;
|
||||
}
|
||||
if (e.key >= '1' && e.key <= '5') {
|
||||
const dest = NUM_MAP[Number(e.key) - 1];
|
||||
if (dest) {
|
||||
e.preventDefault();
|
||||
go(dest);
|
||||
}
|
||||
const digit = /^(?:Digit|Numpad)([1-5])$/.exec(e.code);
|
||||
if (digit) {
|
||||
e.preventDefault();
|
||||
go(NUM_MAP[Number(digit[1]) - 1]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -71,12 +71,29 @@
|
||||
focusedId = files[next]?.id ?? null;
|
||||
if (next >= files.length - gridCols() * 2 && hasMore && !loading) void loadMore();
|
||||
const id = focusedId;
|
||||
requestAnimationFrame(() => {
|
||||
const idx = files.findIndex((f) => f.id === id);
|
||||
scrollContainer
|
||||
?.querySelector<HTMLElement>(`[data-file-index="${idx}"]`)
|
||||
?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
requestAnimationFrame(() => keepFocusedInView(id));
|
||||
}
|
||||
|
||||
// Keep the focused card within the scroller, leaving a margin at the bottom for
|
||||
// the fixed navbar (which overlaps the scroll area and otherwise hides the row
|
||||
// the focus moves onto). scrollIntoView can't account for that overlay.
|
||||
const FOCUS_MARGIN_TOP = 8;
|
||||
const FOCUS_MARGIN_BOTTOM = 72; // ~navbar height + gap
|
||||
|
||||
function keepFocusedInView(id: string | null) {
|
||||
if (!id || !scrollContainer) return;
|
||||
const idx = files.findIndex((f) => f.id === id);
|
||||
const card = scrollContainer.querySelector<HTMLElement>(`[data-file-index="${idx}"]`);
|
||||
if (!card) return;
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const scRect = scrollContainer.getBoundingClientRect();
|
||||
const top = cardRect.top - scRect.top;
|
||||
const bottom = cardRect.bottom - scRect.top;
|
||||
if (top < FOCUS_MARGIN_TOP) {
|
||||
scrollContainer.scrollTop += top - FOCUS_MARGIN_TOP;
|
||||
} else if (bottom > scRect.height - FOCUS_MARGIN_BOTTOM) {
|
||||
scrollContainer.scrollTop += bottom - (scRect.height - FOCUS_MARGIN_BOTTOM);
|
||||
}
|
||||
}
|
||||
|
||||
// Action keys operate on the selection; with nothing selected they fall back to
|
||||
@@ -86,6 +103,24 @@
|
||||
if (f?.id && !$selectionStore.ids.has(f.id)) selectionStore.select(f.id);
|
||||
}
|
||||
|
||||
// Select via the keyboard: a plain press toggles the focused card and drops the
|
||||
// range anchor there; a Shift press selects everything from the anchor to the
|
||||
// focused card — the same model as Shift+click on the grid.
|
||||
function selectFocused(range: boolean) {
|
||||
const idx = focusedId ? files.findIndex((f) => f.id === focusedId) : -1;
|
||||
if (idx < 0) return;
|
||||
if (range && lastSelectedIdx !== null) {
|
||||
const from = Math.min(lastSelectedIdx, idx);
|
||||
const to = Math.max(lastSelectedIdx, idx);
|
||||
for (let i = from; i <= to; i++) {
|
||||
if (files[i]?.id) selectionStore.select(files[i].id!);
|
||||
}
|
||||
} else if (files[idx]?.id) {
|
||||
selectionStore.toggle(files[idx].id!);
|
||||
}
|
||||
lastSelectedIdx = idx;
|
||||
}
|
||||
|
||||
function openTagEditor() {
|
||||
tagEditorOpen = true;
|
||||
void tick().then(() => document.querySelector<HTMLInputElement>('.tag-sheet input')?.focus());
|
||||
@@ -112,62 +147,72 @@
|
||||
if (activeFileId || tagEditorOpen || poolPickerOpen || confirmDeleteFiles) return;
|
||||
if (isFormTarget(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
// Navigation / named keys — same on every layout.
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
moveFocus(1);
|
||||
break;
|
||||
return;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
moveFocus(-1);
|
||||
break;
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
moveFocus(gridCols());
|
||||
break;
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
moveFocus(-gridCols());
|
||||
break;
|
||||
return;
|
||||
case 'Enter': {
|
||||
const f = focusedFile();
|
||||
if (f) {
|
||||
e.preventDefault();
|
||||
openFile(f);
|
||||
}
|
||||
break;
|
||||
return;
|
||||
}
|
||||
case ' ':
|
||||
case 'x': {
|
||||
const f = focusedFile();
|
||||
if (f?.id) {
|
||||
e.preventDefault();
|
||||
selectionStore.toggle(f.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'e':
|
||||
if ($selectionActive || focusedFile()) {
|
||||
e.preventDefault();
|
||||
ensureSelectedFocused();
|
||||
openTagEditor();
|
||||
}
|
||||
break;
|
||||
case 'p':
|
||||
if ($selectionActive || focusedFile()) {
|
||||
e.preventDefault();
|
||||
ensureSelectedFocused();
|
||||
void openPoolPicker();
|
||||
}
|
||||
break;
|
||||
e.preventDefault();
|
||||
selectFocused(e.shiftKey);
|
||||
return;
|
||||
case 'Delete':
|
||||
if ($selectionActive || focusedFile()) {
|
||||
e.preventDefault();
|
||||
ensureSelectedFocused();
|
||||
confirmDeleteFiles = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Select by position (x), Shift = range — handled before the unshifted-only
|
||||
// guard below because Shift+x is a valid range-select.
|
||||
if (e.code === 'KeyX') {
|
||||
e.preventDefault();
|
||||
selectFocused(e.shiftKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// The remaining letter / symbol commands are unshifted-only, matched by
|
||||
// physical position so they fire the same on a non-Latin layout.
|
||||
if (e.shiftKey) return;
|
||||
switch (e.code) {
|
||||
case 'KeyE':
|
||||
if ($selectionActive || focusedFile()) {
|
||||
e.preventDefault();
|
||||
ensureSelectedFocused();
|
||||
openTagEditor();
|
||||
}
|
||||
break;
|
||||
case '/':
|
||||
case 'KeyP':
|
||||
if ($selectionActive || focusedFile()) {
|
||||
e.preventDefault();
|
||||
ensureSelectedFocused();
|
||||
void openPoolPicker();
|
||||
}
|
||||
break;
|
||||
case 'Slash':
|
||||
e.preventDefault();
|
||||
openFilterAndFocus();
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user