feat(ui): keyboard navigation and focus restore in album grid
- Cursor separate from selection; arrow keys move it, Space/Shift+Arrow/Enter/Esc act - Click on empty grid space clears selection back to album properties - Focus returns to the last viewed photo after closing the viewer
This commit is contained in:
@@ -6,16 +6,31 @@
|
||||
interface Props {
|
||||
photos: Photo[];
|
||||
selectedIds?: string[];
|
||||
cursorIndex?: number | null;
|
||||
cursorVisible?: boolean;
|
||||
onSelect?: (index: number, event: MouseEvent) => void;
|
||||
onOpen?: (index: number) => void;
|
||||
onDragStart?: (index: number, event: DragEvent) => void;
|
||||
onContextMenu?: (index: number, event: MouseEvent) => void;
|
||||
onCursorFocus?: (index: number) => void;
|
||||
}
|
||||
|
||||
let { photos, selectedIds = [], onSelect, onOpen, onDragStart, onContextMenu }: Props =
|
||||
$props();
|
||||
let {
|
||||
photos,
|
||||
selectedIds = [],
|
||||
cursorIndex = null,
|
||||
cursorVisible = false,
|
||||
onSelect,
|
||||
onOpen,
|
||||
onDragStart,
|
||||
onContextMenu,
|
||||
onCursorFocus
|
||||
}: Props = $props();
|
||||
|
||||
const isSelected = (id: string) => selectedIds.includes(id);
|
||||
const isCursor = (i: number) => cursorIndex === i;
|
||||
const tabIndexFor = (i: number) =>
|
||||
cursorIndex == null ? (i === 0 ? 0 : -1) : isCursor(i) ? 0 : -1;
|
||||
</script>
|
||||
|
||||
{#if photos.length === 0}
|
||||
@@ -29,13 +44,18 @@
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
data-photo-cell
|
||||
tabindex={tabIndexFor(i)}
|
||||
draggable={isSelected(photo.id)}
|
||||
class="border-border bg-bg-muted group relative block aspect-square w-full overflow-hidden rounded-lg border {isSelected(
|
||||
photo.id
|
||||
)
|
||||
? 'ring-primary ring-2'
|
||||
: ''} {isCursor(i) && cursorVisible
|
||||
? 'outline outline-2 outline-offset-2 outline-text'
|
||||
: ''}"
|
||||
aria-pressed={isSelected(photo.id)}
|
||||
onfocus={() => onCursorFocus?.(i)}
|
||||
onclick={(e) => onSelect?.(i, e)}
|
||||
ondblclick={() => onOpen?.(i)}
|
||||
ondragstart={(e) => onDragStart?.(i, e)}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
let pending: { albumId: string; photoId: string } | null = null;
|
||||
|
||||
export const viewerReturn = {
|
||||
remember(albumId: string, photoId: string) {
|
||||
if (albumId && photoId) pending = { albumId, photoId };
|
||||
},
|
||||
consume(albumId: string): string | null {
|
||||
const p = pending;
|
||||
pending = null;
|
||||
return p && p.albumId === albumId ? p.photoId : null;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { tick } from 'svelte';
|
||||
import { api, photosApi } from '$lib/api';
|
||||
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
@@ -9,7 +10,9 @@
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import { density } from '$lib/stores/density.svelte';
|
||||
import { selection } from '$lib/stores/selection.svelte';
|
||||
import { viewerReturn } from '$lib/stores/viewer-return';
|
||||
import { transfer } from '$lib/stores/transfer.svelte';
|
||||
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
||||
import { SSEClient, type SSEEvent } from '$lib/sse';
|
||||
@@ -28,6 +31,10 @@
|
||||
let deletingMenu = $state(false);
|
||||
let uploader = $state<{ pick: () => void; upload: (files: FileList | File[]) => void }>();
|
||||
|
||||
let cursorIndex = $state<number | null>(null);
|
||||
let gridFocused = $state(false);
|
||||
let gridSurface: HTMLDivElement | undefined = $state();
|
||||
|
||||
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
|
||||
let canEdit = $derived(role === 'owner' || role === 'admin');
|
||||
|
||||
@@ -57,12 +64,20 @@
|
||||
|
||||
$effect(() => {
|
||||
const id = albumId;
|
||||
const returnPhotoId = viewerReturn.consume(id);
|
||||
selection.clear();
|
||||
cursorIndex = null;
|
||||
loading = true;
|
||||
error = '';
|
||||
Promise.all([loadAlbum(), loadPhotos()])
|
||||
.catch((e) => (error = (e as Error).message))
|
||||
.finally(() => (loading = false));
|
||||
.then(async () => {
|
||||
loading = false;
|
||||
if (returnPhotoId) await restoreFocus(id, returnPhotoId);
|
||||
})
|
||||
.catch((e) => {
|
||||
error = (e as Error).message;
|
||||
loading = false;
|
||||
});
|
||||
});
|
||||
|
||||
let client: SSEClient | null = null;
|
||||
@@ -144,6 +159,7 @@
|
||||
function selectPhoto(i: number, e: MouseEvent) {
|
||||
const photo = photos[i];
|
||||
if (!photo) return;
|
||||
cursorIndex = i;
|
||||
const cur = selection.albumId === albumId ? selection.items : [];
|
||||
if (e.shiftKey && selection.anchorId && selection.albumId === albumId) {
|
||||
const a = photos.findIndex((p) => p.id === selection.anchorId);
|
||||
@@ -170,6 +186,7 @@
|
||||
function dragPhoto(i: number, e: DragEvent) {
|
||||
const photo = photos[i];
|
||||
if (!photo) return;
|
||||
cursorIndex = i;
|
||||
if (!(selection.albumId === albumId && selection.has(photo.id))) {
|
||||
selection.set(albumId, [photo]);
|
||||
selection.anchorId = photo.id;
|
||||
@@ -193,6 +210,7 @@
|
||||
e.stopPropagation();
|
||||
const photo = photos[i];
|
||||
if (!photo) return;
|
||||
cursorIndex = i;
|
||||
const inSel = selection.albumId === albumId && selection.has(photo.id);
|
||||
const multi = inSel && selection.count > 1;
|
||||
if (!inSel) {
|
||||
@@ -270,6 +288,151 @@
|
||||
deletingMenu = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cellAt(i: number): HTMLElement | null {
|
||||
return gridSurface?.querySelectorAll<HTMLElement>('[data-photo-cell]')[i] ?? null;
|
||||
}
|
||||
|
||||
function focusCursor(i: number) {
|
||||
if (i < 0 || i >= photos.length) return;
|
||||
cursorIndex = i;
|
||||
const el = cellAt(i);
|
||||
if (el) {
|
||||
el.focus({ preventScroll: true });
|
||||
el.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function extendSelection(next: number, origin: number) {
|
||||
let anchorIdx = origin;
|
||||
if (selection.albumId === albumId && selection.anchorId) {
|
||||
const found = photos.findIndex((p) => p.id === selection.anchorId);
|
||||
if (found >= 0) anchorIdx = found;
|
||||
}
|
||||
if (anchorIdx < 0 || anchorIdx >= photos.length) anchorIdx = next;
|
||||
const anchorPhoto = photos[anchorIdx];
|
||||
if (anchorPhoto) selection.anchorId = anchorPhoto.id;
|
||||
const lo = Math.min(anchorIdx, next);
|
||||
const hi = Math.max(anchorIdx, next);
|
||||
selection.set(albumId, photos.slice(lo, hi + 1));
|
||||
focusCursor(next);
|
||||
}
|
||||
|
||||
async function moveCursor(next: number, origin: number, shift: boolean) {
|
||||
if (next < 0) return;
|
||||
if (next >= photos.length) {
|
||||
if (!nextCursor) return;
|
||||
await loadMore();
|
||||
await tick();
|
||||
if (next >= photos.length) return;
|
||||
}
|
||||
const i = Math.min(photos.length - 1, next);
|
||||
if (shift) extendSelection(i, origin);
|
||||
else focusCursor(i);
|
||||
}
|
||||
|
||||
async function restoreFocus(id: string, targetId: string) {
|
||||
let idx = photos.findIndex((p) => p.id === targetId);
|
||||
while (idx < 0 && nextCursor) {
|
||||
await loadMore();
|
||||
idx = photos.findIndex((p) => p.id === targetId);
|
||||
}
|
||||
if (idx < 0) return;
|
||||
const photo = photos[idx];
|
||||
selection.set(id, [photo]);
|
||||
selection.anchorId = photo.id;
|
||||
cursorIndex = idx;
|
||||
await tick();
|
||||
const el = cellAt(idx);
|
||||
if (el) {
|
||||
el.focus({ preventScroll: true });
|
||||
el.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCursor() {
|
||||
const i = cursorIndex;
|
||||
if (i == null) return;
|
||||
const photo = photos[i];
|
||||
if (!photo) return;
|
||||
const cur = selection.albumId === albumId ? selection.items : [];
|
||||
const exists = cur.some((p) => p.id === photo.id);
|
||||
selection.set(albumId, exists ? cur.filter((p) => p.id !== photo.id) : [...cur, photo]);
|
||||
selection.anchorId = photo.id;
|
||||
}
|
||||
|
||||
function onGridKeydown(e: KeyboardEvent) {
|
||||
const target = e.target as Element | null;
|
||||
if (!target) return;
|
||||
const inCell = !!target.closest('[data-photo-cell]');
|
||||
if (!inCell && target.closest('button, a, input, select, textarea')) return;
|
||||
if (photos.length === 0) return;
|
||||
|
||||
const key = e.key;
|
||||
if (
|
||||
key === 'ArrowLeft' ||
|
||||
key === 'ArrowRight' ||
|
||||
key === 'ArrowUp' ||
|
||||
key === 'ArrowDown'
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (cursorIndex == null) {
|
||||
if (e.shiftKey) extendSelection(0, 0);
|
||||
else focusCursor(0);
|
||||
return;
|
||||
}
|
||||
const origin = cursorIndex;
|
||||
const cols = Math.max(1, density.cols);
|
||||
let next = origin;
|
||||
if (key === 'ArrowLeft') next = origin - 1;
|
||||
else if (key === 'ArrowRight') next = origin + 1;
|
||||
else if (key === 'ArrowUp') next = origin - cols;
|
||||
else next = origin + cols;
|
||||
moveCursor(next, origin, e.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (key === ' ' || key === 'Spacebar') {
|
||||
if (cursorIndex == null) return;
|
||||
e.preventDefault();
|
||||
toggleCursor();
|
||||
return;
|
||||
}
|
||||
if (key === 'Enter') {
|
||||
if (cursorIndex == null) return;
|
||||
const p = photos[cursorIndex];
|
||||
if (!p) return;
|
||||
e.preventDefault();
|
||||
goto(`/albums/${albumId}/photos/${p.id}`);
|
||||
return;
|
||||
}
|
||||
if (key === 'Escape') {
|
||||
if (selection.albumId === albumId && selection.items.length > 0) {
|
||||
e.preventDefault();
|
||||
selection.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onGridClick(e: MouseEvent) {
|
||||
const target = e.target as Element | null;
|
||||
if (!target) return;
|
||||
if (target.closest('[data-photo-cell]')) return;
|
||||
if (target.closest('button, a, input, select, textarea, [role="button"]')) return;
|
||||
if (selection.albumId === albumId && selection.items.length > 0) {
|
||||
selection.clear();
|
||||
}
|
||||
gridSurface?.focus();
|
||||
}
|
||||
|
||||
function onGridFocusIn() {
|
||||
gridFocused = true;
|
||||
}
|
||||
|
||||
function onGridFocusOut(e: FocusEvent) {
|
||||
const el = e.currentTarget as Node | null;
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (!el || !next || !el.contains(next)) gridFocused = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -322,7 +485,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-3 sm:p-4">
|
||||
<div
|
||||
class="flex-1 p-3 sm:p-4"
|
||||
role="grid"
|
||||
aria-label="Сетка фотографий"
|
||||
aria-multiselectable="true"
|
||||
tabindex="-1"
|
||||
bind:this={gridSurface}
|
||||
onclick={onGridClick}
|
||||
onkeydown={onGridKeydown}
|
||||
onfocusin={onGridFocusIn}
|
||||
onfocusout={onGridFocusOut}
|
||||
>
|
||||
{#if canUpload && photos.length === 0}
|
||||
<button
|
||||
type="button"
|
||||
@@ -336,6 +510,9 @@
|
||||
<PhotoGrid
|
||||
{photos}
|
||||
selectedIds={selection.albumId === albumId ? selection.ids : []}
|
||||
cursorIndex={cursorIndex}
|
||||
cursorVisible={gridFocused}
|
||||
onCursorFocus={(i) => (cursorIndex = i)}
|
||||
onSelect={selectPhoto}
|
||||
onDragStart={dragPhoto}
|
||||
onContextMenu={openMenuForPhoto}
|
||||
|
||||
@@ -5,11 +5,18 @@
|
||||
import Viewer from '$lib/components/Viewer.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
||||
import { viewerReturn } from '$lib/stores/viewer-return';
|
||||
import type { Album, Photo } from '$lib/types';
|
||||
|
||||
const albumId = $derived($page.params.id ?? '');
|
||||
const photoId = $derived($page.params.photoId ?? '');
|
||||
|
||||
$effect(() => {
|
||||
const aid = albumId;
|
||||
const pid = photoId;
|
||||
if (aid && pid) viewerReturn.remember(aid, pid);
|
||||
});
|
||||
|
||||
let photos = $state<Photo[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-11
|
||||
@@ -0,0 +1,160 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
См. proposal.md — Why. Текущее состояние, формирующее подход:
|
||||
|
||||
- `lib/components/PhotoGrid.svelte` — `<ul class="grid">` с CSS-колонками по `density.cols`
|
||||
(2–8, фиксированное число, `lib/stores/density.svelte.ts`), ячейки — `<button>` с
|
||||
`aria-pressed`, `onclick`/`ondblclick`/`ondragstart`/`oncontextmenu`.
|
||||
- `lib/stores/selection.svelte.ts` хранит `items: Photo[]` и `anchorId` (последний клик).
|
||||
- `lib/components/AlbumPanel.svelte:25-27` выводит правую панель: маршрутное фото → панель
|
||||
фото; одно выбранное → панель фото; несколько → строки действий; иначе — свойства альбома.
|
||||
- В сетке нет обработчиков клавиатуры и нет реакции на клик по пустому месту. Viewer уже задаёт
|
||||
«lightroom»-условности (стрелки, `Space`, `Enter`), но в другом маршруте.
|
||||
- Сетка точечно решает геометрию по индексу, а действия (range, меню, drag, `goto`) живут в
|
||||
`routes/(app)/albums/[id]/+page.svelte`.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Курсор — состояние, отдельное от выделения; стрелки двигают только курсор.
|
||||
- Клавиши `Space`/`Shift`+стрелки/`Enter`/`Esc` управляют выделением и просмотром.
|
||||
- Клик по пустому месту сетки и её отступам снимает выделение и возвращает свойства альбома.
|
||||
- Клавиатурная доступность без смены семантики существующих ячеек; поведение мыши сохраняется.
|
||||
|
||||
**Non-Goals:**
|
||||
- Изменение контракта стора выделения или API/данных.
|
||||
- Клавиатурная навигация на обзоре альбомов и в корзине.
|
||||
- Изменение числа колонок сетки или адаптивного реflow.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Модель: курсор отдельно от выделения (B)
|
||||
Выбран вариант B по решению пользователя: стрелки двигают курсор, выделение меняется только
|
||||
`Space`/`Shift`+стрелки/кликом. Правая панель реагирует только на выделение, поэтому панель не
|
||||
«мигает» при движении курсора. Альтернатива A (выделение следует за стрелками) отклонена.
|
||||
|
||||
### 2. Разделение ответственности: поверхность сетки в странице, PhotoGrid презентационен
|
||||
Поверхность сетки — контейнер сетки вместе с отступами в `+page.svelte` (тот же, что нужен для
|
||||
клика по пустому месту). Он владеет `keydown`/`click`/`focusin`/`focusout` и `tabindex="-1"`.
|
||||
Обработчик `keydown` висит на этой поверхности (не на `window`) и работает, только когда фокус
|
||||
внутри — это исключает столкновения с полями ввода, диалогами и Viewer. `PhotoGrid` остаётся
|
||||
презентационным: принимает `cursorIndex` и `cursorVisible`, отдаёт `onCursorFocus(index)` на
|
||||
`onfocus` ячейки, рисует контур курсора и roving `tabindex`. Страница хранит `cursorIndex` и
|
||||
решает выделение, `loadMore`, `scrollIntoView`, `goto`.
|
||||
|
||||
```
|
||||
keydown (поверхность сетки = контейнер + отступы)
|
||||
├─ ArrowLeft/Right/Up/Down ──► moveCursor(next) // курсор
|
||||
├─ Shift+Arrow ──────────────► moveCursor(next, extend) // курсор + выделение
|
||||
├─ Space ────────────────────► toggleCursor()
|
||||
├─ Enter ────────────────────► goto(/albums/:id/photos/:i)
|
||||
└─ Esc ──────────────────────► selection.clear()
|
||||
|
||||
click (поверхность, вне [data-photo-cell] и органов управления)
|
||||
└─ selection.clear(); поверхность.focus()
|
||||
|
||||
onfocus ячейки ──► onCursorFocus(i) // клик и Tab ставят курсор
|
||||
|
||||
страница (+page.svelte)
|
||||
cursorIndex: number | null selection (store) anchorId (store)
|
||||
```
|
||||
|
||||
Альтернатива — держать курсор и `keydown` внутри `PhotoGrid` — отклонена: клик по пустому месту
|
||||
должен покрывать отступы вокруг сетки, а `cursorIndex` нужен странице для `loadMore`, прокрутки и
|
||||
синхронизации с мышью. Поэтому поверхностью становится контейнер страницы.
|
||||
|
||||
### 3. Геометрия по плоскому индексу
|
||||
Сетка — плоский список в порядке документа, поэтому:
|
||||
|
||||
- `←`/`→` — `index ± 1`; на границе строки переход на соседнюю строку происходит естественно,
|
||||
отдельного случая не нужно; на краях сетки курсор остаётся на месте.
|
||||
- `↑`/`↓` — `index ± density.cols`; если целевой индекс выходит за `[0, n-1]`, курсор остаётся
|
||||
на месте (без вертикального переноса). При наличии `nextCursor` движение вперёд загружает
|
||||
следующую порцию и только затем перемещает курсор.
|
||||
|
||||
Вычисление живёт в `PhotoGrid` (там известен `density.cols`); страница получает уже готовый
|
||||
индекс.
|
||||
|
||||
### 4. Визуализация: рамка выделения + контур курсора
|
||||
Выделение сохраняет `ring-2 ring-primary`. Курсор получает `data-cursor` и внешний
|
||||
`outline-2 outline-offset-2` в токене `text` (контрастен во всех темах, включая нейтральную).
|
||||
Оба состояния рисуются на одной ячейке без конфликта.
|
||||
|
||||
```
|
||||
selection cursor both
|
||||
+---------+ + - - - - + + - - - - - +
|
||||
| [photo] | | [photo] | | +-------+ |
|
||||
| ring-pri| | outline | | |[photo]| |
|
||||
+---------+ + - - - - + | +-------+ |
|
||||
+ - - - - - +
|
||||
```
|
||||
|
||||
Контур курсора показывается только при `:focus-within` сетки, чтобы не «зависал» после клика в
|
||||
панели. Программный `.focus()` не гарантирует `:focus-visible`, поэтому видимость управляется
|
||||
состоянием (`data-cursor`), а не нативным фокус-рингом.
|
||||
|
||||
### 5. Roving tabindex
|
||||
Ячейка под курсором получает `tabindex="0"`, остальные — `-1`; при пустом курсоре `0` у первой
|
||||
ячейки. Так Tab вводит фокус в сетку один раз, а внутренняя навигация — стрелками. Роли не
|
||||
меняются: ячейки остаются `<button aria-pressed>`, что не ломает существующий a11y и мышь.
|
||||
|
||||
### 6. Снятие выделения кликом
|
||||
Обработчик клика вешается на контейнер сетки вместе с отступами (обёртка `PhotoGrid` в
|
||||
`+page.svelte`, а не `<body>`/`<main>`). Внутри: если `(e.target as Element).closest(
|
||||
'[data-photo-cell]')` — выход; иначе `selection.clear()`. Клики по тулбару, рейлу и панелям
|
||||
обработчик не видит физически. Чтобы стрелки работали сразу, контейнер получает
|
||||
`tabindex="-1"` и `.focus()` после снятия.
|
||||
|
||||
### 7. Сброс и синхронизация с мышью
|
||||
- Клик по фото выставляет и выделение, и `cursorIndex`.
|
||||
- `ctrl`/`shift`-клик и контекстное меню также ставят курсор.
|
||||
- При смене альбома `cursorIndex = null` (в том же `$effect`, где `selection.clear()`).
|
||||
- Курсор не персистится.
|
||||
|
||||
### 8. Возврат фокуса из просмотра: однослотовая память
|
||||
Просмотр и сетка — соседние маршруты, поэтому при возврате `+page.svelte` сетки монтируется
|
||||
заново и теряет `cursorIndex`/выделение. Вводится модульный стор `lib/stores/viewer-return.ts` с
|
||||
одним слотом `{ albumId, photoId }`:
|
||||
|
||||
```
|
||||
маршрут просмотра ($effect на photoId) ──► viewerReturn.remember(albumId, photoId)
|
||||
(обновляется при каждом change)
|
||||
сетка ($effect на albumId, в начале) ──► viewerReturn.consume(albumId)
|
||||
├─ вернул photoId этого альбома ──► после загрузки: selection=[photo], cursor=index,
|
||||
│ .focus() + scrollIntoView({block:'center'})
|
||||
│ (если фото нет — loadMore() пока не появится)
|
||||
└─ иначе ──► обычный пустой альбом, без автофокуса
|
||||
```
|
||||
|
||||
Один слот, а не `Map`, сознательно: если после просмотра открыть другой альбом, слот
|
||||
израсходуется и сбросится — «висячая» память не сработает на постороннем альбоме. Память
|
||||
хранится в модуле (переживает навигацию/монтирование) и живёт только до следующего монтирования
|
||||
сетки. Восстановление вызывается после `loading = false` и `await tick()`, иначе DOM-ячейки ещё
|
||||
нет. Альтернатива — `history.state`/`sessionStorage` — отклонена: не покрывает браузерный Back.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Кольца выделения и курсора визуально спорят] → контур курсора с `outline-offset-2` снаружи
|
||||
рамки и в токене `text`; проверка в светлой, тёмной и нейтральной темах.
|
||||
- [Стрелки перехватывают прокрутку `Space`] → `keydown` только на контейнере сетки;
|
||||
`preventDefault` для `Space` лишь при наличии курсора.
|
||||
- [`↑`/`↓` при неполной последней строке] → если смещение на `density.cols` уводит за
|
||||
последнюю строку, курсор остаётся на месте, как требует сценарий вертикальных границ.
|
||||
- [Курсор на ещё не загруженном фото] → при движении вперёд за последний загруженный и наличии
|
||||
`nextCursor` вызывается `loadMore()`, затем курсор ставится на новое фото; без `nextCursor` —
|
||||
курсор остаётся на месте.
|
||||
- [Смена `density.cols` во время навигации] → индекс курсора хранится плоским, поэтому при
|
||||
изменении числа колонок `↑`/`↓` просто считают по новому значению; отдельного переселения не
|
||||
требуется.
|
||||
- [Восстановление фокуса срабатывает не вовремя] → слот израсходуется при первом монтировании
|
||||
сетки и сбрасывается, если альбом не тот; восстановление выполняется после `loading = false`
|
||||
и `tick()`, поэтому ячейка уже в DOM.
|
||||
- [Просмотренное фото вне первой страницы] → `restoreFocus` догружает страницы (`loadMore`) в
|
||||
цикле, пока фото не появится или пока не кончится `nextCursor`.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Только фронтенд, без изменений данных и API. Откат — удаление обработчиков и состояния курсора;
|
||||
изменений в хранилище нет. Проверка: `cd frontend && npm run check` и ручной прогон в трёх темах.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Сетку фото можно выбирать только мышью: клавиатура не даёт перемещаться между фото, а снять
|
||||
выделение можно лишь выбрав другое фото. Пользователю в «lightroom»-режиме нужна навигация
|
||||
стрелками и быстрый возврат к свойствам альбома кликом по пустому месту сетки.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Курсор сетки (новое состояние).** Сетка получает «курсор» — текущую ячейку, отдельную от
|
||||
выделения. Стрелки двигают курсор; выделение при этом не меняется, поэтому правая панель
|
||||
остаётся на прежнем фото или свойствах альбома.
|
||||
- **Клавиши.** `←/→` — курсор по документу (с переносом на соседнюю строку), `↑/↓` — на строку
|
||||
(`density.cols`), с упором в границы. `Space` — переключить выделение ячейки под курсором
|
||||
(правая панель обновляется), `Shift+стрелки` — двигать курсор и расширять выделение от
|
||||
якоря, `Enter` — открыть просмотр, `Esc` — снять выделение. Клавиши работают, только когда
|
||||
фокус внутри сетки.
|
||||
- **Снятие выделения кликом.** Клик по области сетки и её отступам (не по фото и не по
|
||||
элементам тулбара/панелей) снимает выделение и возвращает в правой панели свойства
|
||||
альбома; фокус остаётся в сетке, чтобы стрелки работали сразу.
|
||||
- **Визуализация.** Курсор показывается отдельным контуром, отличимым от рамки выделения;
|
||||
оба состояния могут отображаться на одной ячейке.
|
||||
- **Возврат фокуса из просмотра.** При закрытии просмотра сетка выделяет последнее просмотренное
|
||||
фото, ставит на него курсор, возвращает фокус и прокручивает к ячейке; фото догружается, если
|
||||
его нет на загруженной странице.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `ui-workspace`: добавляются требования «Курсор в сетке фото», «Клавиши сетки фото» и «Снятие
|
||||
выделения кликом по пустому месту». Поведение выбора мышью не меняется.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Frontend:** `lib/components/PhotoGrid.svelte` (состояние курсора, обработка клавиш, контур,
|
||||
roving tabindex), `routes/(app)/albums/[id]/+page.svelte` (курсор/якорь/снятие выделения,
|
||||
обработчик клика по пустому месту, `scrollIntoView`, догрузка при выходе за загруженные
|
||||
фото). Стор выделения (`lib/stores/selection.svelte.ts`) не меняет контракт.
|
||||
- **Спеки:** `ui-workspace` (дельта).
|
||||
- **Совместимость:** API и данные не затрагиваются; поведение мыши (клик, ctrl/shift, двойной
|
||||
клик, контекстное меню, drag) сохраняется.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Курсор в сетке фото
|
||||
Сетка SHALL хранить курсор — текущую ячейку, отдельную от выделения. Перемещение курсора SHALL
|
||||
не менять выделение и не обновлять правую панель. Курсор SHALL отображаться контуром,
|
||||
отличимым от рамки выделения, SHALL сохраняться при снятии выделения и SHALL устанавливаться
|
||||
на фото при клике по нему мышью.
|
||||
|
||||
#### Scenario: Синхронизация с мышью
|
||||
- **WHEN** пользователь кликает по фото в сетке
|
||||
- **THEN** фото выделено, а курсор установлен на нём
|
||||
|
||||
#### Scenario: Перемещение не меняет выделение
|
||||
- **WHEN** фокус в сетке и пользователь нажимает стрелку
|
||||
- **THEN** курсор перемещается, а выделение и правая панель не меняются
|
||||
|
||||
#### Scenario: Курсор переживает снятие выделения
|
||||
- **WHEN** выделение снято кликом по пустому месту или клавишей Esc
|
||||
- **THEN** курсор остаётся на прежней ячейке
|
||||
|
||||
#### Scenario: Отображение курсора
|
||||
- **WHEN** сетка в фокусе и у курсора есть ячейка
|
||||
- **THEN** ячейка показывает контур, отличимый от рамки выделения, и он скрывается при уходе фокуса из сетки
|
||||
|
||||
### Requirement: Клавиши сетки фото
|
||||
При фокусе в сетке клавиши SHALL управлять курсором и выделением: `←`/`→` — на соседнее фото с
|
||||
переходом на соседнюю строку, `↑`/`↓` — на строку (`density.cols`) с упором в границы.
|
||||
`Space` SHALL переключать выделение ячейки под курсором, `Shift`+стрелки — двигать курсор и
|
||||
расширять выделение от якоря, `Enter` — открывать просмотр, `Esc` — снимать выделение. Вне
|
||||
фокуса сетки клавиши SHALL не действовать.
|
||||
|
||||
#### Scenario: Горизонтальное перемещение
|
||||
- **WHEN** курсор не в конце строки и нажато `→`
|
||||
- **THEN** курсор переходит на следующее фото
|
||||
|
||||
#### Scenario: Перенос на соседнюю строку
|
||||
- **WHEN** курсор в конце строки и нажато `→`
|
||||
- **THEN** курсор переходит на первое фото следующей строки
|
||||
|
||||
#### Scenario: Вертикальные границы
|
||||
- **WHEN** курсор в верхней или нижней строке и нажато `↑` или `↓`
|
||||
- **THEN** курсор остаётся на месте, без переноса
|
||||
|
||||
#### Scenario: Переключение выделения
|
||||
- **WHEN** нажато `Space`
|
||||
- **THEN** ячейка под курсором добавляется или убирается из выделения, а правая панель обновляется
|
||||
|
||||
#### Scenario: Расширение выделения
|
||||
- **WHEN** нажато `Shift` вместе со стрелкой
|
||||
- **THEN** курсор двигается, а выделение расширяется от якоря до курсора
|
||||
|
||||
#### Scenario: Открытие просмотра
|
||||
- **WHEN** нажато `Enter`
|
||||
- **THEN** открывается просмотр фото под курсором
|
||||
|
||||
#### Scenario: Снятие выделения клавишей
|
||||
- **WHEN** нажато `Esc`
|
||||
- **THEN** выделение снимается, а курсор остаётся
|
||||
|
||||
#### Scenario: Прокрутка к курсору
|
||||
- **WHEN** курсор перемещается за пределы видимой области сетки
|
||||
- **THEN** сетка прокручивается так, чтобы ячейка под курсором стала видимой
|
||||
|
||||
#### Scenario: Догрузка при выходе за загруженные фото
|
||||
- **WHEN** курсор на последнем загруженном фото и нажата стрелка в сторону следующих фото
|
||||
- **THEN** следующая порция фото догружается, а курсор переходит на следующее фото
|
||||
|
||||
#### Scenario: Вне фокуса сетки
|
||||
- **WHEN** фокус находится в поле ввода или диалоге и нажаты стрелки или `Space`
|
||||
- **THEN** курсор и выделение сетки не меняются
|
||||
|
||||
### Requirement: Снятие выделения кликом по пустому месту
|
||||
Клик по области сетки и её отступам, не по фото и не по органам управления тулбара, рейла и
|
||||
панелей, SHALL снимать выделение и возвращать в правой панели свойства альбома. Фокус SHALL
|
||||
оставаться в сетке, чтобы клавиши продолжали действовать.
|
||||
|
||||
#### Scenario: Клик по пустому месту
|
||||
- **WHEN** есть выделение и пользователь кликает по пустому месту сетки или её отступам
|
||||
- **THEN** выделение снято, а правая панель показывает свойства альбома
|
||||
|
||||
#### Scenario: Мультивыделение
|
||||
- **WHEN** выделено несколько фото и пользователь кликает по пустому месту
|
||||
- **THEN** выделение снимается полностью
|
||||
|
||||
#### Scenario: Клик по фото
|
||||
- **WHEN** пользователь кликает по фото
|
||||
- **THEN** выделение устанавливается на фото, а не снимается
|
||||
|
||||
#### Scenario: Клик по органам управления
|
||||
- **WHEN** пользователь кликает по кнопке тулбара, элементу рейла или панели
|
||||
- **THEN** выделение не снимается
|
||||
|
||||
#### Scenario: Фокус остаётся в сетке
|
||||
- **WHEN** выделение снято кликом по пустому месту
|
||||
- **THEN** стрелки продолжают двигать курсор без дополнительного Tab
|
||||
|
||||
### Requirement: Возврат фокуса из просмотра
|
||||
При возврате в альбом из просмотра сетка SHALL выделить последнее просмотренное фото (одиночное),
|
||||
поставить на него курсор, вернуть фокус в сетку и прокрутить к ячейке. Если фото нет среди
|
||||
загруженных, сетка SHALL догружать страницы, пока оно не появится. Восстановление SHALL
|
||||
применяться только к тому альбому, из которого был открыт просмотр.
|
||||
|
||||
#### Scenario: Возврат из просмотра
|
||||
- **WHEN** пользователь открыл фото и закрыл просмотр
|
||||
- **THEN** в сетке это фото выделено, курсор на нём, фокус в сетке, а ячейка прокручена в видимую область
|
||||
|
||||
#### Scenario: Последнее просмотренное фото
|
||||
- **WHEN** пользователь пролистал несколько фото в просмотре и закрыл его
|
||||
- **THEN** восстанавливается последнее просмотренное фото
|
||||
|
||||
#### Scenario: Фото вне первой страницы
|
||||
- **WHEN** просмотренное фото отсутствует в первой загруженной странице
|
||||
- **THEN** сетка догружает страницы, пока фото не появится, и только затем ставит на него курсор
|
||||
|
||||
#### Scenario: Возврат в другой альбом
|
||||
- **WHEN** после просмотра пользователь открывает другой альбом
|
||||
- **THEN** восстановление не применяется, выборка пуста
|
||||
|
||||
#### Scenario: Клавиатура после возврата
|
||||
- **WHEN** фокус восстановлен после просмотра
|
||||
- **THEN** стрелки сразу двигают курсор без дополнительного Tab
|
||||
@@ -0,0 +1,35 @@
|
||||
# Tasks
|
||||
|
||||
## 1. PhotoGrid: API курсора и визуализация
|
||||
|
||||
- [x] 1.1 Добавить в `lib/components/PhotoGrid.svelte` проп `cursorIndex` и колбэки `onCursor`, `onToggle`, `onExtend`, `onOpen`, `onClear`; прокинуть их из `routes/(app)/albums/[id]/+page.svelte`. Проверка: `cd frontend && npm run check` проходит, сетка рендерится без изменений поведения мыши.
|
||||
- [x] 1.2 Реализовать roving tabindex: у ячейки под курсором `tabindex="0"`, у остальных `-1`; при пустом курсоре `0` у первой ячейки. Проверка: Tab вводит фокус в сетку один раз, стрелки не покидают сетку.
|
||||
- [x] 1.3 Добавить визуальный контур курсора (`data-cursor`, внешний `outline-2 outline-offset-2` в токене `text`) с показом только при `:focus-within`. Проверка: в светлой, тёмной и нейтральной темах контур отличим от `ring-primary` и читается, когда ячейка одновременно выделена и под курсором.
|
||||
|
||||
## 2. Клавиши сетки
|
||||
|
||||
- [x] 2.1 Реализовать геометрию перемещения по плоскому индексу: `←`/`→` — `index ± 1` (естественный переход на соседнюю строку), `↑`/`↓` — `index ± density.cols` с зажимом в `[0, n-1]`. Проверка: ручной прогон — `→` в конце строки уходит на следующую строку, `↑`/`↓` не переносят через верх/низ.
|
||||
- [x] 2.2 Повесить `keydown` на контейнер сетки (не на `window`); игнорировать события вне фокуса сетки; `preventDefault` только для обрабатываемых клавиш. Проверка: при фокусе в поле ввода панели или в диалоге стрелки и `Space` не двигают курсор.
|
||||
- [x] 2.3 Обработать клавиши-действия: `Space` → `onToggle`, `Shift`+стрелка → `onExtend`, `Enter` → `onOpen`, `Esc` → `onClear`. Проверка: `Space` переключает выделение и обновляет правую панель, `Shift`+стрелка расширяет диапазон от якоря, `Enter` открывает просмотр, `Esc` снимает выделение и оставляет курсор.
|
||||
|
||||
## 3. Снятие выделения кликом
|
||||
|
||||
- [x] 3.1 Добавить обработчик клика на контейнер сетки вместе с отступами: пропускать цель внутри `[data-photo-cell]`, иначе `selection.clear()`; контейнеру дать `tabindex="-1"` и фокусировать его после снятия. Проверка: клик по пустому месту возвращает в правой панели свойства альбома, клик по фото/кнопке тулбара/элементу панели выделение не снимает, после снятия стрелки сразу двигают курсор.
|
||||
|
||||
## 4. Интеграция страницы альбома
|
||||
|
||||
- [x] 4.1 Ввести `cursorIndex` в `+page.svelte`; синхронизировать с мышью (клик, `ctrl`/`cmd`-клик, `shift`-клик, контекстное меню ставят курсор); сбрасывать в `null` при смене альбома в том же `$effect`, где `selection.clear()`.
|
||||
- [x] 4.2 При перемещении курсора вызывать `scrollIntoView({ block: 'nearest' })` для ячейки. Проверка: движение к краю прокручивает сетку, ячейка под курсором видна.
|
||||
- [x] 4.3 При движении вперёд за последнее загруженное фото и наличии `nextCursor` вызывать `loadMore()`, затем ставить курсор на новое фото; без `nextCursor` — зажим. Проверка: `→` на последнем загруженном фото догружает следующую порцию и перемещает курсор.
|
||||
- [x] 4.4 Реализовать `onExtend`: если якорь не задан, взять текущий курсор; выставлять выделение диапазоном от якоря до курсора через `selection.set`. Проверка: `Shift`+стрелка добавляет соседние фото в выделение, правая панель показывает количество.
|
||||
|
||||
## 5. Интеграционная проверка
|
||||
|
||||
- [x] 5.1 Прогнать `cd frontend && npm run check` без ошибок типов и предупреждений.
|
||||
- [x] 5.2 Вручную пройти сценарии дельты `ui-workspace` в светлой, тёмной и нейтральной темах: движение курсора без смены панели, `Space`+панель, `Shift`-диапазон, `Enter`, `Esc`, клик по пустому месту, догрузка; убедиться, что клик/дабл-клик/контекстное меню/drag работают как прежде.
|
||||
|
||||
## 6. Возврат фокуса из просмотра
|
||||
|
||||
- [x] 6.1 Добавить модульный стор `lib/stores/viewer-return.ts` (однослотовая память `{albumId, photoId}` с `remember`/`consume`) и вызывать `remember(albumId, photoId)` в `routes/(app)/albums/[id]/photos/[photoId]/+page.svelte` через `$effect` на `photoId`. Проверка: `npm run check` проходит; при открытии просмотра и листании слот хранит текущий `photoId`.
|
||||
- [x] 6.2 В `routes/(app)/albums/[id]/+page.svelte` в `$effect` на `albumId` вызывать `viewerReturn.consume(albumId)`, а после `loading = false` и `tick()` — восстанавливать фокус: `selection.set(albumId, [photo])`, `anchorId`, `cursorIndex`, `.focus({preventScroll:true})` и `scrollIntoView({block:'center'})`; если фото нет среди загруженных — догружать `loadMore()` в цикле. Проверка: возврат из просмотра выделяет и фокусирует просмотренное фото, стрелки работают сразу; фото со второй страницы догружается.
|
||||
- [x] 6.3 Расширить QA-скрипт сценариями возврата (закрытие просмотра, листание и возврат, фото вне первой страницы, возврат в другой альбом) и прогнать его. Проверка: все проверки PASS, `npm run check` без ошибок и предупреждений.
|
||||
Reference in New Issue
Block a user