feat: Lightroom-style workspace UI #2

Merged
iu merged 18 commits from feature/lightroom-workspace-ui into develop 2026-10-10 19:56:23 +00:00
Showing only changes of commit d1e3aa85b4 - Show all commits
+184 -39
View File
@@ -31,17 +31,29 @@
const current = $derived(photos[index]); const current = $derived(photos[index]);
const MIN_ZOOM = 0.1; const MAX_ZOOM = 2;
const MAX_ZOOM = 8;
let viewerEl: HTMLDivElement | undefined = $state(); let viewerEl: HTMLDivElement | undefined = $state();
let scroller: HTMLDivElement | undefined = $state(); let scroller: HTMLDivElement | undefined = $state();
let imgEl: HTMLImageElement | undefined = $state(); let imgEl: HTMLImageElement | undefined = $state();
let topEl: HTMLDivElement | undefined = $state();
let bottomEl: HTMLDivElement | undefined = $state();
let isFullscreen = $state(false); let isFullscreen = $state(false);
let zoomed = $state(false); let zoomed = $state(false);
let scale = $state(1); let scale = $state(1);
let showTop = $state(false);
let showBottom = $state(false);
let showLeft = $state(false);
let showRight = $state(false);
const EDGE = 8;
let hideTopTimer: ReturnType<typeof setTimeout> | undefined;
let hideBottomTimer: ReturnType<typeof setTimeout> | undefined;
let hideLeftTimer: ReturnType<typeof setTimeout> | undefined;
let hideRightTimer: ReturnType<typeof setTimeout> | undefined;
let dragging = false; let dragging = false;
let startX = 0; let startX = 0;
let startY = 0; let startY = 0;
@@ -68,13 +80,135 @@
}); });
$effect(() => { $effect(() => {
const onFs = () => (isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl); const onFs = () => {
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
resetBars();
};
document.addEventListener('fullscreenchange', onFs); document.addEventListener('fullscreenchange', onFs);
return () => document.removeEventListener('fullscreenchange', onFs); return () => {
document.removeEventListener('fullscreenchange', onFs);
clearTimeout(hideTopTimer);
clearTimeout(hideBottomTimer);
clearTimeout(hideLeftTimer);
clearTimeout(hideRightTimer);
};
}); });
const topClass = $derived(
isFullscreen
? `border-border bg-chrome absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-2 border-b px-3 py-2 transition-transform duration-200 ${showTop ? 'translate-y-0' : '-translate-y-full'}`
: 'border-border bg-chrome flex shrink-0 items-center justify-between gap-2 border-b px-3 py-2'
);
const bottomClass = $derived(
isFullscreen
? `border-border bg-chrome absolute inset-x-0 bottom-0 z-30 flex gap-2 overflow-x-auto border-t p-2 transition-transform duration-200 ${showBottom ? 'translate-y-0' : 'translate-y-full'}`
: 'border-border bg-chrome flex shrink-0 gap-2 overflow-x-auto border-t p-2'
);
const arrowPos =
'absolute top-1/2 z-20 inline-flex -translate-y-1/2 items-center justify-center rounded-full bg-black/40 p-1.5 text-text backdrop-blur transition-opacity duration-200 hover:text-white';
const arrowVisibility = (shown: boolean, disabled: boolean, isZoomed: boolean) =>
isZoomed || !shown ? 'pointer-events-none opacity-0' : disabled ? 'opacity-40' : 'opacity-100';
const leftDisabled = $derived(index === 0 || zoomed);
const rightDisabled = $derived(index === photos.length - 1 || zoomed);
const leftBtnClass = $derived(
`${arrowPos} left-2 ${arrowVisibility(showLeft, leftDisabled, zoomed)}`
);
const rightBtnClass = $derived(
`${arrowPos} right-2 ${arrowVisibility(showRight, rightDisabled, zoomed)}`
);
function resetBars() {
clearTimeout(hideTopTimer);
clearTimeout(hideBottomTimer);
clearTimeout(hideLeftTimer);
clearTimeout(hideRightTimer);
hideTopTimer = undefined;
hideBottomTimer = undefined;
hideLeftTimer = undefined;
hideRightTimer = undefined;
showTop = false;
showBottom = false;
showLeft = false;
showRight = false;
}
function inRect(el: HTMLElement | undefined, x: number, y: number): boolean {
if (!el) return false;
const r = el.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
}
function scheduleTop(shouldShow: boolean) {
if (shouldShow) {
clearTimeout(hideTopTimer);
hideTopTimer = undefined;
showTop = true;
} else if (showTop && !hideTopTimer) {
hideTopTimer = setTimeout(() => {
showTop = false;
hideTopTimer = undefined;
}, 1000);
}
}
function scheduleBottom(shouldShow: boolean) {
if (shouldShow) {
clearTimeout(hideBottomTimer);
hideBottomTimer = undefined;
showBottom = true;
} else if (showBottom && !hideBottomTimer) {
hideBottomTimer = setTimeout(() => {
showBottom = false;
hideBottomTimer = undefined;
}, 1000);
}
}
function scheduleLeft(shouldShow: boolean) {
if (shouldShow) {
clearTimeout(hideLeftTimer);
hideLeftTimer = undefined;
showLeft = true;
} else if (showLeft && !hideLeftTimer) {
hideLeftTimer = setTimeout(() => {
showLeft = false;
hideLeftTimer = undefined;
}, 300);
}
}
function scheduleRight(shouldShow: boolean) {
if (shouldShow) {
clearTimeout(hideRightTimer);
hideRightTimer = undefined;
showRight = true;
} else if (showRight && !hideRightTimer) {
hideRightTimer = setTimeout(() => {
showRight = false;
hideRightTimer = undefined;
}, 300);
}
}
function onWinMouseMove(e: MouseEvent) {
const r = viewerEl?.getBoundingClientRect();
if (r) {
const zoneX = r.width / 6;
scheduleLeft(!zoomed && e.clientX <= r.left + zoneX);
scheduleRight(!zoomed && e.clientX >= r.right - zoneX);
}
if (!isFullscreen) return;
const nearTop = e.clientY <= EDGE;
const nearBottom = e.clientY >= window.innerHeight - EDGE;
scheduleTop(nearTop || inRect(topEl, e.clientX, e.clientY));
scheduleBottom(nearBottom || inRect(bottomEl, e.clientX, e.clientY));
}
function baseWidth(): number { function baseWidth(): number {
return fresh?.width || current?.width || imgEl?.naturalWidth || 0; return imgEl?.naturalWidth || fresh?.width || current?.width || 0;
}
function baseHeight(): number {
return imgEl?.naturalHeight || fresh?.height || current?.height || 0;
} }
function prev() { function prev() {
@@ -85,42 +219,52 @@
} }
function toggleActual() { function toggleActual() {
if (zoomed && scale === 1) { const atHundred = zoomed && Math.abs(scale - 1) < 0.01;
if (atHundred) {
zoomed = false; zoomed = false;
scale = 1; scale = 1;
if (scroller) {
scroller.scrollLeft = 0;
scroller.scrollTop = 0;
}
} else { } else {
zoomed = true; zoomed = true;
scale = 1; scale = 1;
tick().then(() => {
if (!scroller) return;
scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2;
scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) / 2;
});
} }
} }
function onWheel(ev: WheelEvent) { function onWheel(ev: WheelEvent) {
if (!current || !scroller) return; if (!current || !scroller || !imgEl) return;
ev.preventDefault(); ev.preventDefault();
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15; ev.stopPropagation();
const newScale = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, scale * factor));
if (newScale === scale) return;
const rect = scroller.getBoundingClientRect(); const natW = baseWidth();
const ox = ev.clientX - rect.left; const natH = baseHeight();
const oy = ev.clientY - rect.top; if (!natW || !natH) return;
const wasFit = !zoomed;
const oldScale = scale; const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
const fitScale = Math.min(scroller.clientWidth / natW, scroller.clientHeight / natH, 1);
const curScale = zoomed ? scale : fitScale;
const newScale = Math.min(MAX_ZOOM, Math.max(fitScale, curScale * factor));
if (Math.abs(newScale - curScale) < 1e-4) return;
const r = imgEl.getBoundingClientRect();
const fx = (ev.clientX - r.left) / r.width;
const fy = (ev.clientY - r.top) / r.height;
zoomed = true; zoomed = true;
scale = newScale; scale = newScale;
tick().then(() => { tick().then(() => {
if (!scroller) return; if (!scroller) return;
if (wasFit) { const sr = scroller.getBoundingClientRect();
scroller.scrollLeft = scroller.scrollWidth / 2 - ox; scroller.scrollLeft = fx * natW * newScale - (ev.clientX - sr.left);
scroller.scrollTop = scroller.scrollHeight / 2 - oy; scroller.scrollTop = fy * natH * newScale - (ev.clientY - sr.top);
} else {
const nx = (scroller.scrollLeft + ox) / oldScale;
const ny = (scroller.scrollTop + oy) / oldScale;
scroller.scrollLeft = nx * newScale - ox;
scroller.scrollTop = ny * newScale - oy;
}
}); });
} }
@@ -207,10 +351,10 @@
'border-border text-danger hover:bg-bg-muted inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm'; 'border-border text-danger hover:bg-bg-muted inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm';
</script> </script>
<svelte:window onkeydown={onKey} /> <svelte:window onkeydown={onKey} onmousemove={onWinMouseMove} />
<div bind:this={viewerEl} class="bg-canvas flex h-full flex-col"> <div bind:this={viewerEl} class="bg-canvas relative flex h-full flex-col overflow-hidden">
<div class="border-border bg-chrome flex shrink-0 items-center justify-between gap-2 border-b px-3 py-2"> <div bind:this={topEl} class={topClass}>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}> <button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
<Icon name="arrow_back" /> <Icon name="arrow_back" />
@@ -222,7 +366,9 @@
type="button" type="button"
class={btn} class={btn}
aria-label={isFullscreen ? 'Выйти из полноэкранного просмотра' : 'Полноэкранный просмотр'} aria-label={isFullscreen ? 'Выйти из полноэкранного просмотра' : 'Полноэкранный просмотр'}
title={isFullscreen ? 'Выйти из полноэкранного просмотра (f)' : 'Полноэкранный просмотр (f)'} title={isFullscreen
? 'Выйти из полноэкранного просмотра (f)'
: 'Полноэкранный просмотр (f)'}
aria-pressed={isFullscreen} aria-pressed={isFullscreen}
onclick={toggleViewerFullscreen} onclick={toggleViewerFullscreen}
> >
@@ -259,21 +405,23 @@
</div> </div>
</div> </div>
<div class="flex min-h-0 flex-1 items-stretch"> <div class="relative flex min-h-0 flex-1 items-stretch">
<button <button
type="button" type="button"
class="text-text-muted hover:text-text inline-flex shrink-0 items-center self-center px-1 disabled:opacity-30" class={leftBtnClass}
aria-label="Предыдущее фото" aria-label="Предыдущее фото"
title="Предыдущее фото" title="Предыдущее фото"
onclick={prev} onclick={prev}
disabled={index === 0 || zoomed} disabled={leftDisabled}
> >
<Icon name="chevron_left" class="size-8" /> <Icon name="chevron_left" class="size-8" />
</button> </button>
<div <div
bind:this={scroller} bind:this={scroller}
class="bg-canvas flex min-h-0 flex-1 {zoomed ? 'cursor-grab overflow-auto' : 'overflow-hidden'}" class="bg-canvas flex min-h-0 flex-1 overscroll-contain {zoomed
? 'cursor-grab overflow-auto'
: 'overflow-hidden'}"
role="group" role="group"
aria-label="Просмотр фото" aria-label="Просмотр фото"
onpointerdown={onPointerDown} onpointerdown={onPointerDown}
@@ -294,7 +442,7 @@
draggable="false" draggable="false"
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''} style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
class="m-auto block {zoomed class="m-auto block {zoomed
? 'max-w-none select-none' ? 'max-w-none shrink-0 select-none'
: 'max-h-full max-w-full object-contain'}" : 'max-h-full max-w-full object-contain'}"
/> />
{/if} {/if}
@@ -302,20 +450,17 @@
<button <button
type="button" type="button"
class="text-text-muted hover:text-text inline-flex shrink-0 items-center self-center px-1 disabled:opacity-30" class={rightBtnClass}
aria-label="Следующее фото" aria-label="Следующее фото"
title="Следующее фото" title="Следующее фото"
onclick={next} onclick={next}
disabled={index === photos.length - 1 || zoomed} disabled={rightDisabled}
> >
<Icon name="chevron_right" class="size-8" /> <Icon name="chevron_right" class="size-8" />
</button> </button>
</div> </div>
<div <div bind:this={bottomEl} class={bottomClass} aria-label="Лента миниатюр">
class="border-border flex shrink-0 gap-2 overflow-x-auto border-t p-2"
aria-label="Лента миниатюр"
>
{#each photos as photo, i (photo.id)} {#each photos as photo, i (photo.id)}
<button <button
type="button" type="button"