Compare commits
3 Commits
19ec96c544
...
bce79867e4
| Author | SHA1 | Date | |
|---|---|---|---|
| bce79867e4 | |||
| e39cda9ec4 | |||
| 94d100675e |
@@ -189,11 +189,14 @@
|
|||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
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);
|
if (prevId) onNavigate(prevId);
|
||||||
} else if (e.key === 'ArrowRight' || e.key === 'j') {
|
} else if (e.key === 'ArrowRight' || e.code === 'KeyJ') {
|
||||||
if (nextId) onNavigate(nextId);
|
if (nextId) onNavigate(nextId);
|
||||||
} else if (e.key === 'e') {
|
} else if (e.code === 'KeyE') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
jumpToTags();
|
jumpToTags();
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
['↑ ↓ ← →', 'Move focus between files'],
|
['↑ ↓ ← →', 'Move focus between files'],
|
||||||
['Enter', 'Open the focused file'],
|
['Enter', 'Open the focused file'],
|
||||||
['Space / x', 'Select / deselect'],
|
['Space / x', 'Select / deselect'],
|
||||||
|
['Shift+Space / Shift+x', 'Select a range from the anchor'],
|
||||||
['e', 'Edit tags (focus the tag filter)'],
|
['e', 'Edit tags (focus the tag filter)'],
|
||||||
['p', 'Add to pool'],
|
['p', 'Add to pool'],
|
||||||
['Del', 'Move to trash'],
|
['Del', 'Move to trash'],
|
||||||
|
|||||||
@@ -57,13 +57,15 @@
|
|||||||
let helpOpen = $state(false);
|
let helpOpen = $state(false);
|
||||||
|
|
||||||
// g-then-letter and 1–5 jump between sections; both honour the remembered
|
// 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> = {
|
const G_MAP: Record<string, string> = {
|
||||||
c: '/categories',
|
KeyC: '/categories',
|
||||||
t: '/tags',
|
KeyT: '/tags',
|
||||||
f: '/files',
|
KeyF: '/files',
|
||||||
p: '/pools',
|
KeyP: '/pools',
|
||||||
s: '/settings'
|
KeyS: '/settings'
|
||||||
};
|
};
|
||||||
const NUM_MAP = navItems.map((it) => it.match); // 1→categories … 5→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 (isEditable(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
if (isLogin) 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;
|
helpOpen = !helpOpen;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -98,8 +101,8 @@
|
|||||||
|
|
||||||
// Focus the page's search box. Pages with a persistent one (Tags/Categories/
|
// 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
|
// Pools) are handled here; Files has no always-on input, so its own handler
|
||||||
// opens the filter instead.
|
// opens the filter instead. Match `/` by character or by Slash position.
|
||||||
if (e.key === '/') {
|
if (!e.shiftKey && (e.key === '/' || e.code === 'Slash')) {
|
||||||
const input = document.querySelector<HTMLInputElement>('input[type="search"]');
|
const input = document.querySelector<HTMLInputElement>('input[type="search"]');
|
||||||
if (input) {
|
if (input) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -108,28 +111,30 @@
|
|||||||
return;
|
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) {
|
if (pendingG) {
|
||||||
pendingG = false;
|
pendingG = false;
|
||||||
clearTimeout(gTimer);
|
clearTimeout(gTimer);
|
||||||
const dest = G_MAP[e.key.toLowerCase()];
|
const dest = G_MAP[e.code];
|
||||||
if (dest) {
|
if (dest) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
go(dest);
|
go(dest);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'g') {
|
if (e.code === 'KeyG') {
|
||||||
pendingG = true;
|
pendingG = true;
|
||||||
clearTimeout(gTimer);
|
clearTimeout(gTimer);
|
||||||
gTimer = setTimeout(() => (pendingG = false), 1000);
|
gTimer = setTimeout(() => (pendingG = false), 1000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key >= '1' && e.key <= '5') {
|
const digit = /^(?:Digit|Numpad)([1-5])$/.exec(e.code);
|
||||||
const dest = NUM_MAP[Number(e.key) - 1];
|
if (digit) {
|
||||||
if (dest) {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
go(NUM_MAP[Number(digit[1]) - 1]);
|
||||||
go(dest);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -71,12 +71,29 @@
|
|||||||
focusedId = files[next]?.id ?? null;
|
focusedId = files[next]?.id ?? null;
|
||||||
if (next >= files.length - gridCols() * 2 && hasMore && !loading) void loadMore();
|
if (next >= files.length - gridCols() * 2 && hasMore && !loading) void loadMore();
|
||||||
const id = focusedId;
|
const id = focusedId;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => keepFocusedInView(id));
|
||||||
const idx = files.findIndex((f) => f.id === id);
|
}
|
||||||
scrollContainer
|
|
||||||
?.querySelector<HTMLElement>(`[data-file-index="${idx}"]`)
|
// Keep the focused card within the scroller, leaving a margin at the bottom for
|
||||||
?.scrollIntoView({ block: 'nearest' });
|
// 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
|
// 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);
|
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() {
|
function openTagEditor() {
|
||||||
tagEditorOpen = true;
|
tagEditorOpen = true;
|
||||||
void tick().then(() => document.querySelector<HTMLInputElement>('.tag-sheet input')?.focus());
|
void tick().then(() => document.querySelector<HTMLInputElement>('.tag-sheet input')?.focus());
|
||||||
@@ -112,62 +147,72 @@
|
|||||||
if (activeFileId || tagEditorOpen || poolPickerOpen || confirmDeleteFiles) return;
|
if (activeFileId || tagEditorOpen || poolPickerOpen || confirmDeleteFiles) return;
|
||||||
if (isFormTarget(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
if (isFormTarget(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
|
||||||
|
// Navigation / named keys — same on every layout.
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
moveFocus(1);
|
moveFocus(1);
|
||||||
break;
|
return;
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
moveFocus(-1);
|
moveFocus(-1);
|
||||||
break;
|
return;
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
moveFocus(gridCols());
|
moveFocus(gridCols());
|
||||||
break;
|
return;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
moveFocus(-gridCols());
|
moveFocus(-gridCols());
|
||||||
break;
|
return;
|
||||||
case 'Enter': {
|
case 'Enter': {
|
||||||
const f = focusedFile();
|
const f = focusedFile();
|
||||||
if (f) {
|
if (f) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openFile(f);
|
openFile(f);
|
||||||
}
|
}
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
case ' ':
|
case ' ':
|
||||||
case 'x': {
|
e.preventDefault();
|
||||||
const f = focusedFile();
|
selectFocused(e.shiftKey);
|
||||||
if (f?.id) {
|
return;
|
||||||
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;
|
|
||||||
case 'Delete':
|
case 'Delete':
|
||||||
if ($selectionActive || focusedFile()) {
|
if ($selectionActive || focusedFile()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
ensureSelectedFocused();
|
ensureSelectedFocused();
|
||||||
confirmDeleteFiles = true;
|
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;
|
break;
|
||||||
case '/':
|
case 'KeyP':
|
||||||
|
if ($selectionActive || focusedFile()) {
|
||||||
|
e.preventDefault();
|
||||||
|
ensureSelectedFocused();
|
||||||
|
void openPoolPicker();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Slash':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openFilterAndFocus();
|
openFilterAndFocus();
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user