Skip to content

Commit eba2b2c

Browse files
refactor(SVGPanZoom): Fix memory leaking - consolidate zoom lifecycle into PanzoomContainer (open-webui#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
1 parent 1c5e84d commit eba2b2c

6 files changed

Lines changed: 72 additions & 109 deletions

File tree

src/lib/components/chat/FileNav.svelte

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,6 @@
293293
// ── File preview management ──────────────────────────────────────────
294294
const clearFilePreview = () => {
295295
fileContent = null;
296-
filePreviewRef?.disposePanzoom();
297296
if (fileImageUrl) {
298297
URL.revokeObjectURL(fileImageUrl);
299298
fileImageUrl = null;

src/lib/components/chat/FileNav/FilePreview.svelte

Lines changed: 15 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
<script lang="ts">
2-
import { getContext, onDestroy, tick } from 'svelte';
3-
import panzoom, { type PanZoom } from 'panzoom';
2+
import { getContext, tick } from 'svelte';
43
import { marked } from 'marked';
54
import DOMPurify from 'dompurify';
65
import { settings } from '$lib/stores';
76
import { isCodeFile } from '$lib/utils/codeHighlight';
87
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
98
import Spinner from '../../common/Spinner.svelte';
109
import PDFViewer from '../../common/PDFViewer.svelte';
10+
import PanzoomContainer from '../../common/PanzoomContainer.svelte';
1111
import JsonTreeView from './JsonTreeView.svelte';
1212
import NotebookView from './NotebookView.svelte';
1313
import SqliteView from './SqliteView.svelte';
@@ -250,38 +250,14 @@
250250
showRaw = true;
251251
}
252252
253-
let pzInstance: PanZoom | null = null;
254-
255-
const initImagePanzoom = (node: HTMLElement) => {
256-
pzInstance = panzoom(node, {
257-
bounds: true,
258-
boundsPadding: 0.1,
259-
zoomSpeed: 0.065,
260-
zoomDoubleClickSpeed: 1
261-
});
262-
};
263-
253+
let panzoomRef: PanzoomContainer;
264254
export const resetImageView = () => {
265-
if (pzInstance) {
266-
pzInstance.moveTo(0, 0);
267-
pzInstance.zoomAbs(0, 0, 1);
268-
}
269-
};
270-
271-
export const disposePanzoom = () => {
272-
if (pzInstance) {
273-
pzInstance.dispose();
274-
pzInstance = null;
275-
}
255+
panzoomRef?.reset();
276256
};
277257
278258
export const resetPdfView = () => {
279259
pdfViewerRef?.resetView();
280260
};
281-
282-
onDestroy(() => {
283-
disposePanzoom();
284-
});
285261
</script>
286262

287263
<div
@@ -293,14 +269,18 @@
293269
{#if fileLoading}
294270
<div class="flex items-center justify-center h-full"><Spinner className="size-4" /></div>
295271
{:else if fileImageUrl !== null}
296-
<div class="w-full h-full flex items-center justify-center" use:initImagePanzoom>
272+
<PanzoomContainer
273+
bind:this={panzoomRef}
274+
className="w-full h-full flex items-center justify-center"
275+
options={{ zoomDoubleClickSpeed: 1 }}
276+
>
297277
<img
298278
src={fileImageUrl}
299279
alt={selectedFile?.split('/').pop()}
300280
class="max-w-full max-h-full object-contain p-3"
301281
draggable="false"
302282
/>
303-
</div>
283+
</PanzoomContainer>
304284
{:else if fileVideoUrl !== null}
305285
<div class="w-full h-full flex items-center justify-center bg-black">
306286
<!-- svelte-ignore a11y-media-has-caption -->
@@ -343,17 +323,18 @@
343323
</div>
344324
{:else if fileOfficeSlides !== null && fileOfficeSlides.length > 0}
345325
<div class="flex flex-col h-full">
346-
<div
347-
class="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden"
348-
use:initImagePanzoom
326+
<PanzoomContainer
327+
bind:this={panzoomRef}
328+
className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden"
329+
options={{ zoomDoubleClickSpeed: 1 }}
349330
>
350331
<img
351332
src={fileOfficeSlides[currentSlide]}
352333
alt="Slide {currentSlide + 1}"
353334
class="max-w-full max-h-full object-contain p-3"
354335
draggable="false"
355336
/>
356-
</div>
337+
</PanzoomContainer>
357338
{#if fileOfficeSlides.length > 1}
358339
<div
359340
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"

src/lib/components/common/FileItemModal.svelte

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@
2525
import dayjs from 'dayjs';
2626
import Spinner from './Spinner.svelte';
2727
import PDFViewer from './PDFViewer.svelte';
28+
import PanzoomContainer from './PanzoomContainer.svelte';
2829
import Reset from '../icons/Reset.svelte';
2930
30-
import panzoom, { type PanZoom } from 'panzoom';
3131
3232
export let item;
3333
export let show = false;
@@ -60,21 +60,9 @@
6060
let pptxCurrentSlide = 0;
6161
let pptxError = '';
6262
63-
let pzInstance: PanZoom | null = null;
64-
65-
const initImagePanzoom = (node: HTMLElement) => {
66-
pzInstance = panzoom(node, {
67-
bounds: true,
68-
boundsPadding: 0.1,
69-
zoomSpeed: 0.065
70-
});
71-
};
72-
63+
let panzoomRef: PanzoomContainer;
7364
const resetImageView = () => {
74-
if (pzInstance) {
75-
pzInstance.moveTo(0, 0);
76-
pzInstance.zoomAbs(0, 0, 1);
77-
}
65+
panzoomRef?.reset();
7866
};
7967
8068
$: isPDF =
@@ -266,10 +254,6 @@
266254
if (item?.context === 'full') {
267255
enableFullContent = true;
268256
}
269-
270-
return () => {
271-
pzInstance?.dispose();
272-
};
273257
});
274258
</script>
275259

@@ -445,15 +429,15 @@
445429
</button>
446430
</Tooltip>
447431
</div>
448-
<div use:initImagePanzoom>
432+
<PanzoomContainer bind:this={panzoomRef}>
449433
<img
450434
src={`${WEBUI_API_BASE_URL}/files/${item.id}/content`}
451435
alt={item?.name ?? 'Image'}
452436
class="w-full object-contain rounded-lg"
453437
loading="lazy"
454438
draggable="false"
455439
/>
456-
</div>
440+
</PanzoomContainer>
457441
</div>
458442
{:else if selectedTab === ''}
459443
{#if item?.file?.data}

src/lib/components/common/ImagePreview.svelte

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<script lang="ts">
2-
import { onDestroy, onMount, getContext } from 'svelte';
3-
import panzoom, { type PanZoom } from 'panzoom';
2+
import { onDestroy, getContext } from 'svelte';
43
54
import fileSaver from 'file-saver';
65
const { saveAs } = fileSaver;
76
7+
import PanzoomContainer from '$lib/components/common/PanzoomContainer.svelte';
88
import XMark from '$lib/components/icons/XMark.svelte';
99
1010
export let show = false;
@@ -13,40 +13,15 @@
1313
1414
const i18n = getContext('i18n');
1515
16-
let mounted = false;
17-
1816
let previewElement = null;
1917
20-
let instance: PanZoom;
21-
22-
let sceneParentElement: HTMLElement;
23-
let sceneElement: HTMLElement;
24-
25-
$: if (sceneElement) {
26-
instance = panzoom(sceneElement, {
27-
bounds: true,
28-
boundsPadding: 0.1,
29-
30-
zoomSpeed: 0.065
31-
});
32-
}
33-
const resetPanZoomViewport = () => {
34-
instance.moveTo(0, 0);
35-
instance.zoomAbs(0, 0, 1);
36-
console.log(instance.getTransform());
37-
};
38-
3918
const handleKeyDown = (event: KeyboardEvent) => {
4019
if (event.key === 'Escape') {
4120
console.log('Escape');
4221
show = false;
4322
}
4423
};
4524
46-
onMount(() => {
47-
mounted = true;
48-
});
49-
5025
$: if (show && previewElement) {
5126
document.body.appendChild(previewElement);
5227
window.addEventListener('keydown', handleKeyDown);
@@ -58,11 +33,15 @@
5833
}
5934
6035
onDestroy(() => {
36+
window.removeEventListener('keydown', handleKeyDown);
6137
show = false;
6238
63-
if (previewElement) {
39+
if (previewElement && previewElement.parentNode === document.body) {
6440
document.body.removeChild(previewElement);
6541
}
42+
// NOTE: If multiple modals can stack in the future, direct "unset" may
43+
// re-enable page scroll too early. Consider a shared body-scroll lock manager.
44+
document.body.style.overflow = 'unset';
6645
});
6746
</script>
6847

@@ -181,14 +160,13 @@
181160
</button>
182161
</div>
183162
</div>
184-
<div class="flex h-full max-h-full justify-center items-center z-0">
163+
<PanzoomContainer className="flex h-full max-h-full justify-center items-center z-0">
185164
<img
186-
bind:this={sceneElement}
187165
{src}
188166
{alt}
189167
class=" mx-auto h-full object-scale-down select-none"
190168
draggable="false"
191169
/>
192-
</div>
170+
</PanzoomContainer>
193171
</div>
194172
{/if}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<script lang="ts">
2+
import { onMount } from 'svelte';
3+
import panzoom, { type PanZoom, type PanZoomOptions } from 'panzoom';
4+
5+
const defaultOpts: PanZoomOptions = {
6+
bounds: true,
7+
boundsPadding: 0.1,
8+
zoomSpeed: 0.065
9+
};
10+
11+
export let className = '';
12+
export let options: Partial<PanZoomOptions> = {};
13+
14+
let containerElement: HTMLElement;
15+
let instance: PanZoom | undefined;
16+
17+
export const reset = () => {
18+
instance?.moveTo(0, 0);
19+
instance?.zoomAbs(0, 0, 1);
20+
};
21+
22+
onMount(() => {
23+
const localInstance = panzoom(containerElement, { ...defaultOpts, ...options });
24+
instance = localInstance;
25+
return () => {
26+
localInstance.dispose();
27+
}
28+
});
29+
</script>
30+
31+
<div bind:this={containerElement} class={className}>
32+
<slot />
33+
</div>

src/lib/components/common/SVGPanZoom.svelte

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@
44
55
import { toast } from 'svelte-sonner';
66
7-
import panzoom, { type PanZoom } from 'panzoom';
87
import DOMPurify from 'dompurify';
98
10-
import { onMount, getContext } from 'svelte';
9+
import { getContext } from 'svelte';
1110
const i18n = getContext('i18n');
1211
1312
import { copyToClipboard } from '$lib/utils';
1413
15-
import DocumentDuplicate from '../icons/DocumentDuplicate.svelte';
14+
import PanzoomContainer from './PanzoomContainer.svelte';
1615
import Tooltip from './Tooltip.svelte';
1716
import Clipboard from '../icons/Clipboard.svelte';
1817
import Reset from '../icons/Reset.svelte';
@@ -22,23 +21,9 @@
2221
export let svg = '';
2322
export let content = '';
2423
25-
let instance: PanZoom;
26-
27-
let sceneParentElement: HTMLElement;
28-
let sceneElement: HTMLElement;
29-
30-
$: if (sceneElement) {
31-
instance = panzoom(sceneElement, {
32-
bounds: true,
33-
boundsPadding: 0.1,
34-
35-
zoomSpeed: 0.065
36-
});
37-
}
24+
let panzoomRef: PanzoomContainer;
3825
const resetPanZoomViewport = () => {
39-
instance.moveTo(0, 0);
40-
instance.zoomAbs(0, 0, 1);
41-
console.log(instance.getTransform());
26+
panzoomRef?.reset();
4227
};
4328
4429
const downloadAsSVG = () => {
@@ -47,8 +32,11 @@
4732
};
4833
</script>
4934

50-
<div bind:this={sceneParentElement} class="relative {className}">
51-
<div bind:this={sceneElement} class="flex h-full max-h-full justify-center items-center">
35+
<div class="relative {className}">
36+
<PanzoomContainer
37+
bind:this={panzoomRef}
38+
className="flex h-full max-h-full justify-center items-center"
39+
>
5240
{@html DOMPurify.sanitize(svg, {
5341
USE_PROFILES: { svg: true, svgFilters: true }, // allow <svg>, <defs>, <filter>, etc.
5442
WHOLE_DOCUMENT: false,
@@ -88,7 +76,7 @@
8876
],
8977
SANITIZE_DOM: true
9078
})}
91-
</div>
79+
</PanzoomContainer>
9280

9381
{#if content}
9482
<div class=" absolute top-2.5 right-2.5">

0 commit comments

Comments
 (0)