feat(frontend): viewer fullscreen chrome and pointer-accurate zoom
- fullscreen: auto-hide top toolbar and filmstrip, revealed at top/bottom screen edge and hidden 1s after the cursor leaves - arrows < >: edge-reveal in both normal and fullscreen; hide 300ms after leaving; hidden while zoomed - zoom: wheel zooms only (no scroll, overscroll contained), anchored to the pointer, capped at 200%; Space toggles 100%/fit; pan via drag/scroll
This commit is contained in:
@@ -31,17 +31,29 @@
|
||||
|
||||
const current = $derived(photos[index]);
|
||||
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 8;
|
||||
const MAX_ZOOM = 2;
|
||||
|
||||
let viewerEl: HTMLDivElement | undefined = $state();
|
||||
let scroller: HTMLDivElement | undefined = $state();
|
||||
let imgEl: HTMLImageElement | undefined = $state();
|
||||
let topEl: HTMLDivElement | undefined = $state();
|
||||
let bottomEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
let isFullscreen = $state(false);
|
||||
let zoomed = $state(false);
|
||||
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 startX = 0;
|
||||
let startY = 0;
|
||||
@@ -68,13 +80,135 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const onFs = () => (isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl);
|
||||
const onFs = () => {
|
||||
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
|
||||
resetBars();
|
||||
};
|
||||
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 {
|
||||
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() {
|
||||
@@ -85,42 +219,52 @@
|
||||
}
|
||||
|
||||
function toggleActual() {
|
||||
if (zoomed && scale === 1) {
|
||||
const atHundred = zoomed && Math.abs(scale - 1) < 0.01;
|
||||
if (atHundred) {
|
||||
zoomed = false;
|
||||
scale = 1;
|
||||
if (scroller) {
|
||||
scroller.scrollLeft = 0;
|
||||
scroller.scrollTop = 0;
|
||||
}
|
||||
} else {
|
||||
zoomed = true;
|
||||
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) {
|
||||
if (!current || !scroller) return;
|
||||
if (!current || !scroller || !imgEl) return;
|
||||
ev.preventDefault();
|
||||
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||||
const newScale = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, scale * factor));
|
||||
if (newScale === scale) return;
|
||||
ev.stopPropagation();
|
||||
|
||||
const rect = scroller.getBoundingClientRect();
|
||||
const ox = ev.clientX - rect.left;
|
||||
const oy = ev.clientY - rect.top;
|
||||
const wasFit = !zoomed;
|
||||
const oldScale = scale;
|
||||
const natW = baseWidth();
|
||||
const natH = baseHeight();
|
||||
if (!natW || !natH) return;
|
||||
|
||||
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;
|
||||
scale = newScale;
|
||||
|
||||
tick().then(() => {
|
||||
if (!scroller) return;
|
||||
if (wasFit) {
|
||||
scroller.scrollLeft = scroller.scrollWidth / 2 - ox;
|
||||
scroller.scrollTop = scroller.scrollHeight / 2 - oy;
|
||||
} else {
|
||||
const nx = (scroller.scrollLeft + ox) / oldScale;
|
||||
const ny = (scroller.scrollTop + oy) / oldScale;
|
||||
scroller.scrollLeft = nx * newScale - ox;
|
||||
scroller.scrollTop = ny * newScale - oy;
|
||||
}
|
||||
const sr = scroller.getBoundingClientRect();
|
||||
scroller.scrollLeft = fx * natW * newScale - (ev.clientX - sr.left);
|
||||
scroller.scrollTop = fy * natH * newScale - (ev.clientY - sr.top);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
<svelte:window onkeydown={onKey} onmousemove={onWinMouseMove} />
|
||||
|
||||
<div bind:this={viewerEl} class="bg-canvas flex h-full flex-col">
|
||||
<div class="border-border bg-chrome flex shrink-0 items-center justify-between gap-2 border-b px-3 py-2">
|
||||
<div bind:this={viewerEl} class="bg-canvas relative flex h-full flex-col overflow-hidden">
|
||||
<div bind:this={topEl} class={topClass}>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
|
||||
<Icon name="arrow_back" />
|
||||
@@ -222,7 +366,9 @@
|
||||
type="button"
|
||||
class={btn}
|
||||
aria-label={isFullscreen ? 'Выйти из полноэкранного просмотра' : 'Полноэкранный просмотр'}
|
||||
title={isFullscreen ? 'Выйти из полноэкранного просмотра (f)' : 'Полноэкранный просмотр (f)'}
|
||||
title={isFullscreen
|
||||
? 'Выйти из полноэкранного просмотра (f)'
|
||||
: 'Полноэкранный просмотр (f)'}
|
||||
aria-pressed={isFullscreen}
|
||||
onclick={toggleViewerFullscreen}
|
||||
>
|
||||
@@ -259,21 +405,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 items-stretch">
|
||||
<div class="relative flex min-h-0 flex-1 items-stretch">
|
||||
<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="Предыдущее фото"
|
||||
title="Предыдущее фото"
|
||||
onclick={prev}
|
||||
disabled={index === 0 || zoomed}
|
||||
disabled={leftDisabled}
|
||||
>
|
||||
<Icon name="chevron_left" class="size-8" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
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"
|
||||
aria-label="Просмотр фото"
|
||||
onpointerdown={onPointerDown}
|
||||
@@ -294,7 +442,7 @@
|
||||
draggable="false"
|
||||
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
|
||||
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'}"
|
||||
/>
|
||||
{/if}
|
||||
@@ -302,20 +450,17 @@
|
||||
|
||||
<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="Следующее фото"
|
||||
title="Следующее фото"
|
||||
onclick={next}
|
||||
disabled={index === photos.length - 1 || zoomed}
|
||||
disabled={rightDisabled}
|
||||
>
|
||||
<Icon name="chevron_right" class="size-8" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border-border flex shrink-0 gap-2 overflow-x-auto border-t p-2"
|
||||
aria-label="Лента миниатюр"
|
||||
>
|
||||
<div bind:this={bottomEl} class={bottomClass} aria-label="Лента миниатюр">
|
||||
{#each photos as photo, i (photo.id)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user