diff --git a/web/src/lib/actions/gridKeyNav.ts b/web/src/lib/actions/gridKeyNav.ts
index fc3ebbd..ec91aaf 100644
--- a/web/src/lib/actions/gridKeyNav.ts
+++ b/web/src/lib/actions/gridKeyNav.ts
@@ -13,6 +13,7 @@ import {
import { queryClient } from '$lib/queryClient';
import { filters } from '$lib/stores/filters.svelte';
import {
+ clearBulkToFirst,
clearSelection,
focusAfter,
indexOf,
@@ -23,7 +24,7 @@ import {
toggle
} from '$lib/stores/selection.svelte';
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
-import { toggleLeftSidebar, toggleRightSidebar } from '$lib/stores/view.svelte';
+import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
import type { PpPhoto } from '$lib/types/photoprism';
/**
@@ -381,6 +382,24 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
+ // Modal owns arrow / Escape / Space while it's open — it handles
+ // its own linear nav, close-on-Esc, and close-on-Space. Action
+ // keys (X/S/U/A/Z) still pass through because they target the
+ // shared selection store and work the same in either context.
+ if (view.previewOpen) {
+ if (
+ e.key === 'ArrowLeft' ||
+ e.key === 'ArrowRight' ||
+ e.key === 'ArrowUp' ||
+ e.key === 'ArrowDown' ||
+ e.key === 'Escape' ||
+ e.key === ' ' ||
+ e.code === 'Space'
+ ) {
+ return;
+ }
+ }
+
// S+digit chord. A digit 1–9 within the chord window consumes the key
// and fires add-to-heap-N. Any other key cancels the chord without
// firing the default active-heap action — the user switched intent —
@@ -398,6 +417,18 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
const meta = e.metaKey || e.ctrlKey;
const shift = e.shiftKey;
+ // Space on a focused tile opens the full-screen preview modal.
+ // Matches the dblclick gesture so the user has both keyboard and
+ // mouse paths to the same surface. `e.code === 'Space'` covers
+ // layouts where `e.key` is the dead-key combining mark.
+ if ((e.key === ' ' || e.code === 'Space') && !meta && !shift) {
+ if (selection.focused) {
+ e.preventDefault();
+ openPreview();
+ return;
+ }
+ }
+
// ── Grid nav keys ────────────────────────────────────────────────────
switch (e.key) {
case 'ArrowLeft':
@@ -424,6 +455,11 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
}
return;
case 'Escape':
+ // First Esc collapses a multi-selection back to single-focus
+ // on its first member — the user's "starting photo" stays
+ // visible instead of vanishing. Only when there's no bulk
+ // does Esc fully dismiss focus.
+ if (clearBulkToFirst()) return;
clearSelection();
setFocused(null);
return;
diff --git a/web/src/lib/actions/resizableVertical.ts b/web/src/lib/actions/resizableVertical.ts
deleted file mode 100644
index 31dfeff..0000000
--- a/web/src/lib/actions/resizableVertical.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * Drag-to-resize Svelte action for vertical splits. Sibling of `resizable`
- * (which handles left/right edges); kept as its own file so each action's
- * surface stays small and the call sites read obviously.
- *
- * edge: 'bottom' — handle on the bottom edge of the pane; drag down enlarges
- * edge: 'top' — handle on the top edge of the pane; drag up enlarges
- *
- * Usage (handle on the bottom edge of the top preview pane):
- *
view.previewPaneHeight,
- * setHeight: setPreviewPaneHeight }} />
- */
-export interface ResizableVerticalParams {
- edge: 'top' | 'bottom';
- getHeight: () => number;
- setHeight: (px: number) => void;
-}
-
-export function resizableVertical(node: HTMLElement, initial: ResizableVerticalParams) {
- let params = initial;
- let pointerId = -1;
- let startY = 0;
- let startHeight = 0;
-
- function onDown(e: PointerEvent) {
- if (e.button !== 0) return;
- pointerId = e.pointerId;
- startY = e.clientY;
- startHeight = params.getHeight();
- node.setPointerCapture(pointerId);
- document.body.style.cursor = 'row-resize';
- document.body.style.userSelect = 'none';
- node.addEventListener('pointermove', onMove);
- node.addEventListener('pointerup', onUp);
- node.addEventListener('pointercancel', onUp);
- }
-
- function onMove(e: PointerEvent) {
- if (e.pointerId !== pointerId) return;
- const dy = e.clientY - startY;
- // `edge: 'bottom'` — handle on the bottom edge of the controlled pane,
- // drag down grows it. `edge: 'top'` — handle on the top edge of the
- // controlled pane (i.e. the pane is below the handle), drag up grows
- // it, so the delta is inverted. Mirrors the horizontal action.
- const delta = params.edge === 'bottom' ? dy : -dy;
- params.setHeight(startHeight + delta);
- }
-
- function onUp(e: PointerEvent) {
- if (pointerId === -1) return;
- try {
- node.releasePointerCapture(pointerId);
- } catch {
- // Pointer may already be released; ignore.
- }
- pointerId = -1;
- document.body.style.cursor = '';
- document.body.style.userSelect = '';
- node.removeEventListener('pointermove', onMove);
- node.removeEventListener('pointerup', onUp);
- node.removeEventListener('pointercancel', onUp);
- }
-
- node.addEventListener('pointerdown', onDown);
-
- return {
- update(next: ResizableVerticalParams) {
- params = next;
- },
- destroy() {
- node.removeEventListener('pointerdown', onDown);
- }
- };
-}
diff --git a/web/src/lib/components/layout/FolderTree.svelte b/web/src/lib/components/layout/FolderTree.svelte
index e4bf381..f7de61d 100644
--- a/web/src/lib/components/layout/FolderTree.svelte
+++ b/web/src/lib/components/layout/FolderTree.svelte
@@ -120,11 +120,11 @@
with the px-2 of Views/Heaps rows; +12px per nested level.
-->
{#if hasChildren}
- {:else if depth > 0}
+ {:else}
+ peers at every depth, so labels share a common left edge
+ across the sidebar (folders, heaps, views, manage). -->
{/if}