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 {