Files
open-webui/src/lib/components/common/PptxPreview.svelte
T
2026-08-30 17:45:50 -04:00

437 lines
13 KiB
Svelte

<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
import panzoom, { type PanZoom } from 'panzoom';
import { clampDocumentTargetPage } from '$lib/utils/documentPreview';
export let slides: string[] = [];
export let currentSlide = 0;
export let className = '';
export let targetPage: number | null = null;
export let itemLabel = 'Slide';
export let listLabel = 'Slides';
let rootEl: HTMLDivElement;
let stageEl: HTMLElement;
let sceneEl: HTMLDivElement;
let slideImgEl: HTMLImageElement;
let naturalWidth = 1280;
let naturalHeight = 720;
let fitScale = 1;
let zoomLevel = 1;
let resizeObserver: ResizeObserver | null = null;
let pzInstance: PanZoom | null = null;
let mounted = false;
let initializedSlide = '';
let hideThumbs = false;
let wheelDelta = 0;
let lastWheelNavigationAt = 0;
let lastScrolledSlide = -1;
let appliedTargetPage: number | null = null;
let appliedTargetSlides: string[] | null = null;
let thumbnailButtons: Array<HTMLButtonElement | undefined> = [];
const slideShortcutKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
const wheelNavigationThreshold = 80;
const wheelNavigationCooldown = 450;
$: safeSlide = Math.min(Math.max(0, currentSlide), Math.max(0, slides.length - 1));
$: selectedSlide = slides[safeSlide] ?? '';
$: slideWidth = Math.max(1, Math.round(naturalWidth * fitScale));
$: slideHeight = Math.max(1, Math.round(naturalHeight * fitScale));
const updateFitScale = () => {
if (!stageEl || !naturalWidth || !naturalHeight) return;
const availableWidth = Math.max(320, stageEl.clientWidth - 64);
const availableHeight = Math.max(220, stageEl.clientHeight - 64);
fitScale = Math.min(1, availableWidth / naturalWidth, availableHeight / naturalHeight);
};
const updateLayout = () => {
hideThumbs = (rootEl?.clientWidth ?? window.innerWidth) < 720;
void tick().then(() => {
updateFitScale();
scrollSelectedThumbnailIntoView();
});
};
const trackThumbnail = (node: HTMLButtonElement, index: number) => {
thumbnailButtons[index] = node;
return {
destroy: () => {
if (thumbnailButtons[index] === node) thumbnailButtons[index] = undefined;
}
};
};
const scrollSelectedThumbnailIntoView = () => {
if (hideThumbs) return;
thumbnailButtons[safeSlide]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
};
const initPanzoom = () => {
if (!sceneEl) return;
pzInstance?.dispose();
pzInstance = panzoom(sceneEl, {
bounds: true,
boundsPadding: 0.1,
pinchSpeed: 3.5,
filterKey: (e?: KeyboardEvent) => !!e && slideShortcutKeys.includes(e.key),
beforeWheel: () => true,
beforeMouseDown: () => {
const transform = pzInstance?.getTransform();
return !!transform && Math.abs(transform.scale - 1) < 0.01;
}
});
pzInstance.on('zoom', () => {
zoomLevel = pzInstance?.getTransform()?.scale ?? 1;
});
pzInstance.on('pan', () => {
zoomLevel = pzInstance?.getTransform()?.scale ?? 1;
});
};
const selectSlide = (index: number) => {
const nextSlide = Math.min(Math.max(0, index), Math.max(0, slides.length - 1));
if (nextSlide === safeSlide) return;
currentSlide = nextSlide;
void tick().then(() => {
updateFitScale();
resetView();
});
};
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.defaultPrevented ||
e.altKey ||
e.ctrlKey ||
e.metaKey ||
slides.length === 0 ||
!slideShortcutKeys.includes(e.key)
) {
return;
}
e.preventDefault();
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
selectSlide(safeSlide - 1);
} else {
selectSlide(safeSlide + 1);
}
};
const focusPreview = () => {
if (!rootEl?.contains(document.activeElement)) rootEl?.focus();
};
const zoomIn = () => {
if (!pzInstance || !stageEl) return;
pzInstance.zoomTo(stageEl.clientWidth / 2, stageEl.clientHeight / 2, 1.25);
zoomLevel = pzInstance.getTransform().scale;
};
const zoomOut = () => {
if (!pzInstance || !stageEl) return;
pzInstance.zoomTo(stageEl.clientWidth / 2, stageEl.clientHeight / 2, 0.8);
zoomLevel = pzInstance.getTransform().scale;
};
export const resetView = () => {
pzInstance?.moveTo(0, 0);
pzInstance?.zoomAbs(0, 0, 1);
zoomLevel = 1;
updateFitScale();
};
const onSlideLoad = () => {
if (slideImgEl?.naturalWidth && slideImgEl?.naturalHeight) {
naturalWidth = slideImgEl.naturalWidth;
naturalHeight = slideImgEl.naturalHeight;
}
updateFitScale();
resetView();
};
const handleStageWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
if (!pzInstance || !sceneEl) return;
e.preventDefault();
const rect = sceneEl.getBoundingClientRect();
pzInstance.zoomTo(e.clientX - rect.left, e.clientY - rect.top, Math.exp(-e.deltaY * 0.002));
zoomLevel = pzInstance.getTransform().scale;
return;
}
const transform = pzInstance?.getTransform();
if (transform && Math.abs(transform.scale - 1) >= 0.01) {
e.preventDefault();
pzInstance?.moveBy(-e.deltaX, -e.deltaY, false);
zoomLevel = pzInstance?.getTransform().scale ?? 1;
return;
}
e.preventDefault();
if (slides.length <= 1) return;
const multiplier = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? stageEl.clientHeight : 1;
const dominantDelta = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
wheelDelta += dominantDelta * multiplier;
const now = Date.now();
if (Math.abs(wheelDelta) < wheelNavigationThreshold) return;
if (now - lastWheelNavigationAt < wheelNavigationCooldown) {
wheelDelta = 0;
return;
}
lastWheelNavigationAt = now;
selectSlide(safeSlide + (wheelDelta > 0 ? 1 : -1));
wheelDelta = 0;
};
onMount(() => {
mounted = true;
resizeObserver = new ResizeObserver(updateLayout);
if (rootEl) resizeObserver.observe(rootEl);
if (stageEl) resizeObserver.observe(stageEl);
updateLayout();
});
$: if (mounted && selectedSlide && selectedSlide !== initializedSlide) {
initializedSlide = selectedSlide;
void tick().then(() => {
initPanzoom();
resetView();
});
}
$: if (mounted && safeSlide !== lastScrolledSlide) {
lastScrolledSlide = safeSlide;
void tick().then(scrollSelectedThumbnailIntoView);
}
$: if (
mounted &&
targetPage &&
slides.length > 0 &&
(targetPage !== appliedTargetPage || slides !== appliedTargetSlides)
) {
appliedTargetPage = targetPage;
appliedTargetSlides = slides;
const page = clampDocumentTargetPage(targetPage, slides.length);
if (page) selectSlide(page - 1);
}
onDestroy(() => {
pzInstance?.dispose();
resizeObserver?.disconnect();
});
</script>
<div
bind:this={rootEl}
role="application"
aria-label={`${itemLabel} preview`}
tabindex="0"
on:keydown={handleKeyDown}
on:pointerdown={focusPreview}
class="relative grid {hideThumbs
? 'grid-cols-[minmax(0,1fr)]'
: 'grid-cols-[144px_minmax(0,1fr)]'} min-h-0 bg-transparent text-gray-900 dark:text-gray-100 {className}"
>
<aside
class={hideThumbs
? 'hidden'
: 'thumbnail-sidebar overflow-y-auto px-2 pt-3 pb-16 border-r border-gray-50 dark:border-gray-850/30 bg-transparent'}
aria-label={listLabel}
>
{#each slides as slide, index}
<button
use:trackThumbnail={index}
type="button"
class="grid grid-cols-[20px_minmax(0,1fr)] items-start gap-2 w-full mb-3 p-0 text-left text-gray-900 dark:text-gray-100"
on:click={() => selectSlide(index)}
aria-label="{itemLabel} {index + 1}"
aria-current={safeSlide === index ? 'true' : undefined}
>
<span
class="pt-1.5 text-[0.6875rem] font-medium text-right {safeSlide === index
? 'text-gray-400 dark:text-gray-500'
: 'text-gray-300/70 dark:text-gray-700'}">{index + 1}</span
>
<span
class="block aspect-video overflow-hidden rounded-md bg-transparent {safeSlide === index
? 'opacity-100'
: 'opacity-55 hover:opacity-80'}"
>
<img
src={slide}
alt="{itemLabel} {index + 1} thumbnail"
class="block w-full h-full object-contain"
draggable="false"
/>
</span>
</button>
{/each}
</aside>
<section
bind:this={stageEl}
class="min-w-0 min-h-0 overflow-hidden flex items-center justify-center overscroll-contain"
on:wheel|nonpassive={handleStageWheel}
>
{#if selectedSlide}
<div
bind:this={sceneEl}
class="shrink-0"
style="width: {slideWidth}px; height: {slideHeight}px;"
>
<img
bind:this={slideImgEl}
src={selectedSlide}
alt="{itemLabel} {safeSlide + 1}"
class="block w-full h-full object-contain rounded"
draggable="false"
on:load={onSlideLoad}
/>
</div>
{/if}
</section>
{#if slides.length > 0}
<div
class="absolute bottom-3 {hideThumbs
? 'left-1/2'
: 'left-[calc(144px+(100%-144px)/2)]'} -translate-x-1/2 z-10 flex items-center gap-0.5 rounded-lg bg-white/90 dark:bg-gray-850/90 backdrop-blur-sm shadow-lg border border-gray-200/60 dark:border-gray-700/60 px-1 py-0.5"
>
<button
type="button"
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 disabled:opacity-30"
disabled={safeSlide === 0}
on:click={() => selectSlide(safeSlide - 1)}
aria-label={`Previous ${itemLabel.toLowerCase()}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
<span
class="shrink-0 min-w-12 text-center text-[0.6875rem] text-gray-500 dark:text-gray-400 tabular-nums"
>{safeSlide + 1} / {slides.length}</span
>
<button
type="button"
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 disabled:opacity-30"
disabled={safeSlide === slides.length - 1}
on:click={() => selectSlide(safeSlide + 1)}
aria-label={`Next ${itemLabel.toLowerCase()}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
<!-- Pinch covers in/out on coarse pointers; reset has no gesture, so it stays -->
<button
type="button"
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 pointer-coarse:hidden"
on:click={zoomOut}
aria-label="Zoom out"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M4 10a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H4.75A.75.75 0 0 1 4 10Z"
clip-rule="evenodd"
/>
</svg>
</button>
<button
type="button"
class="shrink-0 min-w-12 h-7 px-1.5 py-1 text-center text-[0.6875rem] font-normal text-gray-500 dark:text-gray-400 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition tabular-nums"
on:click={resetView}
aria-label="Reset zoom"
>
{Math.round(zoomLevel * 100)}%
</button>
<button
type="button"
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 pointer-coarse:hidden"
on:click={zoomIn}
aria-label="Zoom in"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
/>
</svg>
</button>
</div>
{/if}
</div>
<style>
.thumbnail-sidebar {
scrollbar-color: transparent transparent;
}
.thumbnail-sidebar:hover,
.thumbnail-sidebar:focus,
.thumbnail-sidebar:focus-within,
.thumbnail-sidebar:active {
scrollbar-color: rgba(215, 215, 215, 0.6) transparent;
}
:global(.dark) .thumbnail-sidebar:hover,
:global(.dark) .thumbnail-sidebar:focus,
:global(.dark) .thumbnail-sidebar:focus-within,
:global(.dark) .thumbnail-sidebar:active {
scrollbar-color: rgba(67, 67, 67, 0.6) transparent;
}
.thumbnail-sidebar::-webkit-scrollbar-thumb {
visibility: hidden;
}
.thumbnail-sidebar:hover::-webkit-scrollbar-thumb,
.thumbnail-sidebar:focus::-webkit-scrollbar-thumb,
.thumbnail-sidebar:focus-within::-webkit-scrollbar-thumb,
.thumbnail-sidebar:active::-webkit-scrollbar-thumb {
visibility: visible;
}
.thumbnail-sidebar::-webkit-scrollbar-corner {
display: none;
}
</style>