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:
@@ -293,7 +293,6 @@
|
|||||||
// ── File preview management ──────────────────────────────────────────
|
// ── File preview management ──────────────────────────────────────────
|
||||||
const clearFilePreview = () => {
|
const clearFilePreview = () => {
|
||||||
fileContent = null;
|
fileContent = null;
|
||||||
filePreviewRef?.disposePanzoom();
|
|
||||||
if (fileImageUrl) {
|
if (fileImageUrl) {
|
||||||
URL.revokeObjectURL(fileImageUrl);
|
URL.revokeObjectURL(fileImageUrl);
|
||||||
fileImageUrl = null;
|
fileImageUrl = null;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext, onDestroy, tick } from 'svelte';
|
import { getContext, tick } from 'svelte';
|
||||||
import panzoom, { type PanZoom } from 'panzoom';
|
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { settings } from '$lib/stores';
|
import { settings } from '$lib/stores';
|
||||||
@@ -8,6 +7,7 @@
|
|||||||
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
|
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
|
||||||
import Spinner from '../../common/Spinner.svelte';
|
import Spinner from '../../common/Spinner.svelte';
|
||||||
import PDFViewer from '../../common/PDFViewer.svelte';
|
import PDFViewer from '../../common/PDFViewer.svelte';
|
||||||
|
import PanzoomContainer from '../../common/PanzoomContainer.svelte';
|
||||||
import JsonTreeView from './JsonTreeView.svelte';
|
import JsonTreeView from './JsonTreeView.svelte';
|
||||||
import NotebookView from './NotebookView.svelte';
|
import NotebookView from './NotebookView.svelte';
|
||||||
import SqliteView from './SqliteView.svelte';
|
import SqliteView from './SqliteView.svelte';
|
||||||
@@ -250,38 +250,14 @@
|
|||||||
showRaw = true;
|
showRaw = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pzInstance: PanZoom | null = null;
|
let panzoomRef: PanzoomContainer;
|
||||||
|
|
||||||
const initImagePanzoom = (node: HTMLElement) => {
|
|
||||||
pzInstance = panzoom(node, {
|
|
||||||
bounds: true,
|
|
||||||
boundsPadding: 0.1,
|
|
||||||
zoomSpeed: 0.065,
|
|
||||||
zoomDoubleClickSpeed: 1
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resetImageView = () => {
|
export const resetImageView = () => {
|
||||||
if (pzInstance) {
|
panzoomRef?.reset();
|
||||||
pzInstance.moveTo(0, 0);
|
|
||||||
pzInstance.zoomAbs(0, 0, 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const disposePanzoom = () => {
|
|
||||||
if (pzInstance) {
|
|
||||||
pzInstance.dispose();
|
|
||||||
pzInstance = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resetPdfView = () => {
|
export const resetPdfView = () => {
|
||||||
pdfViewerRef?.resetView();
|
pdfViewerRef?.resetView();
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
disposePanzoom();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -293,14 +269,18 @@
|
|||||||
{#if fileLoading}
|
{#if fileLoading}
|
||||||
<div class="flex items-center justify-center h-full"><Spinner className="size-4" /></div>
|
<div class="flex items-center justify-center h-full"><Spinner className="size-4" /></div>
|
||||||
{:else if fileImageUrl !== null}
|
{: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
|
<img
|
||||||
src={fileImageUrl}
|
src={fileImageUrl}
|
||||||
alt={selectedFile?.split('/').pop()}
|
alt={selectedFile?.split('/').pop()}
|
||||||
class="max-w-full max-h-full object-contain p-3"
|
class="max-w-full max-h-full object-contain p-3"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</PanzoomContainer>
|
||||||
{:else if fileVideoUrl !== null}
|
{:else if fileVideoUrl !== null}
|
||||||
<div class="w-full h-full flex items-center justify-center bg-black">
|
<div class="w-full h-full flex items-center justify-center bg-black">
|
||||||
<!-- svelte-ignore a11y-media-has-caption -->
|
<!-- svelte-ignore a11y-media-has-caption -->
|
||||||
@@ -343,9 +323,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if fileOfficeSlides !== null && fileOfficeSlides.length > 0}
|
{:else if fileOfficeSlides !== null && fileOfficeSlides.length > 0}
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<div
|
<PanzoomContainer
|
||||||
class="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden"
|
bind:this={panzoomRef}
|
||||||
use:initImagePanzoom
|
className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden"
|
||||||
|
options={{ zoomDoubleClickSpeed: 1 }}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={fileOfficeSlides[currentSlide]}
|
src={fileOfficeSlides[currentSlide]}
|
||||||
@@ -353,7 +334,7 @@
|
|||||||
class="max-w-full max-h-full object-contain p-3"
|
class="max-w-full max-h-full object-contain p-3"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</PanzoomContainer>
|
||||||
{#if fileOfficeSlides.length > 1}
|
{#if fileOfficeSlides.length > 1}
|
||||||
<div
|
<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"
|
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"
|
||||||
|
|||||||
@@ -25,9 +25,9 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import Spinner from './Spinner.svelte';
|
import Spinner from './Spinner.svelte';
|
||||||
import PDFViewer from './PDFViewer.svelte';
|
import PDFViewer from './PDFViewer.svelte';
|
||||||
|
import PanzoomContainer from './PanzoomContainer.svelte';
|
||||||
import Reset from '../icons/Reset.svelte';
|
import Reset from '../icons/Reset.svelte';
|
||||||
|
|
||||||
import panzoom, { type PanZoom } from 'panzoom';
|
|
||||||
|
|
||||||
export let item;
|
export let item;
|
||||||
export let show = false;
|
export let show = false;
|
||||||
@@ -60,21 +60,9 @@
|
|||||||
let pptxCurrentSlide = 0;
|
let pptxCurrentSlide = 0;
|
||||||
let pptxError = '';
|
let pptxError = '';
|
||||||
|
|
||||||
let pzInstance: PanZoom | null = null;
|
let panzoomRef: PanzoomContainer;
|
||||||
|
|
||||||
const initImagePanzoom = (node: HTMLElement) => {
|
|
||||||
pzInstance = panzoom(node, {
|
|
||||||
bounds: true,
|
|
||||||
boundsPadding: 0.1,
|
|
||||||
zoomSpeed: 0.065
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetImageView = () => {
|
const resetImageView = () => {
|
||||||
if (pzInstance) {
|
panzoomRef?.reset();
|
||||||
pzInstance.moveTo(0, 0);
|
|
||||||
pzInstance.zoomAbs(0, 0, 1);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$: isPDF =
|
$: isPDF =
|
||||||
@@ -266,10 +254,6 @@
|
|||||||
if (item?.context === 'full') {
|
if (item?.context === 'full') {
|
||||||
enableFullContent = true;
|
enableFullContent = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
|
||||||
pzInstance?.dispose();
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -445,7 +429,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div use:initImagePanzoom>
|
<PanzoomContainer bind:this={panzoomRef}>
|
||||||
<img
|
<img
|
||||||
src={`${WEBUI_API_BASE_URL}/files/${item.id}/content`}
|
src={`${WEBUI_API_BASE_URL}/files/${item.id}/content`}
|
||||||
alt={item?.name ?? 'Image'}
|
alt={item?.name ?? 'Image'}
|
||||||
@@ -453,7 +437,7 @@
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</PanzoomContainer>
|
||||||
</div>
|
</div>
|
||||||
{:else if selectedTab === ''}
|
{:else if selectedTab === ''}
|
||||||
{#if item?.file?.data}
|
{#if item?.file?.data}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount, getContext } from 'svelte';
|
import { onDestroy, getContext } from 'svelte';
|
||||||
import panzoom, { type PanZoom } from 'panzoom';
|
|
||||||
|
|
||||||
import fileSaver from 'file-saver';
|
import fileSaver from 'file-saver';
|
||||||
const { saveAs } = fileSaver;
|
const { saveAs } = fileSaver;
|
||||||
|
|
||||||
|
import PanzoomContainer from '$lib/components/common/PanzoomContainer.svelte';
|
||||||
import XMark from '$lib/components/icons/XMark.svelte';
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
@@ -13,29 +13,8 @@
|
|||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
let mounted = false;
|
|
||||||
|
|
||||||
let previewElement = null;
|
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) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
console.log('Escape');
|
console.log('Escape');
|
||||||
@@ -43,10 +22,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
mounted = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
$: if (show && previewElement) {
|
$: if (show && previewElement) {
|
||||||
document.body.appendChild(previewElement);
|
document.body.appendChild(previewElement);
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
@@ -58,11 +33,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
show = false;
|
show = false;
|
||||||
|
|
||||||
if (previewElement) {
|
if (previewElement && previewElement.parentNode === document.body) {
|
||||||
document.body.removeChild(previewElement);
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -181,14 +160,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<img
|
||||||
bind:this={sceneElement}
|
|
||||||
{src}
|
{src}
|
||||||
{alt}
|
{alt}
|
||||||
class=" mx-auto h-full object-scale-down select-none"
|
class=" mx-auto h-full object-scale-down select-none"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</PanzoomContainer>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
||||||
@@ -4,15 +4,14 @@
|
|||||||
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
import panzoom, { type PanZoom } from 'panzoom';
|
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
import { onMount, getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
import { copyToClipboard } from '$lib/utils';
|
import { copyToClipboard } from '$lib/utils';
|
||||||
|
|
||||||
import DocumentDuplicate from '../icons/DocumentDuplicate.svelte';
|
import PanzoomContainer from './PanzoomContainer.svelte';
|
||||||
import Tooltip from './Tooltip.svelte';
|
import Tooltip from './Tooltip.svelte';
|
||||||
import Clipboard from '../icons/Clipboard.svelte';
|
import Clipboard from '../icons/Clipboard.svelte';
|
||||||
import Reset from '../icons/Reset.svelte';
|
import Reset from '../icons/Reset.svelte';
|
||||||
@@ -22,23 +21,9 @@
|
|||||||
export let svg = '';
|
export let svg = '';
|
||||||
export let content = '';
|
export let content = '';
|
||||||
|
|
||||||
let instance: PanZoom;
|
let panzoomRef: PanzoomContainer;
|
||||||
|
|
||||||
let sceneParentElement: HTMLElement;
|
|
||||||
let sceneElement: HTMLElement;
|
|
||||||
|
|
||||||
$: if (sceneElement) {
|
|
||||||
instance = panzoom(sceneElement, {
|
|
||||||
bounds: true,
|
|
||||||
boundsPadding: 0.1,
|
|
||||||
|
|
||||||
zoomSpeed: 0.065
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const resetPanZoomViewport = () => {
|
const resetPanZoomViewport = () => {
|
||||||
instance.moveTo(0, 0);
|
panzoomRef?.reset();
|
||||||
instance.zoomAbs(0, 0, 1);
|
|
||||||
console.log(instance.getTransform());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadAsSVG = () => {
|
const downloadAsSVG = () => {
|
||||||
@@ -47,8 +32,11 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={sceneParentElement} class="relative {className}">
|
<div class="relative {className}">
|
||||||
<div bind:this={sceneElement} class="flex h-full max-h-full justify-center items-center">
|
<PanzoomContainer
|
||||||
|
bind:this={panzoomRef}
|
||||||
|
className="flex h-full max-h-full justify-center items-center"
|
||||||
|
>
|
||||||
{@html DOMPurify.sanitize(svg, {
|
{@html DOMPurify.sanitize(svg, {
|
||||||
USE_PROFILES: { svg: true, svgFilters: true }, // allow <svg>, <defs>, <filter>, etc.
|
USE_PROFILES: { svg: true, svgFilters: true }, // allow <svg>, <defs>, <filter>, etc.
|
||||||
WHOLE_DOCUMENT: false,
|
WHOLE_DOCUMENT: false,
|
||||||
@@ -88,7 +76,7 @@
|
|||||||
],
|
],
|
||||||
SANITIZE_DOM: true
|
SANITIZE_DOM: true
|
||||||
})}
|
})}
|
||||||
</div>
|
</PanzoomContainer>
|
||||||
|
|
||||||
{#if content}
|
{#if content}
|
||||||
<div class=" absolute top-2.5 right-2.5">
|
<div class=" absolute top-2.5 right-2.5">
|
||||||
|
|||||||
Reference in New Issue
Block a user