valaxy博客全局美化教程(四)

本系列教程共十篇

效果预览


简单介绍一下,我用的时候这个博客没有图片预览,就加了一个,大概效果是这样的,底部有预览栏,可以点击切换,移动端可以滑动切换,支持图片缩放,效果还是不错的,对了,后续有一个相册页面,如果想要加入相册页面的话,这个部分必须得加哦,否则可能会出小问题,因为这两个东西是一起写的,虽然二者的图片预览是单独分开的,但是说不好哦~

开始美化

修改文件

修改文件部分共2个文件需要修改
下面这个是css文件,控制组件特效的,默认文件时空的,直接新增即可

styles\index.scss文件增加以下代码
1
2
3
4
5
6
7
8
9
/* 文章图片点击放大 */
.markdown-body img,
.prose img {
cursor: zoom-in;
}

html.image-gallery-open {
overflow: hidden;
}

下面也是修改哦,下面给的是纯净代码,记得格式哦~

文件site.config.tsexport default defineSiteConfig({})内增加以下代码
1
2
3
4
// 关闭自带的图片预览
  mediumZoom: {
    enable: false,
  },

解释一下,官方说自带了一个预览功能,我也没怎么研究,因为说的是很基本,不如自己加~这里就是给他关掉,放置两个一起生效(虽然压根就没开,其实大概不加这个也行)

新增文件

下面是新增文件部分啦,本部分共新增5个文件,有点长哦,别漏啦

新增App.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup lang="ts">
import { onMounted } from 'vue'
import ImageGalleryViewer from './components/ImageGalleryViewer.vue'
import { initImageGallery } from './utils/imageGallery'

onMounted(() => {
initImageGallery()
})
</script>

<template>
<ImageGalleryViewer />
</template>

第二个文件~

新增utils\imageGallery.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
import { ref, shallowRef } from 'vue'

export interface GalleryImage {
src: string
alt: string
}

export const galleryVisible = ref(false)
export const galleryImages = shallowRef<GalleryImage[]>([])
export const galleryIndex = ref(0)

function getImageSrc(img: HTMLImageElement) {
return img.currentSrc || img.src || img.getAttribute('data-src') || ''
}

function isGalleryImage(img: HTMLImageElement) {
if (!img.closest('.markdown-body'))
return false

if (img.closest('pre, code, .sakura-comment, .twikoo, nav, header, footer, .sakura-navbar, .sakura-post-nav'))
return false

const src = getImageSrc(img)
if (!src || src.startsWith('data:'))
return false

return true
}

function collectImages(container: Element) {
return Array.from(container.querySelectorAll<HTMLImageElement>('img'))
.filter(isGalleryImage)
.map(el => ({
el,
src: getImageSrc(el),
alt: el.alt || '',
}))
}

export function openGallery(images: GalleryImage[], index: number) {
if (!images.length)
return

galleryImages.value = images
galleryIndex.value = Math.min(Math.max(index, 0), images.length - 1)
galleryVisible.value = true
document.body.style.overflow = 'hidden'
}

export function closeGallery() {
galleryVisible.value = false
document.body.style.overflow = ''
}

export function prevImage() {
if (galleryIndex.value > 0)
galleryIndex.value -= 1
}

export function nextImage() {
if (galleryIndex.value < galleryImages.value.length - 1)
galleryIndex.value += 1
}

export function goToImage(index: number) {
if (index < 0 || index >= galleryImages.value.length)
return

galleryIndex.value = index
}

function handleImageClick(event: Event) {
if (!(event.target instanceof Element))
return

const img = event.target.closest('img')
if (!(img instanceof HTMLImageElement))
return

if (!isGalleryImage(img))
return

const article = img.closest('.markdown-body')
if (!article)
return

const list = collectImages(article)
const index = list.findIndex(item => item.el === img)
if (index === -1)
return

event.preventDefault()
event.stopPropagation()

openGallery(
list.map(item => ({ src: item.src, alt: item.alt })),
index,
)
}

let initialized = false

export function initImageGallery() {
if (initialized || typeof document === 'undefined')
return

initialized = true
document.addEventListener('click', handleImageClick, true)
}

第三个咯~

新增components\ImageGalleryViewer.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
closeGallery,
galleryImages,
galleryIndex,
galleryVisible,
goToImage,
nextImage,
prevImage,
} from '../utils/imageGallery'
import { createLightboxSwipeHandlers } from '../utils/useLightboxSwipe'
import { useLightboxZoom } from '../utils/useLightboxZoom'

const thumbsContainerRef = ref<HTMLElement | null>(null)
const imageRef = ref<HTMLImageElement | null>(null)
const thumbWidth = ref(72)
const thumbStep = ref(82)

const {
isZoomed,
isDragging,
isPinching,
imageTransformStyle,
resetZoomState,
onImageDblClick,
onPointerDown,
onPointerMove,
onPointerUp,
onWheel,
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel,
} = useLightboxZoom(imageRef)

const current = computed(() => galleryImages.value[galleryIndex.value])
const hasPrev = computed(() => galleryIndex.value > 0)
const hasNext = computed(() => galleryIndex.value < galleryImages.value.length - 1)
const showThumbs = computed(() => galleryImages.value.length > 1)
const counter = computed(() => {
if (galleryImages.value.length <= 1)
return ''

return `${galleryIndex.value + 1} / ${galleryImages.value.length}`
})

const trackOffset = computed(() => {
const containerWidth = thumbsContainerRef.value?.clientWidth ?? 0
if (!containerWidth)
return 0

return containerWidth / 2 - thumbWidth.value / 2 - galleryIndex.value * thumbStep.value
})

const swipeHandlers = createLightboxSwipeHandlers({
enabled: () => galleryVisible.value && galleryImages.value.length > 1,
shouldIgnore: () => isZoomed.value || isDragging.value || isPinching.value,
onPrev: prevImage,
onNext: nextImage,
})

function updateThumbMetrics() {
const container = thumbsContainerRef.value
const thumb = container?.querySelector<HTMLElement>('.image-gallery__thumb')
const track = container?.querySelector<HTMLElement>('.image-gallery__thumbs-track')

if (!container || !thumb || !track)
return

thumbWidth.value = thumb.offsetWidth

const gapValue = Number.parseFloat(getComputedStyle(track).columnGap || getComputedStyle(track).gap || '0')
thumbStep.value = thumbWidth.value + (Number.isFinite(gapValue) ? gapValue : 0)
}

async function syncThumbTrack() {
await nextTick()
updateThumbMetrics()
}

function onKeydown(event: KeyboardEvent) {
if (!galleryVisible.value)
return

if (event.key === 'Escape') {
closeGallery()
return
}

if (event.key === 'ArrowLeft') {
event.preventDefault()
prevImage()
}

if (event.key === 'ArrowRight') {
event.preventDefault()
nextImage()
}
}

function onMobileBackdropClose() {
if (!window.matchMedia('(max-width: 640px)').matches)
return

closeGallery()
}

watch(galleryVisible, (visible) => {
if (visible) {
document.documentElement.classList.add('image-gallery-open')
syncThumbTrack()
}
else {
document.documentElement.classList.remove('image-gallery-open')
resetZoomState()
}
})

watch(galleryIndex, () => {
resetZoomState()
if (galleryVisible.value)
syncThumbTrack()
})

let resizeObserver: ResizeObserver | undefined

onMounted(() => {
window.addEventListener('keydown', onKeydown)
window.addEventListener('resize', updateThumbMetrics)

if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
updateThumbMetrics()
})
}
})

watch(thumbsContainerRef, (container, _, onCleanup) => {
if (resizeObserver && container)
resizeObserver.observe(container)

onCleanup(() => {
if (resizeObserver && container)
resizeObserver.unobserve(container)
})
}, { flush: 'post' })

onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', updateThumbMetrics)
resizeObserver?.disconnect()
document.documentElement.classList.remove('image-gallery-open')
document.body.style.overflow = ''
})
</script>

<template>
<Teleport to="body">
<Transition name="image-gallery-fade">
<div
v-if="galleryVisible && current"
class="image-gallery"
role="dialog"
aria-modal="true"
:aria-label="current.alt || '图片预览'"
@click.self="closeGallery"
>
<div
class="image-gallery__main"
@click.self="onMobileBackdropClose"
>
<button
type="button"
class="image-gallery__close"
aria-label="关闭预览"
@click="closeGallery"
>
×
</button>

<button
v-if="hasPrev"
type="button"
class="image-gallery__nav image-gallery__nav--prev"
aria-label="上一张"
@click.stop="prevImage"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M14.5 6.5 9 12l5.5 5.5" />
</svg>
</button>

<button
v-if="hasNext"
type="button"
class="image-gallery__nav image-gallery__nav--next"
aria-label="下一张"
@click.stop="nextImage"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9.5 6.5 15 12l-5.5 5.5" />
</svg>
</button>

<div
class="image-gallery__stage"
:class="{ 'is-zoomed': isZoomed || isPinching }"
@click.self="onMobileBackdropClose"
@touchstart.passive="swipeHandlers.onTouchStart"
@touchend.passive="swipeHandlers.onTouchEnd"
@touchcancel="swipeHandlers.onTouchCancel"
>
<div
class="image-gallery__viewport"
:class="{
'is-zoomed': isZoomed || isPinching,
'is-dragging': isDragging,
'is-pinching': isPinching,
}"
@click.self="onMobileBackdropClose"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@wheel.prevent="onWheel"
@touchstart="onTouchStart"
@touchmove.prevent="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchCancel"
>
<img
ref="imageRef"
:key="current.src"
class="image-gallery__img"
:class="{ 'is-zoomed': isZoomed }"
:style="imageTransformStyle"
:src="current.src"
:alt="current.alt"
draggable="false"
@dblclick.stop="onImageDblClick"
>
</div>

<p v-if="current.alt" class="image-gallery__caption">
{{ current.alt }}
</p>

<span v-if="counter" class="image-gallery__counter">
{{ counter }}
</span>
</div>
</div>

<div
v-if="showThumbs"
ref="thumbsContainerRef"
class="image-gallery__thumbs"
@click.stop
>
<div class="image-gallery__thumbs-indicator" aria-hidden="true" />

<div
class="image-gallery__thumbs-track"
:style="{ transform: `translateX(${trackOffset}px)` }"
>
<button
v-for="(image, index) in galleryImages"
:key="`${image.src}-${index}`"
type="button"
class="image-gallery__thumb"
:aria-label="`查看第 ${index + 1} 张图片`"
:aria-current="index === galleryIndex ? 'true' : undefined"
@click="goToImage(index)"
>
<img
class="image-gallery__thumb-img"
:src="image.src"
:alt="image.alt || `缩略图 ${index + 1}`"
loading="lazy"
decoding="async"
>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

<style lang="scss" scoped>
.image-gallery {
position: fixed;
inset: 0;
z-index: 10001;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: rgb(0 0 0 / 82%);
backdrop-filter: blur(4px);
}

.image-gallery__main {
position: relative;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
padding: 56px 72px 20px;
box-sizing: border-box;
}

.image-gallery__stage {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
max-width: min(96vw, 1200px);
max-height: 100%;
overflow: hidden;
touch-action: pan-y pinch-zoom;

&.is-zoomed {
touch-action: none;
}
}

.image-gallery__viewport {
display: flex;
justify-content: center;
align-items: center;
max-width: 100%;
max-height: calc(100vh - 220px);
overflow: hidden;

&.is-zoomed,
&.is-pinching {
touch-action: none;
}

&.is-zoomed {
cursor: grab;
width: 100%;
height: calc(100vh - 220px);

&.is-dragging {
cursor: grabbing;
}
}
}

.image-gallery__img {
display: block;
max-width: 100%;
max-height: calc(100vh - 220px);
object-fit: contain;
border-radius: 8px;
box-shadow: 0 12px 40px rgb(0 0 0 / 35%);
user-select: none;
cursor: zoom-in;
-webkit-user-drag: none;

&.is-zoomed {
cursor: zoom-out;
}
}

.image-gallery__caption {
margin: 14px 0 0;
max-width: 100%;
font-size: 0.9rem;
line-height: 1.5;
text-align: center;
color: rgb(255 255 255 / 88%);
}

.image-gallery__counter {
position: absolute;
top: -36px;
right: 0;
font-size: 0.82rem;
color: rgb(255 255 255 / 72%);
font-variant-numeric: tabular-nums;
}

.image-gallery__thumbs {
position: relative;
width: 100%;
padding: 12px 0 calc(14px + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
overflow: hidden;
background: linear-gradient(to top, rgb(0 0 0 / 55%), rgb(0 0 0 / 18%));
border-top: 1px solid rgb(255 255 255 / 10%);
}

.image-gallery__thumbs-indicator {
position: absolute;
top: 12px;
left: 50%;
z-index: 2;
width: 72px;
height: 54px;
border: 2px solid var(--sakura-color-primary, #fe9500);
border-radius: 8px;
pointer-events: none;
transform: translateX(-50%);
box-shadow: 0 0 0 1px rgb(254 149 0 / 35%);
}

.image-gallery__thumbs-track {
display: flex;
gap: 10px;
align-items: center;
width: max-content;
transition: transform 0.28s ease;
will-change: transform;
}

.image-gallery__thumb {
position: relative;
flex: 0 0 auto;
width: 72px;
height: 54px;
padding: 0;
border: none;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: rgb(255 255 255 / 8%);
transition: opacity 0.2s ease, transform 0.2s ease;
opacity: 0.72;

&:hover {
opacity: 0.95;
transform: translateY(-2px);
}

&[aria-current='true'] {
opacity: 1;
}
}

.image-gallery__thumb-img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
user-select: none;
pointer-events: none;
}

.image-gallery__close,
.image-gallery__nav {
position: absolute;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
cursor: pointer;
color: #fff;
background: rgb(0 0 0 / 42%);
transition: background 0.2s ease;

&:hover {
background: rgb(0 0 0 / 62%);
}
}

.image-gallery__close {
top: 16px;
right: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
font-size: 1.5rem;
line-height: 1;
font-family: system-ui, sans-serif;
}

.image-gallery__nav {
top: 50%;
width: 48px;
height: 48px;
border-radius: 50%;
transform: translateY(-50%);

svg {
display: block;
width: 22px;
height: 22px;
fill: none;
stroke: currentcolor;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
}

&--prev {
left: 16px;
}

&--next {
right: 16px;
}
}

.image-gallery-fade-enter-active,
.image-gallery-fade-leave-active {
transition: opacity 0.2s ease;
}

.image-gallery-fade-enter-from,
.image-gallery-fade-leave-to {
opacity: 0;
}

@media (max-width: 640px) {
.image-gallery__main {
padding: 48px 12px 12px;
}

.image-gallery__nav {
display: none;
}

.image-gallery__img {
max-height: calc(100vh - 200px);
}

.image-gallery__thumb,
.image-gallery__thumbs-indicator {
width: 60px;
height: 45px;
}

.image-gallery__thumbs {
padding-inline: 0;
}
}
</style>

第四第四~

新增utils\useLightboxSwipe.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
export interface LightboxSwipeHandlers {
onTouchStart: (event: TouchEvent) => void
onTouchEnd: (event: TouchEvent) => void
onTouchCancel: () => void
}

export function createLightboxSwipeHandlers(options: {
enabled: () => boolean
onPrev: () => void
onNext: () => void
shouldIgnore?: () => boolean
threshold?: number
}): LightboxSwipeHandlers {
const threshold = options.threshold ?? 48

let touchStartX = 0
let touchStartY = 0
let tracking = false

function reset() {
tracking = false
}

function onTouchStart(event: TouchEvent) {
if (!options.enabled() || options.shouldIgnore?.())
return

if (event.touches.length !== 1)
return

touchStartX = event.touches[0].clientX
touchStartY = event.touches[0].clientY
tracking = true
}

function onTouchEnd(event: TouchEvent) {
if (!tracking)
return

tracking = false

if (!options.enabled() || options.shouldIgnore?.())
return

const touch = event.changedTouches[0]
if (!touch)
return

const deltaX = touch.clientX - touchStartX
const deltaY = touch.clientY - touchStartY

if (Math.abs(deltaX) < threshold)
return

if (Math.abs(deltaY) > Math.abs(deltaX))
return

if (deltaX > 0)
options.onPrev()
else
options.onNext()
}

return {
onTouchStart,
onTouchEnd,
onTouchCancel: reset,
}
}

最后一个啦

新增utils\useLightboxZoom.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
import { computed, ref, type Ref } from 'vue'

const MIN_SCALE = 1
const MAX_SCALE = 4
const DOUBLE_TAP_SCALE = 2
const DBLCLICK_TOUCH_GUARD_MS = 700
const DRAG_START_THRESHOLD = 6
const WHEEL_ZOOM_INTENSITY = 0.002

function getTouchDistance(touches: TouchList) {
if (touches.length < 2)
return 0

const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.hypot(dx, dy)
}

function getTouchMidpoint(touches: TouchList) {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2,
}
}

export function useLightboxZoom(imageRef: Ref<HTMLImageElement | null>) {
const currentScale = ref(MIN_SCALE)
const isDragging = ref(false)
const isPinching = ref(false)
const isWheelZooming = ref(false)
const panOffset = ref({ x: 0, y: 0 })

const isZoomed = computed(() => currentScale.value > MIN_SCALE + 0.01)

let dragStartX = 0
let dragStartY = 0
let startPanX = 0
let startPanY = 0
let pendingDrag = false
let activePointerId: number | null = null
let dragCaptureTarget: HTMLElement | null = null

let pinchStartDistance = 0
let pinchStartScale = MIN_SCALE
let pinchStartPan = { x: 0, y: 0 }
let pinchStartFocal = { x: 0, y: 0 }
let pinchLayoutCenter = { x: 0, y: 0 }
let suppressDblClickUntil = 0
let wheelZoomEndTimer: ReturnType<typeof setTimeout> | undefined

function markTouchGesture() {
suppressDblClickUntil = Date.now() + DBLCLICK_TOUCH_GUARD_MS
}

const imageTransformStyle = computed(() => {
if (currentScale.value <= MIN_SCALE && panOffset.value.x === 0 && panOffset.value.y === 0)
return {}

const interacting = isDragging.value || isPinching.value || isWheelZooming.value

return {
transform: `translate(${panOffset.value.x}px, ${panOffset.value.y}px) scale(${currentScale.value})`,
transformOrigin: 'center center',
transition: interacting ? 'none' : 'transform 0.25s ease',
}
})

function getLayoutCenter() {
const img = imageRef.value
if (!img)
return null

const rect = img.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0)
return null

return {
x: rect.left + rect.width / 2 - panOffset.value.x,
y: rect.top + rect.height / 2 - panOffset.value.y,
}
}

function adjustPanForScaleChange(
focalX: number,
focalY: number,
oldScale: number,
newScale: number,
) {
const center = getLayoutCenter()
if (!center)
return

panOffset.value = {
x: focalX - center.x - (focalX - center.x - panOffset.value.x) * (newScale / oldScale),
y: focalY - center.y - (focalY - center.y - panOffset.value.y) * (newScale / oldScale),
}
}

function setScaleAt(focalX: number, focalY: number, newScale: number) {
const oldScale = currentScale.value
const clamped = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newScale))

if (Math.abs(clamped - oldScale) < 0.001)
return

adjustPanForScaleChange(focalX, focalY, oldScale, clamped)
currentScale.value = clamped
}

function resetZoomState() {
currentScale.value = MIN_SCALE
isDragging.value = false
isPinching.value = false
isWheelZooming.value = false
pendingDrag = false
activePointerId = null
dragCaptureTarget = null
panOffset.value = { x: 0, y: 0 }
pinchStartDistance = 0
}

function releasePointerCapture() {
if (dragCaptureTarget && activePointerId !== null) {
try {
dragCaptureTarget.releasePointerCapture(activePointerId)
}
catch {
// pointer may already be released
}
}

activePointerId = null
dragCaptureTarget = null
}

function zoomImageAt(clientX: number, clientY: number) {
if (isZoomed.value) {
resetZoomState()
return
}

const img = imageRef.value
if (!img)
return

const rect = img.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0)
return

setScaleAt(clientX, clientY, DOUBLE_TAP_SCALE)
}

function onImageDblClick(event: MouseEvent) {
if (Date.now() < suppressDblClickUntil) {
event.preventDefault()
return
}

pendingDrag = false
isDragging.value = false
releasePointerCapture()

zoomImageAt(event.clientX, event.clientY)
}

function onPointerDown(event: PointerEvent) {
if (event.pointerType === 'touch')
markTouchGesture()

if (!isZoomed.value || isPinching.value || event.button !== 0)
return

pendingDrag = true
activePointerId = event.pointerId
dragCaptureTarget = event.currentTarget as HTMLElement
dragStartX = event.clientX
dragStartY = event.clientY
startPanX = panOffset.value.x
startPanY = panOffset.value.y
}

function onPointerMove(event: PointerEvent) {
if (!pendingDrag && !isDragging.value)
return

if (activePointerId !== null && event.pointerId !== activePointerId)
return

if (pendingDrag && !isDragging.value) {
const dx = event.clientX - dragStartX
const dy = event.clientY - dragStartY

if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD)
return

isDragging.value = true
pendingDrag = false

if (dragCaptureTarget)
dragCaptureTarget.setPointerCapture(event.pointerId)
}

panOffset.value = {
x: startPanX + event.clientX - dragStartX,
y: startPanY + event.clientY - dragStartY,
}
}

function onPointerUp(event: PointerEvent) {
if (activePointerId !== null && event.pointerId !== activePointerId)
return

pendingDrag = false
isDragging.value = false
releasePointerCapture()
}

function onWheel(event: WheelEvent) {
event.preventDefault()

isWheelZooming.value = true
clearTimeout(wheelZoomEndTimer)
wheelZoomEndTimer = setTimeout(() => {
isWheelZooming.value = false
}, 120)

const newScale = Math.min(
MAX_SCALE,
Math.max(MIN_SCALE, currentScale.value * Math.exp(-event.deltaY * WHEEL_ZOOM_INTENSITY)),
)

if (newScale <= MIN_SCALE + 0.02) {
resetZoomState()
return
}

setScaleAt(event.clientX, event.clientY, newScale)
}

function onTouchStart(event: TouchEvent) {
markTouchGesture()

if (event.touches.length !== 2)
return

isPinching.value = true
isDragging.value = false
pendingDrag = false
releasePointerCapture()

pinchStartDistance = getTouchDistance(event.touches)
pinchStartScale = currentScale.value
pinchStartPan = { ...panOffset.value }
pinchStartFocal = getTouchMidpoint(event.touches)

const center = getLayoutCenter()
if (center)
pinchLayoutCenter = { ...center }

event.preventDefault()
}

function onTouchMove(event: TouchEvent) {
if (event.touches.length === 2 && isPinching.value) {
const distance = getTouchDistance(event.touches)
if (pinchStartDistance <= 0)
return

const focal = getTouchMidpoint(event.touches)
const newScale = Math.min(
MAX_SCALE,
Math.max(MIN_SCALE, pinchStartScale * (distance / pinchStartDistance)),
)

const ratio = newScale / pinchStartScale
panOffset.value = {
x: focal.x - pinchLayoutCenter.x
- (pinchStartFocal.x - pinchLayoutCenter.x - pinchStartPan.x) * ratio
+ (focal.x - pinchStartFocal.x),
y: focal.y - pinchLayoutCenter.y
- (pinchStartFocal.y - pinchLayoutCenter.y - pinchStartPan.y) * ratio
+ (focal.y - pinchStartFocal.y),
}

currentScale.value = newScale
event.preventDefault()
}
}

function onTouchEnd(event: TouchEvent) {
markTouchGesture()

if (!isPinching.value || event.touches.length >= 2)
return

isPinching.value = false
pinchStartDistance = 0

if (currentScale.value <= MIN_SCALE + 0.02)
resetZoomState()
}

function onTouchCancel() {
markTouchGesture()

if (isPinching.value) {
isPinching.value = false
pinchStartDistance = 0

if (currentScale.value <= MIN_SCALE + 0.02)
resetZoomState()
}
}

return {
isZoomed,
isDragging,
isPinching,
imageTransformStyle,
resetZoomState,
onImageDblClick,
onPointerDown,
onPointerMove,
onPointerUp,
onWheel,
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel,
}
}

说一嘴,上面这个一行一个空格,其实本来不这样,但是复制过来自己加上了,但是不影响,就是有点不雅观好啦到此结束咯

valaxy博客全局美化教程(四)
© AIOVTUE · https://20030327.xyz/posts/bdc4192f/· 更新于 2026-05-31