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 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user