images, image viewer

frdel committed Jan 21, 2026 at 14:02 UTC cc834d3b36b4e16e4aba4a6a7c19d8a56b0bda02
5 files changed +223 -59
webui/components/modals/image-viewer/image-viewer-store.js
+159 -10
@@ -11,6 +11,23 @@ const model = {
11 refreshInterval: 0,
12 activeIntervalId: null,
13 closePromise: null,
14 +
15 + // Image dimensions
16 + naturalWidth: 0,
17 + naturalHeight: 0,
18 + baseScale: 1,
19 +
20 + // Pan state
21 + panX: 0,
22 + panY: 0,
23 + isDragging: false,
24 + dragStartX: 0,
25 + dragStartY: 0,
26 + dragStartPanX: 0,
27 + dragStartPanY: 0,
28 + activePointerId: null,
29 + windowDragMoveHandler: null,
30 + windowDragUpHandler: null,
31
32 /**
33 * Open image viewer modal
@@ -129,31 +146,163 @@ const model = {
146 this.imageError = false;
147 this.zoomLevel = 1;
148 this.refreshInterval = 0;
149 + this.naturalWidth = 0;
150 + this.naturalHeight = 0;
151 + this.baseScale = 1;
152 + this.panX = 0;
153 + this.panY = 0;
154 + this.isDragging = false;
155 + this.activePointerId = null;
156 + this.removeWindowDragListeners();
157 + },
158 +
159 + onImageLoad(event) {
160 + const img = event.target;
161 + this.naturalWidth = img.naturalWidth;
162 + this.naturalHeight = img.naturalHeight;
163 + this.imageLoaded = true;
164 + this.zoomLevel = 1;
165 + this.panX = 0;
166 + this.panY = 0;
167 + // Reset any previous transform
168 + img.classList.remove('zoomed');
169 + img.style.width = '';
170 + img.style.height = '';
171 + img.style.transform = '';
172 + },
173 +
174 + // Drag/pan methods
175 + startDrag(event) {
176 + // Pointer events: mouse (button 0) or touch/pen (button can be -1/undefined)
177 + if (typeof event.button === 'number' && event.button !== 0) return;
178 + event.preventDefault();
179 +
180 + this.isDragging = true;
181 + this.activePointerId = typeof event.pointerId === 'number' ? event.pointerId : null;
182 + this.dragStartX = event.clientX;
183 + this.dragStartY = event.clientY;
184 + this.dragStartPanX = this.panX;
185 + this.dragStartPanY = this.panY;
186 +
187 + const canvas = document.querySelector('.image-canvas');
188 + if (canvas && this.activePointerId !== null && typeof canvas.setPointerCapture === 'function') {
189 + try {
190 + canvas.setPointerCapture(this.activePointerId);
191 + } catch (e) {
192 + // ignore
193 + }
194 + }
195 +
196 + this.addWindowDragListeners();
197 + },
198 +
199 + onDrag(event) {
200 + if (!this.isDragging) return;
201 + if (this.activePointerId !== null && typeof event.pointerId === 'number' && event.pointerId !== this.activePointerId) {
202 + return;
203 + }
204 + event.preventDefault();
205 + const dx = event.clientX - this.dragStartX;
206 + const dy = event.clientY - this.dragStartY;
207 + this.panX = this.dragStartPanX + dx;
208 + this.panY = this.dragStartPanY + dy;
209 + this.updateImageTransform();
210 + },
211 +
212 + endDrag() {
213 + if (!this.isDragging) return;
214 + this.isDragging = false;
215 + this.activePointerId = null;
216 + this.removeWindowDragListeners();
217 + },
218 +
219 + addWindowDragListeners() {
220 + this.removeWindowDragListeners();
221 +
222 + this.windowDragMoveHandler = (e) => this.onDrag(e);
223 + this.windowDragUpHandler = () => this.endDrag();
224 +
225 + window.addEventListener('pointermove', this.windowDragMoveHandler, { passive: false });
226 + window.addEventListener('pointerup', this.windowDragUpHandler, { passive: false });
227 + window.addEventListener('pointercancel', this.windowDragUpHandler, { passive: false });
228 + },
229 +
230 + removeWindowDragListeners() {
231 + if (this.windowDragMoveHandler) {
232 + window.removeEventListener('pointermove', this.windowDragMoveHandler);
233 + this.windowDragMoveHandler = null;
234 + }
235 + if (this.windowDragUpHandler) {
236 + window.removeEventListener('pointerup', this.windowDragUpHandler);
237 + window.removeEventListener('pointercancel', this.windowDragUpHandler);
238 + this.windowDragUpHandler = null;
239 + }
240 + },
241 +
242 + clampPan(containerWidth, containerHeight, displayWidth, displayHeight) {
243 + const margin = 25;
244 +
245 + const limitX = Math.max(0, (containerWidth + displayWidth) / 2 - margin);
246 + const limitY = Math.max(0, (containerHeight + displayHeight) / 2 - margin);
247 +
248 + this.panX = Math.max(-limitX, Math.min(limitX, this.panX));
249 + this.panY = Math.max(-limitY, Math.min(limitY, this.panY));
250 + },
251 +
252 + getDisplaySize(containerWidth, containerHeight) {
253 + // Fit in both dimensions without upscaling
254 + const scaleX = containerWidth / this.naturalWidth;
255 + const scaleY = containerHeight / this.naturalHeight;
256 + const fitScale = Math.min(scaleX, scaleY, 1);
257 + const scale = fitScale * this.zoomLevel;
258 +
259 + return {
260 + width: this.naturalWidth * scale,
261 + height: this.naturalHeight * scale,
262 + };
263 },
264
265 // Zoom controls
266 zoomIn() {
136 - this.zoomLevel = Math.min(this.zoomLevel * 1.2, 5); // Max 5x zoom
137 - this.updateImageZoom();
267 + this.zoomLevel = Math.min(this.zoomLevel * 1.25, 10);
268 + this.updateImageTransform();
269 },
270
271 zoomOut() {
141 - this.zoomLevel = Math.max(this.zoomLevel / 1.2, 0.1); // Min 0.1x zoom
142 - this.updateImageZoom();
272 + this.zoomLevel = Math.max(this.zoomLevel / 1.25, 0.1);
273 + this.updateImageTransform();
274 },
275
276 resetZoom() {
277 this.zoomLevel = 1;
147 - this.updateImageZoom();
148 - },
149 -
150 - updateImageZoom() {
151 - const img = document.querySelector(".modal-image");
278 + this.panX = 0;
279 + this.panY = 0;
280 + const img = document.querySelector('.modal-image');
281 if (img) {
153 - img.style.transform = `scale(${this.zoomLevel})`;
282 + img.classList.remove('zoomed');
283 + img.style.width = '';
284 + img.style.height = '';
285 + img.style.transform = '';
286 }
287 },
288
289 + updateImageTransform() {
290 + const img = document.querySelector('.modal-image');
291 + const wrapper = document.querySelector('.image-canvas');
292 + if (!img || !wrapper || !this.naturalWidth || !this.naturalHeight) return;
293 +
294 + const containerWidth = wrapper.clientWidth;
295 + const containerHeight = wrapper.clientHeight;
296 +
297 + const displaySize = this.getDisplaySize(containerWidth, containerHeight);
298 + this.clampPan(containerWidth, containerHeight, displaySize.width, displaySize.height);
299 +
300 + img.classList.add('zoomed');
301 + img.style.width = `${displaySize.width}px`;
302 + img.style.height = `${displaySize.height}px`;
303 + img.style.transform = `translate(${this.panX}px, ${this.panY}px)`;
304 + },
305 +
306 // Utility methods
307 addTimestamp(url) {
308 try {
webui/components/modals/image-viewer/image-viewer.html
+39 -46
@@ -12,15 +12,21 @@
12 <div x-data>
13 <template x-if="$store.imageViewer">
14 <div id="image-viewer-wrapper" class="image-modal-container">
15 - <!-- Image display area -->
16 - <div class="image-display-wrapper">
15 + <!-- Image canvas for panning -->
16 + <div class="image-canvas"
17 + @pointerdown="$store.imageViewer.startDrag($event)"
18 + @pointermove="$store.imageViewer.onDrag($event)"
19 + @pointerup="$store.imageViewer.endDrag()"
20 + @pointercancel="$store.imageViewer.endDrag()">
21 <img
22 x-show="$store.imageViewer.currentImageUrl"
23 :src="$store.imageViewer.currentImageUrl"
24 :alt="$store.imageViewer.currentImageName || 'Image'"
25 class="modal-image"
22 - @load="$store.imageViewer.imageLoaded = true"
26 + :class="{ 'dragging': $store.imageViewer.isDragging }"
27 + @load="$store.imageViewer.onImageLoad($event)"
28 @error="$store.imageViewer.imageError = true"
29 + draggable="false"
30 />
31
32 <!-- Loading indicator -->
@@ -35,11 +41,17 @@
41 </div>
42 </div>
43
38 - <!-- Simple zoom controls -->
44 + <!-- Fixed zoom controls - OUTSIDE the canvas -->
45 <div class="zoom-controls">
40 - <button @click="$store.imageViewer.zoomOut()" class="zoom-btn" title="Zoom Out">−</button>
41 - <button @click="$store.imageViewer.resetZoom()" class="zoom-btn" title="Reset">⌂</button>
42 - <button @click="$store.imageViewer.zoomIn()" class="zoom-btn" title="Zoom In">+</button>
46 + <button type="button" @click="$store.imageViewer.zoomOut()" class="button zoom-btn">
47 + <span class="icon material-symbols-outlined">zoom_out</span>
48 + </button>
49 + <button type="button" @click="$store.imageViewer.resetZoom()" class="button zoom-btn">
50 + <span class="icon material-symbols-outlined">home</span>
51 + </button>
52 + <button type="button" @click="$store.imageViewer.zoomIn()" class="button zoom-btn">
53 + <span class="icon material-symbols-outlined">zoom_in</span>
54 + </button>
55 </div>
56 </div>
57 </template>
@@ -48,61 +60,46 @@
60 <style>
61 .image-modal-container {
62 width: 100%;
51 - height: 100%;
63 + height: 70vh;
64 + position: relative;
65 + background: var(--color-bg-secondary);
66 display: flex;
53 - flex-direction: column;
67 align-items: center;
68 justify-content: center;
56 - position: relative;
57 - background: var(--color-bg-secondary);
69 }
70
60 - .image-display-wrapper {
61 - flex: 1;
71 + .image-canvas {
72 + width: 100%;
73 + height: 100%;
74 display: flex;
75 align-items: center;
76 justify-content: center;
65 - overflow: auto;
66 - width: 100%;
67 - height: 100%;
68 - position: relative;
69 - }
70 -
71 - /* Unified scrollbar styling for modal content */
72 - .image-display-wrapper::-webkit-scrollbar {
73 - width: 6px;
74 - height: 6px;
75 - }
76 -
77 - .image-display-wrapper::-webkit-scrollbar-track {
78 - background: transparent;
79 - margin: 4px 0;
80 - border-radius: 6px;
81 - }
82 -
83 - .image-display-wrapper::-webkit-scrollbar-thumb {
84 - background-color: rgba(155, 155, 155, 0.5);
85 - border-radius: 6px;
86 - transition: background-color 0.2s ease;
77 + cursor: grab;
78 + overflow: hidden;
79 + touch-action: none;
80 + background: repeating-linear-gradient(45deg, var(--color-panel) 0 10px, var(--color-chat-background) 10px 20px);
81 }
82
89 - .image-display-wrapper::-webkit-scrollbar-thumb:hover {
90 - background-color: rgba(155, 155, 155, 0.7);
83 + .image-canvas:active {
84 + cursor: grabbing;
85 }
86
87 .modal-image {
88 max-width: 100%;
89 max-height: 100%;
90 object-fit: contain;
97 - transition: transform 0.2s ease;
98 - cursor: grab;
91 + user-select: none;
92 + -webkit-user-drag: none;
93 + pointer-events: none;
94 }
95
101 - .modal-image:active {
102 - cursor: grabbing;
96 + .modal-image.zoomed {
97 + max-width: none;
98 + max-height: none;
99 }
100
101 .loading-indicator, .error-indicator {
102 + position: absolute;
103 display: flex;
104 flex-direction: column;
105 align-items: center;
@@ -136,6 +133,7 @@
133 padding: 6px;
134 border-radius: 12px;
135 backdrop-filter: blur(5px);
136 + z-index: 100;
137 }
138
139 .zoom-btn {
@@ -158,11 +156,6 @@
156 .zoom-btn:hover {
157 background: rgba(255, 255, 255, 0.15);
158 }
161 -
162 - /* Dark mode adjustments */
163 - .dark-mode .image-modal-container {
164 - background: var(--color-bg-secondary);
165 - }
159 </style>
160
161 </body>
webui/css/messages.css
+10 -2
@@ -643,10 +643,18 @@
643 }
644
645 .message-agent-response .msg-content img {
646 - max-width: 100%;
647 - max-height: 100em;
646 + margin: var(--spacing-xs);
647 + max-width: 25em;
648 + max-height: 25em;
649 + border-radius: var(--border-radius);
650 + background: repeating-linear-gradient(45deg, var(--color-panel) 0 10px, var(--color-chat-background) 10px 20px);
651 }
652
653 +.message-agent-response .msg-content .message-markdown-image-wrap img:hover{
654 + transform: translateY(-2px);
655 +}
656 +
657 +
658 .msg-content h1 {
659 font-size: 1.25em;
660 font-weight: 800;
webui/index.css
+1 -1
@@ -700,7 +700,7 @@ h4 {
700 width: 100%;
701 height: 100%;
702 object-fit: cover;
703 - border-radius: 10px;
703 + border-radius: var(--border-radius);
704 background: repeating-linear-gradient(45deg, var(--color-panel) 0 10px, var(--color-chat-background) 10px 20px);
705
706 }
webui/js/messages.js
+14
@@ -1235,6 +1235,20 @@ function adjustMarkdownRender(element) {
1235 el.parentNode.insertBefore(wrapper, el);
1236 wrapper.appendChild(el);
1237 });
1238 +
1239 + // find all images
1240 + const images = element.querySelectorAll("img");
1241 +
1242 + // wrap each image in <a>
1243 + images.forEach((img) => {
1244 + if (img.parentNode?.tagName === "A") return;
1245 + const link = document.createElement("a");
1246 + link.className = "message-markdown-image-wrap";
1247 + link.href = img.src;
1248 + img.parentNode.insertBefore(link, img);
1249 + link.appendChild(img);
1250 + link.onclick = (e) => (e.preventDefault(), imageViewerStore.open(img.src, { name: img.alt || "Image" }));
1251 + });
1252 }
1253
1254 class Scroller {