refactor(SVGPanZoom): Fix memory leaking - consolidate zoom lifecycle into PanzoomContainer (#23236)

* refactor(SVGPanZoom): attach panzoom via use: action and remove unused parent bind:this

* refactor: centralize panzoom in createPanzoomAction and align ImagePreview cleanup

* refactor(panzoom): consolidate zoom lifecycle into PanzoomContainer and remove action-based wiring
This commit is contained in:
Shirasawa
2026-03-30 20:54:28 -05:00
committed by GitHub
parent 1c5e84ddf2
commit eba2b2cd72
6 changed files with 72 additions and 109 deletions
-1
View File
@@ -293,7 +293,6 @@
// ── File preview management ──────────────────────────────────────────
const clearFilePreview = () => {
fileContent = null;
filePreviewRef?.disposePanzoom();
if (fileImageUrl) {
URL.revokeObjectURL(fileImageUrl);
fileImageUrl = null;
@@ -1,6 +1,5 @@
<script lang="ts">
import { getContext, onDestroy, tick } from 'svelte';
import panzoom, { type PanZoom } from 'panzoom';
import { getContext, tick } from 'svelte';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { settings } from '$lib/stores';
@@ -8,6 +7,7 @@
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
import Spinner from '../../common/Spinner.svelte';
import PDFViewer from '../../common/PDFViewer.svelte';
import PanzoomContainer from '../../common/PanzoomContainer.svelte';
import JsonTreeView from './JsonTreeView.svelte';
import NotebookView from './NotebookView.svelte';
import SqliteView from './SqliteView.svelte';
@@ -250,38 +250,14 @@
showRaw = true;
}
let pzInstance: PanZoom | null = null;
const initImagePanzoom = (node: HTMLElement) => {
pzInstance = panzoom(node, {
bounds: true,
boundsPadding: 0.1,
zoomSpeed: 0.065,
zoomDoubleClickSpeed: 1
});
};
let panzoomRef: PanzoomContainer;
export const resetImageView = () => {
if (pzInstance) {
pzInstance.moveTo(0, 0);
pzInstance.zoomAbs(0, 0, 1);
}
};
export const disposePanzoom = () => {
if (pzInstance) {
pzInstance.dispose();
pzInstance = null;
}
panzoomRef?.reset();
};
export const resetPdfView = () => {
pdfViewerRef?.resetView();
};
onDestroy(() => {
disposePanzoom();
});
</script>
<div
@@ -293,14 +269,18 @@
{#if fileLoading}
<div class="flex items-center justify-center h-full"><Spinner className="size-4" /></div>
{:else if fileImageUrl !== null}
<div class="w-full h-full flex items-center justify-center" use:initImagePanzoom>
<PanzoomContainer
bind:this={panzoomRef}
className="w-full h-full flex items-center justify-center"
options={{ zoomDoubleClickSpeed: 1 }}
>
<img
src={fileImageUrl}
alt={selectedFile?.split('/').pop()}
class="max-w-full max-h-full object-contain p-3"
draggable="false"
/>
</div>
</PanzoomContainer>
{:else if fileVideoUrl !== null}
<div class="w-full h-full flex items-center justify-center bg-black">
<!-- svelte-ignore a11y-media-has-caption -->
@@ -343,9 +323,10 @@
</div>
{:else if fileOfficeSlides !== null && fileOfficeSlides.length > 0}
<div class="flex flex-col h-full">
<div
class="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden"
use:initImagePanzoom
<PanzoomContainer
bind:this={panzoomRef}
className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden"
options={{ zoomDoubleClickSpeed: 1 }}
>
<img
src={fileOfficeSlides[currentSlide]}
@@ -353,7 +334,7 @@
class="max-w-full max-h-full object-contain p-3"
draggable="false"
/>
</div>
</PanzoomContainer>
{#if fileOfficeSlides.length > 1}
<div
class="flex items-center justify-center gap-3 py-2 px-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-500"
+5 -21
View File
@@ -25,9 +25,9 @@
import dayjs from 'dayjs';
import Spinner from './Spinner.svelte';
import PDFViewer from './PDFViewer.svelte';
import PanzoomContainer from './PanzoomContainer.svelte';
import Reset from '../icons/Reset.svelte';
import panzoom, { type PanZoom } from 'panzoom';
export let item;
export let show = false;
@@ -60,21 +60,9 @@
let pptxCurrentSlide = 0;
let pptxError = '';
let pzInstance: PanZoom | null = null;
const initImagePanzoom = (node: HTMLElement) => {
pzInstance = panzoom(node, {
bounds: true,
boundsPadding: 0.1,
zoomSpeed: 0.065
});
};
let panzoomRef: PanzoomContainer;
const resetImageView = () => {
if (pzInstance) {
pzInstance.moveTo(0, 0);
pzInstance.zoomAbs(0, 0, 1);
}
panzoomRef?.reset();
};
$: isPDF =
@@ -266,10 +254,6 @@
if (item?.context === 'full') {
enableFullContent = true;
}
return () => {
pzInstance?.dispose();
};
});
</script>
@@ -445,7 +429,7 @@
</button>
</Tooltip>
</div>
<div use:initImagePanzoom>
<PanzoomContainer bind:this={panzoomRef}>
<img
src={`${WEBUI_API_BASE_URL}/files/${item.id}/content`}
alt={item?.name ?? 'Image'}
@@ -453,7 +437,7 @@
loading="lazy"
draggable="false"
/>
</div>
</PanzoomContainer>
</div>
{:else if selectedTab === ''}
{#if item?.file?.data}
+9 -31
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import { onDestroy, onMount, getContext } from 'svelte';
import panzoom, { type PanZoom } from 'panzoom';
import { onDestroy, getContext } from 'svelte';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import PanzoomContainer from '$lib/components/common/PanzoomContainer.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false;
@@ -13,29 +13,8 @@
const i18n = getContext('i18n');
let mounted = false;
let previewElement = null;
let instance: PanZoom;
let sceneParentElement: HTMLElement;
let sceneElement: HTMLElement;
$: if (sceneElement) {
instance = panzoom(sceneElement, {
bounds: true,
boundsPadding: 0.1,
zoomSpeed: 0.065
});
}
const resetPanZoomViewport = () => {
instance.moveTo(0, 0);
instance.zoomAbs(0, 0, 1);
console.log(instance.getTransform());
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
console.log('Escape');
@@ -43,10 +22,6 @@
}
};
onMount(() => {
mounted = true;
});
$: if (show && previewElement) {
document.body.appendChild(previewElement);
window.addEventListener('keydown', handleKeyDown);
@@ -58,11 +33,15 @@
}
onDestroy(() => {
window.removeEventListener('keydown', handleKeyDown);
show = false;
if (previewElement) {
if (previewElement && previewElement.parentNode === document.body) {
document.body.removeChild(previewElement);
}
// NOTE: If multiple modals can stack in the future, direct "unset" may
// re-enable page scroll too early. Consider a shared body-scroll lock manager.
document.body.style.overflow = 'unset';
});
</script>
@@ -181,14 +160,13 @@
</button>
</div>
</div>
<div class="flex h-full max-h-full justify-center items-center z-0">
<PanzoomContainer className="flex h-full max-h-full justify-center items-center z-0">
<img
bind:this={sceneElement}
{src}
{alt}
class=" mx-auto h-full object-scale-down select-none"
draggable="false"
/>
</div>
</PanzoomContainer>
</div>
{/if}
@@ -0,0 +1,33 @@
<script lang="ts">
import { onMount } from 'svelte';
import panzoom, { type PanZoom, type PanZoomOptions } from 'panzoom';
const defaultOpts: PanZoomOptions = {
bounds: true,
boundsPadding: 0.1,
zoomSpeed: 0.065
};
export let className = '';
export let options: Partial<PanZoomOptions> = {};
let containerElement: HTMLElement;
let instance: PanZoom | undefined;
export const reset = () => {
instance?.moveTo(0, 0);
instance?.zoomAbs(0, 0, 1);
};
onMount(() => {
const localInstance = panzoom(containerElement, { ...defaultOpts, ...options });
instance = localInstance;
return () => {
localInstance.dispose();
}
});
</script>
<div bind:this={containerElement} class={className}>
<slot />
</div>
+10 -22
View File
@@ -4,15 +4,14 @@
import { toast } from 'svelte-sonner';
import panzoom, { type PanZoom } from 'panzoom';
import DOMPurify from 'dompurify';
import { onMount, getContext } from 'svelte';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { copyToClipboard } from '$lib/utils';
import DocumentDuplicate from '../icons/DocumentDuplicate.svelte';
import PanzoomContainer from './PanzoomContainer.svelte';
import Tooltip from './Tooltip.svelte';
import Clipboard from '../icons/Clipboard.svelte';
import Reset from '../icons/Reset.svelte';
@@ -22,23 +21,9 @@
export let svg = '';
export let content = '';
let instance: PanZoom;
let sceneParentElement: HTMLElement;
let sceneElement: HTMLElement;
$: if (sceneElement) {
instance = panzoom(sceneElement, {
bounds: true,
boundsPadding: 0.1,
zoomSpeed: 0.065
});
}
let panzoomRef: PanzoomContainer;
const resetPanZoomViewport = () => {
instance.moveTo(0, 0);
instance.zoomAbs(0, 0, 1);
console.log(instance.getTransform());
panzoomRef?.reset();
};
const downloadAsSVG = () => {
@@ -47,8 +32,11 @@
};
</script>
<div bind:this={sceneParentElement} class="relative {className}">
<div bind:this={sceneElement} class="flex h-full max-h-full justify-center items-center">
<div class="relative {className}">
<PanzoomContainer
bind:this={panzoomRef}
className="flex h-full max-h-full justify-center items-center"
>
{@html DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true }, // allow <svg>, <defs>, <filter>, etc.
WHOLE_DOCUMENT: false,
@@ -88,7 +76,7 @@
],
SANITIZE_DOM: true
})}
</div>
</PanzoomContainer>
{#if content}
<div class=" absolute top-2.5 right-2.5">