feat(web): скрытие боковых панелей в альбоме и хоткеи размера миниатюр
В режиме просмотра альбома добавлена кнопка скрытия левой и правой боковых панелей (рядом с регулятором размера миниатюр) и хоткей Tab. В скрытом режиме панели не занимают место в раскладке, а появляются по наведению на левую или правую кромку окна и прячутся после ухода указателя. Клавиши + и - меняют размер миниатюр на шаг, как кнопки регулятора; хоткей работает на всех экранах приложения и не срабатывает при вводе текста, в открытых диалогах и меню. Спека ui-workspace дополнена требованиями о скрытии панелей и клавишах изменения размера миниатюр.
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
import Icon from './Icon.svelte';
|
||||
import ThemeSwitcher from './ThemeSwitcher.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';
|
||||
|
||||
interface Props {
|
||||
@@ -14,9 +16,11 @@
|
||||
rail?: Snippet;
|
||||
panel?: Snippet;
|
||||
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 panelOpen = $state(false);
|
||||
@@ -29,6 +33,11 @@
|
||||
|
||||
let isFullscreen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// Режим скрытия действует только там, где он доступен (просмотр альбома).
|
||||
if (!panelsToggle) panels.reset();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const r = parseInt(localStorage.getItem(RAIL.key) ?? '', 10);
|
||||
if (r >= RAIL.min && r <= RAIL.max) railWidth = r;
|
||||
@@ -93,6 +102,75 @@
|
||||
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';
|
||||
|
||||
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() {
|
||||
if (auth.refresh) {
|
||||
await authApi.logout(auth.refresh).catch(() => {});
|
||||
@@ -102,6 +180,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="bg-canvas text-text flex h-screen flex-col overflow-hidden">
|
||||
<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"
|
||||
@@ -183,23 +263,25 @@
|
||||
></button>
|
||||
{/if}
|
||||
<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
|
||||
? 'translate-x-0'
|
||||
: '-translate-x-full'}"
|
||||
class={railClass}
|
||||
style="--rail-w: {railWidth}px"
|
||||
aria-label="Альбомы"
|
||||
inert={panels.hidden && !panels.railPeek && !railOpen}
|
||||
onmouseleave={(e) => onAsideLeave(e, 'left')}
|
||||
>
|
||||
{#if rail}{@render rail()}{/if}
|
||||
</aside>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="{handle} lg:block"
|
||||
aria-label="Изменить ширину панели альбомов"
|
||||
title="Потяните, чтобы изменить ширину"
|
||||
onpointerdown={(e) => startResize('left', e)}
|
||||
onkeydown={(e) => nudge('left', e)}
|
||||
></button>
|
||||
{#if !panels.hidden}
|
||||
<button
|
||||
type="button"
|
||||
class="{handle} lg:block"
|
||||
aria-label="Изменить ширину панели альбомов"
|
||||
title="Потяните, чтобы изменить ширину"
|
||||
onpointerdown={(e) => startResize('left', e)}
|
||||
onkeydown={(e) => nudge('left', e)}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<main class="bg-canvas min-h-0 flex-1 overflow-y-auto">
|
||||
{@render children()}
|
||||
@@ -214,23 +296,44 @@
|
||||
onclick={() => (panelOpen = false)}
|
||||
></button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="{handle} xl:block"
|
||||
aria-label="Изменить ширину боковой панели"
|
||||
title="Потяните, чтобы изменить ширину"
|
||||
onpointerdown={(e) => startResize('right', e)}
|
||||
onkeydown={(e) => nudge('right', e)}
|
||||
></button>
|
||||
{#if !panels.hidden}
|
||||
<button
|
||||
type="button"
|
||||
class="{handle} xl:block"
|
||||
aria-label="Изменить ширину боковой панели"
|
||||
title="Потяните, чтобы изменить ширину"
|
||||
onpointerdown={(e) => startResize('right', e)}
|
||||
onkeydown={(e) => nudge('right', e)}
|
||||
></button>
|
||||
{/if}
|
||||
<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
|
||||
? 'translate-x-0'
|
||||
: 'translate-x-full'}"
|
||||
class={panelClass}
|
||||
style="--panel-w: {panelWidth}px"
|
||||
aria-label="Панель"
|
||||
inert={panels.hidden && !panels.panelPeek && !panelOpen}
|
||||
onmouseleave={(e) => onAsideLeave(e, 'right')}
|
||||
>
|
||||
{@render panel()}
|
||||
</aside>
|
||||
{/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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<Icon name="grid_view" class="text-text-subtle size-4" />
|
||||
<IconButton
|
||||
name="remove"
|
||||
label="Уменьшить миниатюры"
|
||||
label="Уменьшить миниатюры (-)"
|
||||
disabled={density.size <= DENSITY_MIN}
|
||||
onclick={() => density.stepSize(-1)}
|
||||
class="size-7"
|
||||
@@ -26,7 +26,7 @@
|
||||
/>
|
||||
<IconButton
|
||||
name="add"
|
||||
label="Увеличить миниатюры"
|
||||
label="Увеличить миниатюры (+)"
|
||||
disabled={density.size >= DENSITY_MAX}
|
||||
onclick={() => density.stepSize(1)}
|
||||
class="size-7"
|
||||
|
||||
@@ -53,7 +53,9 @@ export const icons = {
|
||||
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"/>' },
|
||||
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>;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
@@ -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();
|
||||
@@ -11,6 +11,7 @@
|
||||
let { children } = $props();
|
||||
|
||||
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
|
||||
// navigation inside the (app) group. The stream updates the album list
|
||||
@@ -23,7 +24,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<AppShell panelVisible={showPanel}>
|
||||
<AppShell panelVisible={showPanel} panelsToggle={albumView}>
|
||||
{#snippet rail()}
|
||||
<AlbumRail />
|
||||
{/snippet}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
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 { panels } from '$lib/stores/panels.svelte';
|
||||
import { SSEClient, type SSEEvent } from '$lib/sse';
|
||||
import type { Album, Photo } from '$lib/types';
|
||||
|
||||
@@ -496,6 +497,18 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<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}
|
||||
<span class="bg-border h-5 w-px shrink-0" aria-hidden="true"></span>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user