Skip to content

Commit 5277469

Browse files
Lui Wing LeungLui Wing Leung
authored andcommitted
fix: double-click zoom to click position and toggle to fit
1 parent 96357b4 commit 5277469

5 files changed

Lines changed: 90 additions & 53 deletions

File tree

resources/dist/filament-panzoom.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resources/js/index.js

Lines changed: 80 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
scale: 1,
1717
panX: 0,
1818
panY: 0,
19+
fittedScale: 1,
20+
fittedPanX: 0,
21+
fittedPanY: 0,
1922
originX: 0,
2023
originY: 0,
2124
isPanning: false,
@@ -64,10 +67,20 @@
6467

6568
const scaleX = containerWidth / imageWidth;
6669
const scaleY = containerHeight / imageHeight;
67-
this.scale = Math.min(scaleX, scaleY, 1);
70+
const newScale = Math.min(scaleX, scaleY, 1);
6871

69-
this.panX = 0;
70-
this.panY = 0;
72+
// Center the image within the container using top-left origin math
73+
const centeredPanX = (containerWidth - imageWidth * newScale) / 2;
74+
const centeredPanY = (containerHeight - imageHeight * newScale) / 2;
75+
76+
this.scale = newScale;
77+
this.panX = centeredPanX;
78+
this.panY = centeredPanY;
79+
80+
// Store fitted state for precise toggling
81+
this.fittedScale = newScale;
82+
this.fittedPanX = centeredPanX;
83+
this.fittedPanY = centeredPanY;
7184

7285
this.$nextTick(() => {
7386
this.updateBounds();
@@ -119,38 +132,32 @@
119132
const clickX = e.clientX - rect.left;
120133
const clickY = e.clientY - rect.top;
121134

122-
// Toggle between fit-to-container and configurable zoom at click position
123-
if (this.scale <= 1) {
124-
// Zoom to configurable level at the exact click position
135+
// Toggle: if we're approximately at fitted state, zoom in, else return to fitted state
136+
const near = (a, b, eps = 0.01) => Math.abs(a - b) <= eps;
137+
const isNearFitted = near(this.scale, this.fittedScale) && near(this.panX, this.fittedPanX, 1) && near(this.panY, this.fittedPanY, 1);
138+
139+
if (isNearFitted) {
140+
// Zoom to configurable level at the exact click position using top-left origin math
125141
const newScale = Math.min(this.maxScale, this.doubleClickZoomLevel);
126-
127-
// Calculate the position on the image where user clicked
128-
// First, get the current image position relative to container
129-
const imageX = clickX - this.panX;
130-
const imageY = clickY - this.panY;
131-
132-
// Calculate the ratio of click position relative to current image size
133-
const imageWidth = this.$refs.image.naturalWidth * this.scale;
134-
const imageHeight = this.$refs.image.naturalHeight * this.scale;
135-
const clickRatioX = imageX / imageWidth;
136-
const clickRatioY = imageY / imageHeight;
137-
138-
// Calculate new image dimensions
139-
const newImageWidth = this.$refs.image.naturalWidth * newScale;
140-
const newImageHeight = this.$refs.image.naturalHeight * newScale;
141-
142-
// Calculate new pan to keep the clicked point in the same position
143-
const newPanX = clickX - (newImageWidth * clickRatioX);
144-
const newPanY = clickY - (newImageHeight * clickRatioY);
145-
142+
143+
// Image coordinates of the click before scaling
144+
const u = (clickX - this.panX) / this.scale;
145+
const v = (clickY - this.panY) / this.scale;
146+
147+
// New pan to keep the clicked point stationary
148+
const newPanX = clickX - (u * newScale);
149+
const newPanY = clickY - (v * newScale);
150+
151+
this.scale = newScale;
146152
this.panX = newPanX;
147153
this.panY = newPanY;
148-
this.scale = newScale;
149154
} else {
150-
// Reset to fit container
151-
this.fitToContainer();
155+
// Return to fitted state
156+
this.scale = this.fittedScale;
157+
this.panX = this.fittedPanX;
158+
this.panY = this.fittedPanY;
152159
}
153-
160+
154161
this.constrainPan();
155162
},
156163

@@ -165,27 +172,46 @@
165172
const newScale = Math.max(this.minScale, Math.min(this.maxScale, this.scale + delta));
166173

167174
if (newScale !== this.scale) {
168-
const scaleDiff = newScale - this.scale;
169-
const centerX = rect.width / 2;
170-
const centerY = rect.height / 2;
171-
this.panX -= (x - centerX) * scaleDiff;
172-
this.panY -= (y - centerY) * scaleDiff;
175+
// Keep cursor point stationary (top-left origin math)
176+
const u = (x - this.panX) / this.scale;
177+
const v = (y - this.panY) / this.scale;
173178

174179
this.scale = newScale;
180+
this.panX = x - (u * newScale);
181+
this.panY = y - (v * newScale);
182+
175183
this.constrainPan();
176184
}
177185
},
178186

179187
zoomIn() {
188+
const rect = this.$refs.container.getBoundingClientRect();
189+
const x = rect.width / 2;
190+
const y = rect.height / 2;
180191
const newScale = Math.min(this.maxScale, this.scale + 0.2);
181-
this.scale = newScale;
182-
this.constrainPan();
192+
if (newScale !== this.scale) {
193+
const u = (x - this.panX) / this.scale;
194+
const v = (y - this.panY) / this.scale;
195+
this.scale = newScale;
196+
this.panX = x - (u * newScale);
197+
this.panY = y - (v * newScale);
198+
this.constrainPan();
199+
}
183200
},
184201

185202
zoomOut() {
203+
const rect = this.$refs.container.getBoundingClientRect();
204+
const x = rect.width / 2;
205+
const y = rect.height / 2;
186206
const newScale = Math.max(this.minScale, this.scale - 0.2);
187-
this.scale = newScale;
188-
this.constrainPan();
207+
if (newScale !== this.scale) {
208+
const u = (x - this.panX) / this.scale;
209+
const v = (y - this.panY) / this.scale;
210+
this.scale = newScale;
211+
this.panX = x - (u * newScale);
212+
this.panY = y - (v * newScale);
213+
this.constrainPan();
214+
}
189215
},
190216

191217
reset() {
@@ -201,11 +227,22 @@
201227
const containerWidth = container.offsetWidth;
202228
const containerHeight = container.offsetHeight;
203229

204-
const maxPanX = Math.max(0, (imageWidth - containerWidth) / 2);
205-
const maxPanY = Math.max(0, (imageHeight - containerHeight) / 2);
230+
// If the image is smaller than the container, keep it centered
231+
if (imageWidth <= containerWidth) {
232+
this.panX = (containerWidth - imageWidth) / 2;
233+
} else {
234+
const minPanX = containerWidth - imageWidth;
235+
const maxPanX = 0;
236+
this.panX = Math.max(minPanX, Math.min(maxPanX, this.panX));
237+
}
206238

207-
this.panX = Math.max(-maxPanX, Math.min(maxPanX, this.panX));
208-
this.panY = Math.max(-maxPanY, Math.min(maxPanY, this.panY));
239+
if (imageHeight <= containerHeight) {
240+
this.panY = (containerHeight - imageHeight) / 2;
241+
} else {
242+
const minPanY = containerHeight - imageHeight;
243+
const maxPanY = 0;
244+
this.panY = Math.max(minPanY, Math.min(maxPanY, this.panY));
245+
}
209246
},
210247

211248
startTouch(e) {

resources/views/components/pan-zoom.blade.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class="relative bg-gray-50 rounded-lg border border-gray-200 overflow-hidden w-f
2323
style="height: 470px; min-height: 470px; max-height: 470px;"
2424
>
2525
<div
26-
class="w-full h-full relative flex items-center justify-center"
26+
class="w-full h-full relative"
2727
x-ref="container"
2828
@wheel.prevent="zoom"
2929
@mousemove="pan"
@@ -35,10 +35,10 @@ class="w-full h-full relative flex items-center justify-center"
3535
<img
3636
x-ref="image"
3737
:src="imageUrl"
38-
:style="`transform: translate(${panX}px, ${panY}px) scale(${scale}); transform-origin: center center;`"
38+
:style="`transform: translate(${panX}px, ${panY}px) scale(${scale}); transform-origin: 0 0; position: absolute; left: 0; top: 0;`"
3939
:class="isPanning ? 'cursor-grabbing' : 'cursor-grab'"
4040
class="max-w-none transition-transform duration-75 block select-none"
41-
style="object-fit: contain; max-width: 100%; max-height: 100%;"
41+
style="max-width: none; max-height: none;"
4242
alt="Image"
4343
@load="onImageLoad"
4444
@mousedown="startPan"

resources/views/filament-panzoom.blade.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ class="relative bg-gray-50 rounded-lg border border-gray-200 overflow-hidden w-f
44
style="height: 470px; min-height: 470px; max-height: 470px;"
55
>
66
<div
7-
class="w-full h-full relative flex items-center justify-center"
7+
class="w-full h-full relative"
88
x-ref="container"
99
@wheel.prevent="zoom"
1010
@mousemove="pan"
@@ -16,10 +16,10 @@ class="w-full h-full relative flex items-center justify-center"
1616
<img
1717
x-ref="image"
1818
:src="imageUrl"
19-
:style="`transform: translate(${panX}px, ${panY}px) scale(${scale}); transform-origin: center center;`"
19+
:style="`transform: translate(${panX}px, ${panY}px) scale(${scale}); transform-origin: 0 0; position: absolute; left: 0; top: 0;`"
2020
:class="isPanning ? 'cursor-grabbing' : 'cursor-grab'"
2121
class="max-w-none transition-transform duration-75 block select-none"
22-
style="object-fit: contain; max-width: 100%; max-height: 100%;"
22+
style="max-width: none; max-height: none;"
2323
alt="Receipt Image"
2424
@load="onImageLoad"
2525
@mousedown="startPan"

resources/views/infolists/components/pan-zoom-entry.blade.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ class="relative bg-gray-50 rounded-lg border border-gray-200 overflow-hidden w-f
44
style="height: 470px; min-height: 470px; max-height: 470px;"
55
>
66
<div
7-
class="w-full h-full relative flex items-center justify-center"
7+
class="w-full h-full relative"
88
x-ref="container"
99
@wheel.prevent="zoom"
1010
@mousemove="pan"
@@ -16,10 +16,10 @@ class="w-full h-full relative flex items-center justify-center"
1616
<img
1717
x-ref="image"
1818
:src="imageUrl"
19-
:style="`transform: translate(${panX}px, ${panY}px) scale(${scale}); transform-origin: center center;`"
19+
:style="`transform: translate(${panX}px, ${panY}px) scale(${scale}); transform-origin: 0 0; position: absolute; left: 0; top: 0;`"
2020
:class="isPanning ? 'cursor-grabbing' : 'cursor-grab'"
2121
class="max-w-none transition-transform duration-75 block select-none"
22-
style="object-fit: contain; max-width: 100%; max-height: 100%;"
22+
style="max-width: none; max-height: none;"
2323
alt="Receipt Image"
2424
@load="onImageLoad"
2525
@mousedown="startPan"

0 commit comments

Comments
 (0)