- 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
161 lines
13 KiB
Markdown
161 lines
13 KiB
Markdown
# 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` и ручной прогон в трёх темах.
|