feat(web): скрытие боковых панелей в альбоме и хоткеи размера миниатюр

В режиме просмотра альбома добавлена кнопка скрытия левой и правой боковых
панелей (рядом с регулятором размера миниатюр) и хоткей Tab. В скрытом режиме
панели не занимают место в раскладке, а появляются по наведению на левую или
правую кромку окна и прячутся после ухода указателя.

Клавиши + и - меняют размер миниатюр на шаг, как кнопки регулятора; хоткей
работает на всех экранах приложения и не срабатывает при вводе текста, в
открытых диалогах и меню.

Спека ui-workspace дополнена требованиями о скрытии панелей и клавишах
изменения размера миниатюр.
This commit is contained in:
iu
2026-10-20 21:30:06 +03:00
parent 3a44844ff9
commit 45a953f8bb
7 changed files with 214 additions and 28 deletions
+110 -7
View File
@@ -7,6 +7,8 @@
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import ThemeSwitcher from './ThemeSwitcher.svelte'; import ThemeSwitcher from './ThemeSwitcher.svelte';
import { auth } from '$lib/stores/auth.svelte'; import { auth } from '$lib/stores/auth.svelte';
import { density } from '$lib/stores/density.svelte';
import { panels } from '$lib/stores/panels.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface Props { interface Props {
@@ -14,9 +16,11 @@
rail?: Snippet; rail?: Snippet;
panel?: Snippet; panel?: Snippet;
panelVisible?: boolean; panelVisible?: boolean;
/** Показывать переключатель скрытия боковых панелей (режим просмотра альбома). */
panelsToggle?: boolean;
} }
let { children, rail, panel, panelVisible = true }: Props = $props(); let { children, rail, panel, panelVisible = true, panelsToggle = false }: Props = $props();
let railOpen = $state(false); let railOpen = $state(false);
let panelOpen = $state(false); let panelOpen = $state(false);
@@ -29,6 +33,11 @@
let isFullscreen = $state(false); let isFullscreen = $state(false);
$effect(() => {
// Режим скрытия действует только там, где он доступен (просмотр альбома).
if (!panelsToggle) panels.reset();
});
onMount(() => { onMount(() => {
const r = parseInt(localStorage.getItem(RAIL.key) ?? '', 10); const r = parseInt(localStorage.getItem(RAIL.key) ?? '', 10);
if (r >= RAIL.min && r <= RAIL.max) railWidth = r; if (r >= RAIL.min && r <= RAIL.max) railWidth = r;
@@ -93,6 +102,75 @@
const handle = const handle =
'bg-transparent hover:bg-primary/40 focus-visible:bg-primary/40 hidden w-1 shrink-0 cursor-col-resize p-0 transition-colors outline-none'; 'bg-transparent hover:bg-primary/40 focus-visible:bg-primary/40 hidden w-1 shrink-0 cursor-col-resize p-0 transition-colors outline-none';
const railClass = $derived(
`border-border bg-chrome absolute inset-y-0 left-0 z-40 w-60 overflow-y-auto border-r transition-transform motion-reduce:transition-none lg:w-[var(--rail-w)] ${
panels.hidden
? `${railOpen ? 'translate-x-0' : '-translate-x-full'} lg:z-30 ${panels.railPeek ? 'lg:translate-x-0' : 'lg:-translate-x-full'}`
: `lg:static lg:z-auto lg:translate-x-0 ${railOpen ? 'translate-x-0' : '-translate-x-full'}`
}`
);
const panelClass = $derived(
`border-border bg-chrome absolute inset-y-0 right-0 z-40 w-72 overflow-y-auto border-l transition-transform motion-reduce:transition-none xl:w-[var(--panel-w)] ${
panels.hidden
? `${panelOpen ? 'translate-x-0' : 'translate-x-full'} xl:z-30 ${panels.panelPeek ? 'xl:translate-x-0' : 'xl:translate-x-full'}`
: `xl:static xl:z-auto xl:translate-x-0 ${panelOpen ? 'translate-x-0' : 'translate-x-full'}`
}`
);
function isTyping(): boolean {
const el = document.activeElement as HTMLElement | null;
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
}
/** Как isTyping, но ползунок и служебные input'ы текстом не считаются. */
function isTextEntry(): boolean {
const el = document.activeElement as HTMLElement | null;
if (!el) return false;
if (el.isContentEditable) return true;
const tag = el.tagName;
if (tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (tag === 'INPUT') {
return !['range', 'checkbox', 'radio', 'button', 'submit', 'reset'].includes(
(el as HTMLInputElement).type
);
}
return false;
}
function hasOverlay(): boolean {
return !!document.querySelector('[role="dialog"][aria-modal="true"], [role="menu"]');
}
function onKeydown(ev: KeyboardEvent) {
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.repeat) return;
// `+`/`-` меняют размер миниатюр — как кнопки под регулятором.
if (ev.key === '+' || ev.key === '=' || ev.key === '-' || ev.key === '_') {
if (isTextEntry() || hasOverlay()) return;
ev.preventDefault();
density.stepSize(ev.key === '+' || ev.key === '=' ? 1 : -1);
return;
}
if (!panelsToggle || !panel || !panelVisible) return;
if (ev.key !== 'Tab' || ev.shiftKey) return;
if (isTyping() || hasOverlay()) return;
ev.preventDefault();
panels.toggle();
}
function onAsideLeave(ev: MouseEvent, side: 'left' | 'right') {
if (!panels.hidden) return;
const el = ev.currentTarget as HTMLElement | null;
// Панель с фокусом не прячем — иначе клавиатурный пользователь теряет её.
if (el?.contains(document.activeElement)) return;
if (side === 'left') panels.railPeek = false;
else panels.panelPeek = false;
}
async function logout() { async function logout() {
if (auth.refresh) { if (auth.refresh) {
await authApi.logout(auth.refresh).catch(() => {}); await authApi.logout(auth.refresh).catch(() => {});
@@ -102,6 +180,8 @@
} }
</script> </script>
<svelte:window onkeydown={onKeydown} />
<div class="bg-canvas text-text flex h-screen flex-col overflow-hidden"> <div class="bg-canvas text-text flex h-screen flex-col overflow-hidden">
<header <header
class="border-border bg-chrome flex shrink-0 items-center justify-between gap-2 border-b px-2 py-0.5 sm:px-3" class="border-border bg-chrome flex shrink-0 items-center justify-between gap-2 border-b px-2 py-0.5 sm:px-3"
@@ -183,15 +263,16 @@
></button> ></button>
{/if} {/if}
<aside <aside
class="border-border bg-chrome absolute inset-y-0 left-0 z-40 w-60 overflow-y-auto border-r transition-transform lg:static lg:z-auto lg:w-[var(--rail-w)] lg:translate-x-0 {railOpen class={railClass}
? 'translate-x-0'
: '-translate-x-full'}"
style="--rail-w: {railWidth}px" style="--rail-w: {railWidth}px"
aria-label="Альбомы" aria-label="Альбомы"
inert={panels.hidden && !panels.railPeek && !railOpen}
onmouseleave={(e) => onAsideLeave(e, 'left')}
> >
{#if rail}{@render rail()}{/if} {#if rail}{@render rail()}{/if}
</aside> </aside>
{#if !panels.hidden}
<button <button
type="button" type="button"
class="{handle} lg:block" class="{handle} lg:block"
@@ -200,6 +281,7 @@
onpointerdown={(e) => startResize('left', e)} onpointerdown={(e) => startResize('left', e)}
onkeydown={(e) => nudge('left', e)} onkeydown={(e) => nudge('left', e)}
></button> ></button>
{/if}
<main class="bg-canvas min-h-0 flex-1 overflow-y-auto"> <main class="bg-canvas min-h-0 flex-1 overflow-y-auto">
{@render children()} {@render children()}
@@ -214,6 +296,7 @@
onclick={() => (panelOpen = false)} onclick={() => (panelOpen = false)}
></button> ></button>
{/if} {/if}
{#if !panels.hidden}
<button <button
type="button" type="button"
class="{handle} xl:block" class="{handle} xl:block"
@@ -222,15 +305,35 @@
onpointerdown={(e) => startResize('right', e)} onpointerdown={(e) => startResize('right', e)}
onkeydown={(e) => nudge('right', e)} onkeydown={(e) => nudge('right', e)}
></button> ></button>
{/if}
<aside <aside
class="border-border bg-chrome absolute inset-y-0 right-0 z-40 w-72 overflow-y-auto border-l transition-transform xl:static xl:z-auto xl:w-[var(--panel-w)] xl:translate-x-0 {panelOpen class={panelClass}
? 'translate-x-0'
: 'translate-x-full'}"
style="--panel-w: {panelWidth}px" style="--panel-w: {panelWidth}px"
aria-label="Панель" aria-label="Панель"
inert={panels.hidden && !panels.panelPeek && !panelOpen}
onmouseleave={(e) => onAsideLeave(e, 'right')}
> >
{@render panel()} {@render panel()}
</aside> </aside>
{/if} {/if}
{#if panels.hidden}
<div
class="group absolute inset-y-0 left-0 z-20 hidden w-1.5 lg:block"
aria-hidden="true"
onmouseenter={() => (panels.railPeek = true)}
>
<span class="block h-full w-full transition-colors group-hover:bg-primary/40"></span>
</div>
{#if panel && panelVisible}
<div
class="group absolute inset-y-0 right-0 z-20 hidden w-1.5 xl:block"
aria-hidden="true"
onmouseenter={() => (panels.panelPeek = true)}
>
<span class="block h-full w-full transition-colors group-hover:bg-primary/40"></span>
</div>
{/if}
{/if}
</div> </div>
</div> </div>
@@ -8,7 +8,7 @@
<Icon name="grid_view" class="text-text-subtle size-4" /> <Icon name="grid_view" class="text-text-subtle size-4" />
<IconButton <IconButton
name="remove" name="remove"
label="Уменьшить миниатюры" label="Уменьшить миниатюры (-)"
disabled={density.size <= DENSITY_MIN} disabled={density.size <= DENSITY_MIN}
onclick={() => density.stepSize(-1)} onclick={() => density.stepSize(-1)}
class="size-7" class="size-7"
@@ -26,7 +26,7 @@
/> />
<IconButton <IconButton
name="add" name="add"
label="Увеличить миниатюры" label="Увеличить миниатюры (+)"
disabled={density.size >= DENSITY_MAX} disabled={density.size >= DENSITY_MAX}
onclick={() => density.stepSize(1)} onclick={() => density.stepSize(1)}
class="size-7" class="size-7"
+3 -1
View File
@@ -53,7 +53,9 @@ export const icons = {
fullscreen_exit: { path: '<path fill="currentColor" d="M5 16h3v3h2v-5H5zm3-8H5v2h5V5H8zm6 11h2v-3h3v-2h-5zm2-11v3h-2v2h5V5z"/>' }, fullscreen_exit: { path: '<path fill="currentColor" d="M5 16h3v3h2v-5H5zm3-8H5v2h5V5H8zm6 11h2v-3h3v-2h-5zm2-11v3h-2v2h5V5z"/>' },
contrast: { path: '<path fill="currentColor" fill-rule="evenodd" d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM12 4a8 8 0 0 1 0 16z"/>' }, contrast: { path: '<path fill="currentColor" fill-rule="evenodd" d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM12 4a8 8 0 0 1 0 16z"/>' },
check_all: { path: '<path fill="currentColor" d="M18 7l-1.41-1.41l-6.34 6.34l1.41 1.41zm4.24-1.41L11.66 16.17L7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41L6 19l1.41-1.41L1.83 12z"/>' }, check_all: { path: '<path fill="currentColor" d="M18 7l-1.41-1.41l-6.34 6.34l1.41 1.41zm4.24-1.41L11.66 16.17L7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41L6 19l1.41-1.41L1.83 12z"/>' },
uncheck_all: { path: '<path fill="currentColor" d="M1.79 12l5.58 5.59L5.96 19L.37 13.41zm.45-7.78L12.9 14.89l-1.28 1.28L7.44 12l-1.41 1.41L11.62 19l2.69-2.69l4.89 4.89l1.41-1.41L3.65 2.81zm14.9 9.27L23.62 7L22.2 5.59l-6.48 6.48zm-1.18-6.49l-1.41-1.41l-3.65 3.66l1.41 1.41z"/>' } uncheck_all: { path: '<path fill="currentColor" d="M1.79 12l5.58 5.59L5.96 19L.37 13.41zm.45-7.78L12.9 14.89l-1.28 1.28L7.44 12l-1.41 1.41L11.62 19l2.69-2.69l4.89 4.89l1.41-1.41L3.65 2.81zm14.9 9.27L23.62 7L22.2 5.59l-6.48 6.48zm-1.18-6.49l-1.41-1.41l-3.65 3.66l1.41 1.41z"/>' },
left_panel_close: { path: '<path fill="currentColor" d="M16.5 16V8l-4 4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' },
left_panel_open: { path: '<path fill="currentColor" d="M12.5 8v8l4-4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' }
} satisfies Record<string, IconDef>; } satisfies Record<string, IconDef>;
export type IconName = keyof typeof icons; export type IconName = keyof typeof icons;
+27
View File
@@ -0,0 +1,27 @@
class PanelsStore {
/** Боковые панели убраны за край, содержимое занимает всю ширину. */
hidden = $state(false);
/** Левая панель показана у кромки в скрытом режиме. */
railPeek = $state(false);
/** Правая панель показана у кромки в скрытом режиме. */
panelPeek = $state(false);
toggle() {
this.hidden = !this.hidden;
this.railPeek = false;
this.panelPeek = false;
if (this.hidden && typeof document !== 'undefined') {
// Не оставляем фокус внутри панели, которая уезжает за край.
const el = document.activeElement as HTMLElement | null;
if (el?.closest('aside')) el.blur();
}
}
reset() {
this.hidden = false;
this.railPeek = false;
this.panelPeek = false;
}
}
export const panels = new PanelsStore();
+2 -1
View File
@@ -11,6 +11,7 @@
let { children } = $props(); let { children } = $props();
const showPanel = $derived($page.url.pathname !== '/'); const showPanel = $derived($page.url.pathname !== '/');
const albumView = $derived($page.url.pathname.startsWith('/albums/'));
// Personal stream: one subscription for the whole session, kept alive across // Personal stream: one subscription for the whole session, kept alive across
// navigation inside the (app) group. The stream updates the album list // navigation inside the (app) group. The stream updates the album list
@@ -23,7 +24,7 @@
}); });
</script> </script>
<AppShell panelVisible={showPanel}> <AppShell panelVisible={showPanel} panelsToggle={albumView}>
{#snippet rail()} {#snippet rail()}
<AlbumRail /> <AlbumRail />
{/snippet} {/snippet}
@@ -18,6 +18,7 @@
import { viewerReturn } from '$lib/stores/viewer-return'; import { viewerReturn } from '$lib/stores/viewer-return';
import { transfer } from '$lib/stores/transfer.svelte'; import { transfer } from '$lib/stores/transfer.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte'; import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import { panels } from '$lib/stores/panels.svelte';
import { SSEClient, type SSEEvent } from '$lib/sse'; import { SSEClient, type SSEEvent } from '$lib/sse';
import type { Album, Photo } from '$lib/types'; import type { Album, Photo } from '$lib/types';
@@ -496,6 +497,18 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<DensityControl /> <DensityControl />
<span class="bg-border hidden h-5 w-px shrink-0 lg:block" aria-hidden="true"></span>
<button
type="button"
class="text-text-muted hover:bg-bg-muted hover:text-text hidden items-center justify-center rounded-md p-1.5 lg:inline-flex"
aria-label={panels.hidden ? 'Показать боковые панели (Tab)' : 'Скрыть боковые панели (Tab)'}
title={panels.hidden ? 'Показать боковые панели (Tab)' : 'Скрыть боковые панели (Tab)'}
aria-keyshortcuts="Tab"
aria-pressed={panels.hidden}
onclick={() => panels.toggle()}
>
<Icon name={panels.hidden ? 'left_panel_open' : 'left_panel_close'} />
</button>
{#if canUpload} {#if canUpload}
<span class="bg-border h-5 w-px shrink-0" aria-hidden="true"></span> <span class="bg-border h-5 w-px shrink-0" aria-hidden="true"></span>
<button <button
+41 -1
View File
@@ -81,7 +81,9 @@ SHALL отображаться вне каркаса, но в общей тем
Сетка SHALL давать пользователю управлять размером миниатюр: сдвиг регулятора вправо SHALL Сетка SHALL давать пользователю управлять размером миниатюр: сдвиг регулятора вправо SHALL
увеличивать миниатюры, влево — уменьшать. Регулятор SHALL сопровождаться кнопками шага в увеличивать миниатюры, влево — уменьшать. Регулятор SHALL сопровождаться кнопками шага в
сторону уменьшения и увеличения. Выбранный размер SHALL сохраняться между сессиями и SHALL сторону уменьшения и увеличения. Выбранный размер SHALL сохраняться между сессиями и SHALL
быть общим для обзора альбомов и сетки фото альбома. быть общим для обзора альбомов и сетки фото альбома. Клавиши `+` и `-` SHALL менять размер
миниатюр на шаг так же, как кнопки регулятора; при вводе текста и в открытых диалогах либо
меню эти клавиши SHALL сохранять обычное действие.
#### Scenario: Изменение размера #### Scenario: Изменение размера
@@ -98,6 +100,16 @@ SHALL отображаться вне каркаса, но в общей тем
- **WHEN** пользователь нажимает кнопку увеличения или уменьшения рядом с регулятором - **WHEN** пользователь нажимает кнопку увеличения или уменьшения рядом с регулятором
- **THEN** размер миниатюр меняется на один шаг и сохраняется - **THEN** размер миниатюр меняется на один шаг и сохраняется
#### Scenario: Клавиши размера
- **WHEN** пользователь нажимает `+` или `-`
- **THEN** размер миниатюр меняется на один шаг, как при нажатии кнопок регулятора
#### Scenario: Клавиши в поле ввода
- **WHEN** фокус находится в поле ввода или открыт диалог либо меню
- **THEN** `+` и `-` не меняют размер миниатюр
#### Scenario: Единый размер в обоих местах #### Scenario: Единый размер в обоих местах
- **WHEN** пользователь меняет размер на обзоре альбомов или в сетке фото альбома - **WHEN** пользователь меняет размер на обзоре альбомов или в сетке фото альбома
@@ -172,6 +184,34 @@ SHALL выдвигать правую панель поверх содержим
- **WHEN** пользователь перезагружает приложение - **WHEN** пользователь перезагружает приложение
- **THEN** восстановленные панели имеют сохранённую ширину - **THEN** восстановленные панели имеют сохранённую ширину
### Requirement: Скрытие боковых панелей
В режиме просмотра альбома SHALL быть доступна кнопка скрытия левой и правой боковых панелей
в верхней панели сетки рядом с регулятором размера миниатюр и хоткей `Tab`. В скрытом режиме
панели SHALL не занимать место в раскладке, содержимое альбома SHALL занимать всю ширину,
а панели SHALL показываться при наведении указателя на левую или правую кромку окна и
скрываться после ухода указателя. Хоткей SHALL не срабатывать при вводе текста в поля и при
открытых модальных диалогах или меню.
#### Scenario: Переключение кнопкой
- **WHEN** пользователь нажимает кнопку скрытия боковых панелей в альбоме
- **THEN** обе панели уезжают за края, а сетка фото занимает всю ширину окна
#### Scenario: Переключение хоткеем
- **WHEN** пользователь нажимает `Tab` в режиме просмотра альбома
- **THEN** боковые панели скрываются или показываются
#### Scenario: Появление у кромки
- **WHEN** панели скрыты, и указатель подходит к левой или правой кромке окна
- **THEN** соответствующая панель выезжает поверх содержимого
#### Scenario: Скрытие после ухода указателя
- **WHEN** указатель покидает показанную у кромки панель
- **THEN** панель снова уезжает за край
#### Scenario: Поля ввода и диалоги
- **WHEN** фокус находится в поле ввода или открыт модальный диалог либо меню
- **THEN** `Tab` выполняет обычную навигацию и не переключает панели
### Requirement: Сворачиваемые разделы панели ### Requirement: Сворачиваемые разделы панели
Контекстная панель SHALL группировать содержимое в сворачиваемые разделы, а состояние Контекстная панель SHALL группировать содержимое в сворачиваемые разделы, а состояние
сворачивания SHALL сохраняться; раздел «Информация» SHALL показывать метаданные фото, сворачивания SHALL сохраняться; раздел «Информация» SHALL показывать метаданные фото,