feat(ui): 360 panorama viewer by pano360 tag
- Detect 'pano360' tag and open the photo in a spherical viewer - Mini-WebGL equirect viewer (no deps): drag to look, wheel FOV, Space reset - Load original as origin-clean bitmap; fall back to flat image without WebGL - 360 indicator in viewer and badge on grid tiles
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
import { api } from '$lib/api';
|
||||
import { page } from '$app/stores';
|
||||
import Icon from './Icon.svelte';
|
||||
import PanoViewer from './PanoViewer.svelte';
|
||||
import { isPanoTag } from '$lib/pano';
|
||||
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
||||
import { transfer } from '$lib/stores/transfer.svelte';
|
||||
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
||||
@@ -35,6 +37,9 @@
|
||||
let scale = $state(1);
|
||||
let hiResLoaded = $state(false);
|
||||
|
||||
let panoFailed = $state(false);
|
||||
let panoRef: { reset: () => void } | undefined = $state();
|
||||
|
||||
let showTop = $state(false);
|
||||
let showBottom = $state(false);
|
||||
let showLeft = $state(false);
|
||||
@@ -52,6 +57,9 @@
|
||||
);
|
||||
const fullSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
|
||||
const displaySrc = $derived(zoomed && hiResLoaded && fullSrc ? fullSrc : previewSrc);
|
||||
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
|
||||
const pano = $derived(panoTag && !panoFailed);
|
||||
const panoSrc = $derived(fullSrc || previewSrc);
|
||||
|
||||
$effect(() => {
|
||||
const id = current?.id;
|
||||
@@ -59,6 +67,7 @@
|
||||
zoomed = false;
|
||||
scale = 1;
|
||||
hiResLoaded = false;
|
||||
panoFailed = false;
|
||||
if (!id) return;
|
||||
api<{ photo: Photo }>(`/photos/${id}`)
|
||||
.then((data) => (fresh = data.photo))
|
||||
@@ -265,7 +274,8 @@
|
||||
}
|
||||
if (ev.code === 'Space') {
|
||||
ev.preventDefault();
|
||||
toggleActual();
|
||||
if (pano) panoRef?.reset();
|
||||
else toggleActual();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'f' || ev.key === 'F' || ev.key === 'а' || ev.key === 'А') {
|
||||
@@ -329,13 +339,20 @@
|
||||
|
||||
<svelte:window onkeydown={onKey} onmousemove={onWinMouseMove} />
|
||||
|
||||
<div bind:this={viewerEl} class="bg-canvas relative flex h-full flex-col overflow-hidden">
|
||||
<div
|
||||
bind:this={viewerEl}
|
||||
class="bg-canvas relative flex h-full flex-col overflow-hidden"
|
||||
data-pano={panoTag ? '1' : '0'}
|
||||
>
|
||||
<div bind:this={topEl} class={topClass}>
|
||||
<div class="pointer-events-auto flex items-center gap-1">
|
||||
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
|
||||
<Icon name="arrow_back" class="size-4" />
|
||||
</button>
|
||||
<span class="text-text-muted text-xs">{index + 1} / {photos.length}</span>
|
||||
{#if pano}
|
||||
<span class="text-primary text-xs font-semibold" title="360-панорама">360</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="pointer-events-auto flex items-center gap-1">
|
||||
<button
|
||||
@@ -365,34 +382,50 @@
|
||||
<Icon name="chevron_left" class="size-8" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
bind:this={scroller}
|
||||
class="bg-canvas flex min-h-0 flex-1 overscroll-contain {zoomed
|
||||
? 'cursor-grab overflow-auto'
|
||||
: 'overflow-hidden'}"
|
||||
role="group"
|
||||
aria-label="Просмотр фото"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
oncontextmenu={onContextMenu}
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchend={onTouchEnd}
|
||||
>
|
||||
{#if current}
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
src={displaySrc}
|
||||
alt={current.original_filename}
|
||||
draggable="false"
|
||||
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
|
||||
class="m-auto block {zoomed
|
||||
? 'max-w-none shrink-0 select-none'
|
||||
: 'max-h-full max-w-full object-contain'}"
|
||||
{#if pano}
|
||||
<div
|
||||
class="bg-canvas flex min-h-0 flex-1 overflow-hidden"
|
||||
role="group"
|
||||
aria-label="360-панорама"
|
||||
oncontextmenu={onContextMenu}
|
||||
>
|
||||
<PanoViewer
|
||||
bind:this={panoRef}
|
||||
src={panoSrc}
|
||||
fallbackSrc={previewSrc}
|
||||
onFallback={() => (panoFailed = true)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
bind:this={scroller}
|
||||
class="bg-canvas flex min-h-0 flex-1 overscroll-contain {zoomed
|
||||
? 'cursor-grab overflow-auto'
|
||||
: 'overflow-hidden'}"
|
||||
role="group"
|
||||
aria-label="Просмотр фото"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
oncontextmenu={onContextMenu}
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchend={onTouchEnd}
|
||||
>
|
||||
{#if current}
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
src={displaySrc}
|
||||
alt={current.original_filename}
|
||||
draggable="false"
|
||||
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
|
||||
class="m-auto block {zoomed
|
||||
? 'max-w-none shrink-0 select-none'
|
||||
: 'max-h-full max-w-full object-contain'}"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user