2 Commits
Author SHA1 Message Date
539943419 c1373de573 refactor(miaoImages): 将图片查看功能提取为独立组件
提取图片查看逻辑到独立的 MiaoImageViewer 组件,提高代码复用性和可维护性
移除原文件中的重复代码,统一处理图片缩放和拖拽功能
2025-08-23 03:07:39 +08:00
539943419 79221d37d6 feat(miaoPDF): 重构PDF查看器并添加拖拽和缩放功能
- 将margin缩放方案改为更精确的scale缩放方案
- 添加拖拽功能,支持长按后拖动PDF内容
- 重新设计顶部工具栏样式和布局
- 添加缩放比例显示和操作提示
2025-08-23 02:54:40 +08:00
3 changed files with 348 additions and 79 deletions

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
<template>
<miao-mask :show="show" @click="$emit('close')" class="miaoImage-mask" @wheel="handleWheel">
<ChevronBack class="miaoImage-mask-btn miaoImage-mask-btn-back" @click.stop="$emit('back')" />
<MiaoDraggableDiv
v-if="file"
:is-draggable="isDraggable"
:bind-virtual-files="[file]"
@dragend="handleDragEnd"
@pointerdown="handlePressStart"
@pointerup="handlePressEnd"
@pointercancel="handlePressEnd"
@pointerleave="handlePressEnd"
@touchstart.passive="handleTouchStart"
@touchend="handleTouchEnd"
>
<img :src="file.url" class="miaoImage-mask-active" @click="(e) => e.stopPropagation()" draggable="false" :style="{ transform: `scale(${imgViewScale})` }" />
</MiaoDraggableDiv>
<ChevronForward class="miaoImage-mask-btn miaoImage-mask-btn-forward" @click.stop="$emit('forward')" />
</miao-mask>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import miaoMask from '@/components/miaoMask.vue'
import MiaoDraggableDiv from '@/components/miaoDraggableDiv.vue'
import { ChevronBack, ChevronForward } from '@vicons/ionicons5'
import type { VirtualFile } from '@/class/VirtualDirectory'
const props = defineProps<{
show: boolean
file?: VirtualFile
}>()
const emit = defineEmits<{
close: []
back: []
forward: []
}>()
const imgViewScale = ref(1)
const isDraggable = ref(false)
let pressTimer: number | undefined
const clearPress = () => {
if (pressTimer !== undefined) {
clearTimeout(pressTimer)
pressTimer = undefined
}
}
const handlePressStart = () => {
clearPress()
pressTimer = window.setTimeout(() => {
isDraggable.value = true
}, 100)
}
const handlePressEnd = () => {
clearPress()
// 延迟复位,避免影响 drop
setTimeout(() => {
isDraggable.value = false
}, 0)
}
const handleTouchStart = () => handlePressStart()
const handleTouchEnd = () => handlePressEnd()
const handleDragEnd = () => {
isDraggable.value = false
}
const handleWheel = (e: WheelEvent) => {
if (e.deltaY < 0) {
imgViewScale.value *= 1.42857
} else {
imgViewScale.value /= 1.42857
}
}
watch(
() => props.file,
() => {
imgViewScale.value = 1
isDraggable.value = false
clearPress()
}
)
watch(
() => props.show,
(v) => {
if (!v) {
imgViewScale.value = 1
isDraggable.value = false
clearPress()
}
}
)
</script>
<style scoped lang="scss">
.miaoImage-mask {
background-color: rgba(0, 0, 0, 0.444);
overflow: hidden;
}
.miaoImage-mask-active {
max-width: 80%;
max-height: 80%;
user-select: none;
}
.miaoImage-mask-btn {
color: #ffffff99;
width: 7%;
position: absolute;
cursor: pointer;
z-index: 9999;
&-back {
left: 1%;
}
&-forward {
right: 1%;
}
}
</style>
+9 -20
View File
@@ -25,28 +25,26 @@
</div> </div>
</div> </div>
</n-scrollbar> </n-scrollbar>
<miao-mask :show="activeImage !== undefined" @click="activeImage = undefined" class="miaoImage-mask" <MiaoImageViewer
@wheel="handleWheel"> :show="activeImage !== undefined"
<ChevronBack class="miaoImage-mask-btn miaoImage-mask-btn-back" @click="handleActiveImageBack" /> :file="activeImage"
<img :src="activeImage?.url" class="miaoImage-mask-active" @click="(e) => e.stopPropagation()" @close="activeImage = undefined"
draggable="false" :style="{ transform: `scale(${imgViewScale})` }" /> @back="handleActiveImageBack"
<ChevronForward class="miaoImage-mask-btn miaoImage-mask-btn-forward" @forward="handleActiveImageForward"
@click="handleActiveImageForward" /> />
</miao-mask>
</div> </div>
</miao-message-provider> </miao-message-provider>
</miao-drop-handler> </miao-drop-handler>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import miaoMask from '@/components/miaoMask.vue'
import miaoDropHandler from '@/components/miaoDropHandler.vue' import miaoDropHandler from '@/components/miaoDropHandler.vue'
import miaoMessageProvider from '@/components/miaoAlertTipProvider.vue' import miaoMessageProvider from '@/components/miaoAlertTipProvider.vue'
import VirtualDirectory, { VirtualFile } from '@/class/VirtualDirectory' import VirtualDirectory, { VirtualFile } from '@/class/VirtualDirectory'
import uniq from 'lodash/uniq' import uniq from 'lodash/uniq'
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { NScrollbar } from 'naive-ui' import { NScrollbar } from 'naive-ui'
import { ChevronBack, ChevronForward } from '@vicons/ionicons5' import MiaoImageViewer from './miaoImageViewer.vue'
const imageSuffixList = [ const imageSuffixList = [
'xbm', 'xbm',
@@ -80,7 +78,6 @@ const currentFiles = defineModel<VirtualFile[]>('currentFiles', {
const NScrollbarRef = ref() const NScrollbarRef = ref()
const rootRef = ref<any>() const rootRef = ref<any>()
const miaoMessageRef = ref<InstanceType<typeof miaoMessageProvider>>() const miaoMessageRef = ref<InstanceType<typeof miaoMessageProvider>>()
const imgViewScale = ref(1)
const imageFileList = ref<VirtualFile[]>([]) const imageFileList = ref<VirtualFile[]>([])
const activeImage = ref<VirtualFile>() const activeImage = ref<VirtualFile>()
@@ -90,7 +87,6 @@ const imageGroupList = computed(() => {
watch(activeImage, () => { watch(activeImage, () => {
nextTick(scrollToActiveImage) nextTick(scrollToActiveImage)
imgViewScale.value = 1
}) })
const groupByDay = (array: VirtualFile[]) => { const groupByDay = (array: VirtualFile[]) => {
@@ -210,14 +206,6 @@ const updateImageSrcList = async (source: VirtualFile[] | VirtualDirectory) => {
) )
} }
const handleWheel = (e: WheelEvent) => {
if (e.deltaY < 0) {
imgViewScale.value *= 1.42857
} else {
imgViewScale.value /= 1.42857
}
}
onMounted(async () => { onMounted(async () => {
const length_before = imageFileList.value.length const length_before = imageFileList.value.length
for (const i of [currentFiles.value, ...currentDirectories.value]) { for (const i of [currentFiles.value, ...currentDirectories.value]) {
@@ -329,6 +317,7 @@ onMounted(async () => {
width: 7%; width: 7%;
position: absolute; position: absolute;
cursor: pointer; cursor: pointer;
z-index: 9999;
&-back { &-back {
left: 1%; left: 1%;
+210 -59
View File
@@ -2,48 +2,30 @@
<miao-message-provider ref="miaoMessageRef"> <miao-message-provider ref="miaoMessageRef">
<div class="miao-container"> <div class="miao-container">
<div class="miao-container-topBar"> <div class="miao-container-topBar">
<div <div class="topBar-main">
class="miao-container-topBar-btn" <div class="top-btn" @click="() => handleZoom(10)" title="快速缩小">--</div>
@click="() => handleZoom(10)" <div class="top-btn" @click="() => handleZoom()" title="缩小">-</div>
title="快速缩小"> <div class="top-btn" @click="() => handleResetMargin()" title="重置">重置</div>
-- <div class="top-btn" @click="() => handleShrink()" title="放大">+</div>
</div> <div class="top-btn" @click="() => handleShrink(10)" title="快速放大">++</div>
<div <div class="scale-indicator">{{ Math.round(scale * 100) }}%</div>
class="miao-container-topBar-btn"
@click="() => handleZoom()"
title="缩小">
-
</div>
<div
class="miao-container-topBar-btn"
@click="() => handleResetMargin()"
title="还原">
还原缩放
</div>
<div
class="miao-container-topBar-btn"
@click="() => handleShrink()"
title="放大">
+
</div>
<div
class="miao-container-topBar-btn"
@click="() => handleShrink(10)"
title="快速放大">
++
</div> </div>
</div> </div>
<NScrollbar> <NScrollbar ref="scrollRef">
<div <div
class="miao-container-pdf" class="miao-container-pdf"
:style="{ @pointerdown="onPointerDown"
alignItems: pdfMargin < 0 ? 'baseline' : 'center' @pointermove="onPointerMove"
}"> @pointerup="onPointerUp"
@pointercancel="onPointerCancel"
:class="{ 'is-dragging': isDragging}"
:style="{'align-items': scale > 1 ? 'unset' : 'center' }">
<div <div
ref="pdfEl" ref="pdfEl"
class="miao-container-pdf-content" class="miao-container-pdf-content"
:style="{ :style="{
width: `calc( 100% - ${pdfMargin}px)` width: `${(scale * 100).toFixed(2)}%`,
}"></div> }"></div>
</div> </div>
</NScrollbar> </NScrollbar>
@@ -53,7 +35,7 @@
<script setup lang="ts"> <script setup lang="ts">
import miaoMessageProvider from '@/components/miaoAlertTipProvider.vue' import miaoMessageProvider from '@/components/miaoAlertTipProvider.vue'
import { onMounted, ref, shallowReactive } from 'vue' import { onMounted, ref, shallowReactive, computed, onBeforeUnmount } from 'vue'
import PdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs?worker' import PdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs?worker'
import { VirtualFile } from '@/class/VirtualDirectory' import { VirtualFile } from '@/class/VirtualDirectory'
import { NScrollbar } from 'naive-ui' import { NScrollbar } from 'naive-ui'
@@ -64,31 +46,148 @@ const currentFiles = defineModel<VirtualFile[]>('currentFiles', {
const pdfEl = ref<HTMLDivElement>() const pdfEl = ref<HTMLDivElement>()
const miaoMessageRef = ref<InstanceType<typeof miaoMessageProvider>>() const miaoMessageRef = ref<InstanceType<typeof miaoMessageProvider>>()
// 通过margin来实现缩小功能 // 新缩放方案:使用等比scale
const pdfMargin = ref<number>(0) const scale = ref<number>(1)
const minScale = 0.5
const maxScale = 4
const pdfPageCount = ref<number>(-1) const scrollRef = ref<InstanceType<typeof NScrollbar> | null>(null)
const isDragging = ref(false)
let pressTimeoutId: number | null = null
let dragging = false
let startX = 0
let startY = 0
let startLeft = 0
let startTop = 0
const longPressDelay = 100
const canvasList = shallowReactive<HTMLCanvasElement[]>([]) // 更稳健的滚动容器解析逻辑,避免依赖 $el.querySelector
const scrollContainer = ref<HTMLElement | null>(null)
const findScrollableParent = (el: HTMLElement | null): HTMLElement | null => {
let cur: HTMLElement | null = el?.parentElement ?? null
while (cur) {
const style = window.getComputedStyle(cur)
const overflowY = style.overflowY
const overflowX = style.overflowX
const canScroll =
(overflowY === 'auto' || overflowY === 'scroll' || overflowX === 'auto' || overflowX === 'scroll') &&
(cur.scrollHeight > cur.clientHeight || cur.scrollWidth > cur.clientWidth)
if (canScroll) return cur
cur = cur.parentElement
}
return null
}
const resolveScrollContainer = () => {
const fromScrollbar = pdfEl.value?.closest('.n-scrollbar')?.querySelector('.n-scrollbar-container') as HTMLElement | null
scrollContainer.value = fromScrollbar ?? findScrollableParent(pdfEl.value ?? null)
}
const getScrollEl = (): HTMLElement | null => {
if (!scrollContainer.value) resolveScrollContainer()
return scrollContainer.value
}
const onPointerDown = (e: PointerEvent) => {
resolveScrollContainer()
const el = getScrollEl()
if (!el) return
startX = e.clientX
startY = e.clientY
startLeft = el.scrollLeft
startTop = el.scrollTop
dragging = false
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
if (pressTimeoutId) window.clearTimeout(pressTimeoutId)
pressTimeoutId = window.setTimeout(() => {
dragging = true
isDragging.value = true
document.body.style.cursor = 'grabbing'
}, longPressDelay)
}
const onPointerMove = (e: PointerEvent) => {
const el = getScrollEl()
if (!el || !dragging) return
const dx = e.clientX - startX
const dy = e.clientY - startY
let newLeft = startLeft - dx
let newTop = startTop - dy
const maxLeft = Math.max(0, el.scrollWidth - el.clientWidth)
const maxTop = Math.max(0, el.scrollHeight - el.clientHeight)
newLeft = Math.max(0, Math.min(maxLeft, newLeft))
newTop = Math.max(0, Math.min(maxTop, newTop))
el.scrollLeft = newLeft
el.scrollTop = newTop
e.preventDefault()
}
const endDrag = (e: PointerEvent) => {
if (pressTimeoutId) {
window.clearTimeout(pressTimeoutId)
pressTimeoutId = null
}
if (dragging) {
isDragging.value = false
document.body.style.cursor = ''
}
dragging = false
try {
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId)
} catch {}
}
const onPointerUp = endDrag
const onPointerCancel = endDrag
onBeforeUnmount(() => {
if (pressTimeoutId) window.clearTimeout(pressTimeoutId)
})
// 等比缩放并保持视窗中心不抖动
const applyZoom = (multiplier: number) => {
// const el = getScrollEl()
const oldScale = scale.value
let newScale = oldScale * multiplier
newScale = Math.max(minScale, Math.min(maxScale, newScale))
// const ratio = newScale / oldScale
// if (el) {
// const cx = el.clientWidth / 2
// const cy = el.clientHeight / 2
// // const targetLeft = (el.scrollLeft + cx) * ratio - cx
// // const targetTop = (el.scrollTop + cy) * ratio - cy
// scale.value = +newScale.toFixed(4)
// // requestAnimationFrame(() => {
// // const maxLeft = Math.max(0, el.scrollWidth - el.clientWidth)
// // const maxTop = Math.max(0, el.scrollHeight - el.clientHeight)
// // el.scrollLeft = Math.max(0, Math.min(maxLeft, targetLeft))
// // el.scrollTop = Math.max(0, Math.min(maxTop, targetTop))
// // })
// return
// }
scale.value = +newScale.toFixed(4)
}
const handleZoom = (index?: number) => { const handleZoom = (index?: number) => {
const _index = index ?? 1 const _index = index ?? 1
pdfMargin.value += 10 * _index const factor = 1 + 0.1 * _index
applyZoom(1 / factor)
} }
const handleShrink = (index?: number) => { const handleShrink = (index?: number) => {
const _index = index ?? 1 const _index = index ?? 1
// if (pdfMargin.value - 10 * _index <= 0) { const factor = 1 + 0.1 * _index
// return pdfMargin.value = 0 applyZoom(factor)
// }
pdfMargin.value -= 10 * _index
} }
const handleResetMargin = () => { const handleResetMargin = () => {
pdfMargin.value = 0 const el = getScrollEl()
scale.value = 1
} }
const pdfPageCount = ref<number>(-1)
const canvasList = shallowReactive<HTMLCanvasElement[]>([])
onMounted(async () => { onMounted(async () => {
// 初始解析一次滚动容器(等待一帧确保DOM就绪)
requestAnimationFrame(() => resolveScrollContainer())
const setLoadingMessage = miaoMessageRef.value?.alertTip('加载PDF插件中', { const setLoadingMessage = miaoMessageRef.value?.alertTip('加载PDF插件中', {
type: 'info', type: 'info',
timeout: -1 timeout: -1
@@ -134,6 +233,15 @@ onMounted(async () => {
<style scoped lang="scss"> <style scoped lang="scss">
.miao-container { .miao-container {
--bar-height: 44px;
--bar-bg: linear-gradient(90deg, #eef1f5 0%, #e3e7ec 100%);
--btn-bg: #ffffff;
--btn-hover: #f2f4f7;
--btn-active: #e6e9ef;
--btn-border: #d7dbe2;
--text: #1f2328;
--subtext: #6b7280;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgb(201, 201, 201); background-color: rgb(201, 201, 201);
@@ -144,31 +252,57 @@ onMounted(async () => {
.miao-container-topBar { .miao-container-topBar {
box-sizing: border-box; box-sizing: border-box;
padding: 0 30px; padding: 0 12px;
height: 30px; height: var(--bar-height);
width: 100%; width: 100%;
background-color: rgb(170, 176, 182); background: var(--bar-bg);
display: flex; display: flex;
justify-content: space-around; justify-content: center;
align-items: center; align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
backdrop-filter: saturate(180%) blur(8px);
&-btn { .topBar-main {
display: flex;
align-items: center;
gap: 8px;
position: relative;
user-select: none;
}
.top-btn {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
height: 80%; height: 30px;
padding: 0 5px; min-width: 34px;
display: flex; padding: 0 10px;
display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: larger; font-size: 14px;
letter-spacing: 1px; letter-spacing: 0.5px;
color: black; color: var(--text);
border-radius: 5px; background-color: var(--btn-bg);
border: 1px solid var(--btn-border);
border-radius: 8px;
box-shadow: 0 1px 0 rgba(0,0,0,0.02);
transition: background-color .15s ease, transform .08s ease, box-shadow .15s ease;
&:hover { &:hover { background-color: var(--btn-hover); }
background-color: #eaeaea42; &:active { background-color: var(--btn-active); transform: scale(0.98); }
} }
.scale-indicator {
position: absolute;
right: -60px;
min-width: 54px;
text-align: center;
font-size: 13px;
color: var(--subtext);
padding: 0 8px;
} }
.hint { color: var(--subtext); font-size: 12px; }
} }
.miao-container-pdf { .miao-container-pdf {
@@ -176,9 +310,26 @@ onMounted(async () => {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; // 居中以便缩小时居中显示
touch-action: none; // 以便自定义拖动
&.is-dragging { cursor: grabbing; }
.miao-container-pdf-content { .miao-container-pdf-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: width .12s ease, padding-top .12s ease;
will-change: width, padding-top;
// 让Canvas随容器宽度等比缩放,保证显示完整
canvas {
width: 100%;
height: auto;
display: block;
margin: 0 auto 12px auto;
background: #fff;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
} }
} }