1599 lines
53 KiB
Vue
1599 lines
53 KiB
Vue
<template>
|
||
<div class="document-search">
|
||
<div class="viewer-container">
|
||
<transition name="slide-fade">
|
||
<div v-if="showSearchBar" class="floating-search">
|
||
<div class="search-toolbar" :class="{ 'is-searching': searching }">
|
||
<div class="search-box">
|
||
<el-input v-model="keyword" class="search-input" placeholder="输入关键字搜索" clearable
|
||
ref="keywordInput" @keyup.enter.native="handleSearch" @clear="resetSearch">
|
||
</el-input>
|
||
<button class="icon-btn search-icon" :disabled="searching" @click="handleSearch">
|
||
<i class="el-icon-search" v-if="!searching"></i>
|
||
<i class="el-icon-loading" v-else></i>
|
||
</button>
|
||
</div>
|
||
<div class="search-status">
|
||
<span class="result-indicator">{{ resultDisplay }}</span>
|
||
<button class="icon-btn" :disabled="!searchResults.length" @click="goToPrevious">
|
||
<i class="el-icon-arrow-up"></i>
|
||
</button>
|
||
<button class="icon-btn" :disabled="!searchResults.length" @click="goToNext">
|
||
<i class="el-icon-arrow-down"></i>
|
||
</button>
|
||
<button class="icon-btn" @click="handleCloseSearch">
|
||
<i class="el-icon-close"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
<button v-if="!showSearchBar" class="floating-search-btn" @click="toggleSearchBar">
|
||
<i class="el-icon-search"></i>
|
||
</button>
|
||
<div ref="pdfWrapper" class="pdf-wrapper"></div>
|
||
|
||
<transition name="fade">
|
||
<div v-if="overlayType === 'loading'" key="loading" class="state-panel overlay">
|
||
<i class="el-icon-loading state-icon"></i>
|
||
<p>正在加载 PDF,请稍候...</p>
|
||
</div>
|
||
<div v-else-if="overlayType === 'error'" key="error" class="state-panel overlay">
|
||
<i class="el-icon-warning-outline state-icon"></i>
|
||
<p>{{ error }}</p>
|
||
<el-button type="primary" @click="reload">重新加载</el-button>
|
||
</div>
|
||
<div v-else-if="overlayType === 'no-url'" key="no-url" class="state-panel overlay">
|
||
<i class="el-icon-document state-icon"></i>
|
||
<p>暂未指定 PDF 文件,请通过路由参数 url 传入文件地址。</p>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import debounce from 'lodash/debounce'
|
||
import 'pdfjs-dist/legacy/web/pdf_viewer.css'
|
||
import { EventBus, TextLayerBuilder } from 'pdfjs-dist/legacy/web/pdf_viewer'
|
||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf'
|
||
import pdfWorker from 'pdfjs-dist/legacy/build/pdf.worker.entry'
|
||
|
||
const resolvedWorkerSrc =
|
||
typeof pdfWorker === 'string'
|
||
? pdfWorker
|
||
: pdfWorker && pdfWorker.default
|
||
? pdfWorker.default
|
||
: (pdfWorker && pdfWorker.workerSrc) || null
|
||
|
||
if (resolvedWorkerSrc) {
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc = resolvedWorkerSrc
|
||
} else {
|
||
console.warn('未能解析 PDF.js Worker 地址,将在主线程解析 PDF')
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc = null
|
||
}
|
||
|
||
const DEFAULT_SCALE = 1.20
|
||
const DEFAULT_PDF_URL = 'http://192.168.0.14:9090/smart-bid/technicalSolutionDatabase/2025/10/30/fe5b46ea37554516a71e7c0c486d3715.pdf'
|
||
|
||
export default {
|
||
name: 'DocumentSearch',
|
||
props: {
|
||
fileUrl: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
showSearchBar: true
|
||
},
|
||
computed: {
|
||
overlayType() {
|
||
if (this.loading) {
|
||
return 'loading'
|
||
}
|
||
if (this.error) {
|
||
return 'error'
|
||
}
|
||
if (!this.pdfUrl) {
|
||
return 'no-url'
|
||
}
|
||
return null
|
||
},
|
||
resultDisplay() {
|
||
const total = this.searchResults.length
|
||
if (!total) return '0/0'
|
||
const current = this.currentResultIndex >= 0 ? this.currentResultIndex + 1 : 0
|
||
return `${current}/${total}`
|
||
}
|
||
},
|
||
data() {
|
||
return {
|
||
pdfUrl: '',
|
||
pdfDoc: null,
|
||
keyword: '',
|
||
loading: false,
|
||
error: null,
|
||
scale: DEFAULT_SCALE,
|
||
eventBus: new EventBus(),
|
||
pageTextDivs: [],
|
||
searchResults: [],
|
||
currentResultIndex: -1,
|
||
pageMatchRanges: [],
|
||
renderedPages: new Map(),
|
||
observer: null,
|
||
pageContainers: [],
|
||
totalPages: 0,
|
||
isDocumentReady: false,
|
||
renderQueue: [],
|
||
isProcessingQueue: false,
|
||
pageCache: new Map(),
|
||
isPrefetching: false,
|
||
prefetchHandle: null,
|
||
prefetchScheduled: false,
|
||
initialPreloadedCount: 0,
|
||
cMapUrl: '',
|
||
standardFontDataUrl: '',
|
||
pdfAssetsBase: '',
|
||
searching: false,
|
||
scrollAnimationFrame: null,
|
||
}
|
||
},
|
||
mounted() {
|
||
const base = (process.env.BASE_URL || '/').replace(/\/+$/, '/')
|
||
this.pdfAssetsBase = `${base}pdfjs/`
|
||
this.cMapUrl = `${this.pdfAssetsBase}cmaps/`
|
||
this.standardFontDataUrl = `${this.pdfAssetsBase}standard_fonts/`
|
||
|
||
pdfjsLib.GlobalCMapOptions = {
|
||
url: this.cMapUrl,
|
||
packed: true,
|
||
}
|
||
if ('GlobalStandardFontDataUrl' in pdfjsLib) {
|
||
pdfjsLib.GlobalStandardFontDataUrl = this.standardFontDataUrl
|
||
}
|
||
if (pdfjsLib.GlobalOptions) {
|
||
pdfjsLib.GlobalOptions.disableFontFace = false
|
||
if ('useSystemFonts' in pdfjsLib.GlobalOptions) {
|
||
pdfjsLib.GlobalOptions.useSystemFonts = true
|
||
}
|
||
}
|
||
|
||
if (!this.pdfUrl) {
|
||
const initialUrl = this.fileUrl || this.$route?.query?.url || DEFAULT_PDF_URL
|
||
this.applyPdfUrl(initialUrl)
|
||
}
|
||
},
|
||
beforeDestroy() {
|
||
this.disconnectObserver()
|
||
this.cancelScrollAnimation()
|
||
},
|
||
watch: {
|
||
fileUrl: {
|
||
immediate: true,
|
||
handler(newVal) {
|
||
if (newVal) {
|
||
this.applyPdfUrl(newVal)
|
||
}
|
||
}
|
||
},
|
||
'$route.query.url'(newUrl, oldUrl) {
|
||
if (this.fileUrl) return
|
||
if (newUrl !== oldUrl) {
|
||
const target = newUrl || DEFAULT_PDF_URL
|
||
this.applyPdfUrl(target)
|
||
}
|
||
},
|
||
},
|
||
methods: {
|
||
toggleSearchBar() {
|
||
this.showSearchBar = true
|
||
this.$nextTick(() => {
|
||
if (this.$refs.keywordInput) {
|
||
this.$refs.keywordInput.focus()
|
||
}
|
||
})
|
||
},
|
||
handleCloseSearch() {
|
||
this.keyword = ''
|
||
this.resetSearch()
|
||
this.showSearchBar = false
|
||
},
|
||
applyPdfUrl(url) {
|
||
const resolved = url || ''
|
||
if (resolved === this.pdfUrl) {
|
||
if (resolved) {
|
||
this.loadDocument()
|
||
}
|
||
return
|
||
}
|
||
this.pdfUrl = resolved
|
||
if (this.pdfUrl) {
|
||
this.loadDocument()
|
||
} else {
|
||
this.resetViewerState()
|
||
}
|
||
},
|
||
async loadDocument() {
|
||
if (!this.pdfUrl) return
|
||
this.loading = true
|
||
this.error = null
|
||
this.resetViewerState()
|
||
await this.$nextTick()
|
||
|
||
try {
|
||
const headers = {}
|
||
const token = this.$store?.getters?.token || window?.sessionStorage?.getItem('token')
|
||
if (token) {
|
||
headers.Authorization = token.startsWith('Bearer ') ? token : `Bearer ${token}`
|
||
}
|
||
|
||
const loadingTask = pdfjsLib.getDocument({
|
||
url: this.pdfUrl,
|
||
withCredentials: true,
|
||
httpHeaders: headers,
|
||
disableWorker: !resolvedWorkerSrc,
|
||
useWorkerFetch: !!resolvedWorkerSrc,
|
||
cMapUrl: this.cMapUrl,
|
||
cMapPacked: true,
|
||
standardFontDataUrl: this.standardFontDataUrl,
|
||
useSystemFonts: true,
|
||
fontExtraProperties: true,
|
||
})
|
||
this.pdfDoc = await loadingTask.promise
|
||
console.log('PDF 文档加载成功', this.pdfDoc)
|
||
await this.renderAllPages()
|
||
if (this.keyword) {
|
||
await this.highlightMatches()
|
||
}
|
||
} catch (err) {
|
||
console.error('加载 PDF 失败:', err)
|
||
this.error = err?.message || 'PDF 文件加载失败,请稍后再试'
|
||
} finally {
|
||
this.loading = false
|
||
}
|
||
},
|
||
|
||
async renderAllPages() {
|
||
if (!this.pdfDoc) return
|
||
const container = this.$refs.pdfWrapper
|
||
if (!container) return
|
||
container.innerHTML = ''
|
||
container.style.minWidth = ''
|
||
this.pageTextDivs = []
|
||
this.renderedPages.clear()
|
||
this.pageContainers = []
|
||
this.totalPages = this.pdfDoc.numPages
|
||
this.renderQueue = []
|
||
this.isProcessingQueue = false
|
||
this.pageCache.clear()
|
||
this.isPrefetching = false
|
||
this.initialPreloadedCount = 0
|
||
|
||
const fragment = document.createDocumentFragment()
|
||
for (let pageNumber = 1; pageNumber <= this.totalPages; pageNumber += 1) {
|
||
const placeholder = document.createElement('div')
|
||
placeholder.className = 'pdf-page placeholder'
|
||
placeholder.style.margin = '0px auto 10px'
|
||
placeholder.style.position = 'relative';
|
||
placeholder.dataset.page = pageNumber
|
||
placeholder.dataset.status = 'placeholder'
|
||
fragment.appendChild(placeholder)
|
||
this.pageContainers.push(placeholder)
|
||
}
|
||
container.appendChild(fragment)
|
||
container.scrollTop = 0
|
||
|
||
this.setupIntersectionObserver()
|
||
await this.preloadInitialPages()
|
||
this.observeInitialPages()
|
||
this.schedulePrefetch()
|
||
this.isDocumentReady = true
|
||
},
|
||
|
||
ensureContainerDimensions(pageNumber, viewport) {
|
||
const index = pageNumber - 1
|
||
const container = this.pageContainers[index]
|
||
if (!container) return
|
||
|
||
container.style.width = `${viewport.width}px`
|
||
container.style.height = `${viewport.height}px`
|
||
container.style.margin = '0px auto 10px'
|
||
container.style.backgroundColor = '#fff';
|
||
if (!container.dataset.status || container.dataset.status === 'placeholder') {
|
||
container.dataset.status = 'prefetched'
|
||
}
|
||
container.classList.remove('placeholder')
|
||
if (container.dataset.status !== 'rendered') {
|
||
container.classList.add('prefetched')
|
||
}
|
||
|
||
if (this.$refs.pdfWrapper && !this.$refs.pdfWrapper.style.minWidth) {
|
||
this.$refs.pdfWrapper.style.minWidth = `${Math.ceil(viewport.width)}px`
|
||
}
|
||
},
|
||
|
||
async ensurePageCached(pageNumber) {
|
||
if (!this.pdfDoc) return null
|
||
if (this.pageCache.has(pageNumber)) {
|
||
return this.pageCache.get(pageNumber)
|
||
}
|
||
const page = await this.pdfDoc.getPage(pageNumber)
|
||
this.pageCache.set(pageNumber, page)
|
||
return page
|
||
},
|
||
|
||
async renderCanvas(pageNumber) {
|
||
const page = await this.ensurePageCached(pageNumber)
|
||
if (!page) return null
|
||
|
||
const index = pageNumber - 1
|
||
const container = this.pageContainers[index]
|
||
if (!container) return null
|
||
|
||
const viewport = page.getViewport({ scale: this.scale })
|
||
this.ensureContainerDimensions(pageNumber, viewport)
|
||
container.classList.add('is-loading')
|
||
|
||
const oldCanvas = container.querySelector('.pdf-canvas')
|
||
if (oldCanvas) {
|
||
oldCanvas.remove()
|
||
}
|
||
|
||
const canvas = document.createElement('canvas')
|
||
canvas.className = 'pdf-canvas'
|
||
const deviceScale = window.devicePixelRatio || 1
|
||
const outputScale = deviceScale > 1 ? deviceScale : 1
|
||
canvas.width = viewport.width * outputScale
|
||
canvas.height = viewport.height * outputScale
|
||
canvas.style.width = `${viewport.width}px`
|
||
canvas.style.height = `${viewport.height}px`
|
||
container.appendChild(canvas)
|
||
|
||
const canvasContext = canvas.getContext('2d')
|
||
if (canvasContext && 'imageSmoothingEnabled' in canvasContext) {
|
||
canvasContext.imageSmoothingEnabled = false
|
||
}
|
||
const renderContext = {
|
||
canvasContext,
|
||
viewport,
|
||
transform: [outputScale, 0, 0, outputScale, 0, 0],
|
||
intent: 'print',
|
||
enableWebGL: true,
|
||
background: 'rgba(255,255,255,0)',
|
||
}
|
||
canvasContext.setTransform(outputScale, 0, 0, outputScale, 0, 0)
|
||
canvasContext.imageSmoothingEnabled = false
|
||
await page.render(renderContext).promise
|
||
|
||
container.dataset.status = 'rendered'
|
||
container.classList.remove('prefetched')
|
||
|
||
const textLayerDiv = container.querySelector('.textLayer')
|
||
if (textLayerDiv) {
|
||
textLayerDiv.style.display = ''
|
||
}
|
||
|
||
this.renderedPages.set(pageNumber, { container, viewport })
|
||
container.classList.remove('is-loading')
|
||
return { page, viewport, container }
|
||
},
|
||
|
||
async renderTextLayer(pageNumber, { visible = true, force = false } = {}) {
|
||
const page = await this.ensurePageCached(pageNumber)
|
||
if (!page) return
|
||
|
||
const index = pageNumber - 1
|
||
const container = this.pageContainers[index]
|
||
if (!container) return
|
||
|
||
const viewport = page.getViewport({ scale: this.scale })
|
||
this.ensureContainerDimensions(pageNumber, viewport)
|
||
container.classList.add('is-loading-text')
|
||
|
||
const existing = container.querySelector('.textLayer')
|
||
if (existing && !force && this.pageTextDivs[index]?.length) {
|
||
existing.style.display = ''
|
||
container.classList.remove('is-loading-text')
|
||
return
|
||
}
|
||
if (existing) {
|
||
existing.remove()
|
||
}
|
||
|
||
const textLayerDiv = document.createElement('div')
|
||
textLayerDiv.className = 'textLayer'
|
||
textLayerDiv.style.width = `${viewport.width}px`
|
||
textLayerDiv.style.height = `${viewport.height}px`
|
||
textLayerDiv.style.display = ''
|
||
container.appendChild(textLayerDiv)
|
||
const textLayer = new TextLayerBuilder({
|
||
textLayerDiv,
|
||
pageIndex: index,
|
||
viewport,
|
||
eventBus: this.eventBus,
|
||
})
|
||
|
||
try {
|
||
const textContent = await page.getTextContent()
|
||
textLayer.setTextContent(textContent)
|
||
textLayer.render()
|
||
|
||
const textDivs = [...textLayer.textDivs]
|
||
textDivs.forEach((div) => {
|
||
div.dataset.originalText = div.textContent
|
||
})
|
||
this.pageTextDivs[index] = textDivs
|
||
} catch (error) {
|
||
console.warn(`加载第 ${pageNumber} 页文本失败`, error)
|
||
} finally {
|
||
container.classList.remove('is-loading-text')
|
||
}
|
||
|
||
if (this.keyword.trim() && this.pageMatchRanges && this.pageMatchRanges.length) {
|
||
this.applyHighlightsToPage(index)
|
||
}
|
||
|
||
if (container.dataset.status !== 'rendered') {
|
||
container.dataset.status = visible ? 'rendered' : 'text-ready'
|
||
if (!visible) {
|
||
container.classList.add('prefetched')
|
||
}
|
||
}
|
||
},
|
||
|
||
async renderSinglePage(pageNumber) {
|
||
const container = this.pageContainers[pageNumber - 1]
|
||
if (container) {
|
||
container.classList.add('is-loading', 'is-loading-text')
|
||
}
|
||
try {
|
||
await this.renderTextLayer(pageNumber, { visible: true, force: true })
|
||
await this.renderCanvas(pageNumber)
|
||
} finally {
|
||
if (container) {
|
||
container.classList.remove('is-loading', 'is-loading-text')
|
||
}
|
||
}
|
||
},
|
||
|
||
scheduleRender(pageNumber, { priority = false } = {}) {
|
||
if (!pageNumber || pageNumber > this.totalPages || this.renderedPages.has(pageNumber)) return
|
||
if (this.renderQueue.includes(pageNumber)) return
|
||
if (priority) {
|
||
this.renderQueue.unshift(pageNumber)
|
||
} else {
|
||
this.renderQueue.push(pageNumber)
|
||
}
|
||
this.processRenderQueue()
|
||
},
|
||
|
||
async processRenderQueue() {
|
||
if (this.isProcessingQueue) return
|
||
this.isProcessingQueue = true
|
||
try {
|
||
while (this.renderQueue.length) {
|
||
const pageNumber = this.renderQueue.shift()
|
||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||
await this.renderSinglePage(pageNumber)
|
||
}
|
||
} finally {
|
||
this.isProcessingQueue = false
|
||
}
|
||
},
|
||
|
||
schedulePrefetch() {
|
||
this.cancelPrefetch()
|
||
if (!this.totalPages) return
|
||
const startPage = Math.min(this.totalPages, Math.max(1, this.initialPreloadedCount + 1))
|
||
let pageNumber = startPage
|
||
|
||
const fetchNext = async () => {
|
||
this.prefetchScheduled = false
|
||
this.prefetchHandle = null
|
||
if (pageNumber > this.totalPages) {
|
||
this.isPrefetching = false
|
||
return
|
||
}
|
||
if (!this.pageCache.has(pageNumber)) {
|
||
try {
|
||
const page = await this.pdfDoc.getPage(pageNumber)
|
||
this.pageCache.set(pageNumber, page)
|
||
const viewport = page.getViewport({ scale: this.scale })
|
||
this.ensureContainerDimensions(pageNumber, viewport)
|
||
} catch (error) {
|
||
console.warn(`预取第 ${pageNumber} 页失败`, error)
|
||
}
|
||
}
|
||
pageNumber += 1
|
||
if (pageNumber <= this.totalPages) {
|
||
const schedule = () => {
|
||
this.prefetchScheduled = true
|
||
if (typeof window.requestIdleCallback === 'function') {
|
||
this.prefetchHandle = window.requestIdleCallback(fetchNext, { timeout: 1000 })
|
||
} else {
|
||
this.prefetchHandle = window.setTimeout(fetchNext, 80)
|
||
}
|
||
}
|
||
schedule()
|
||
} else {
|
||
this.isPrefetching = false
|
||
}
|
||
}
|
||
|
||
this.isPrefetching = true
|
||
if (typeof window.requestIdleCallback === 'function') {
|
||
this.prefetchScheduled = true
|
||
this.prefetchHandle = window.requestIdleCallback(fetchNext, { timeout: 800 })
|
||
} else {
|
||
this.prefetchScheduled = true
|
||
this.prefetchHandle = window.setTimeout(fetchNext, 120)
|
||
}
|
||
},
|
||
|
||
cancelPrefetch() {
|
||
if (this.prefetchHandle) {
|
||
if (typeof window.cancelIdleCallback === 'function' && this.prefetchScheduled) {
|
||
window.cancelIdleCallback(this.prefetchHandle)
|
||
} else {
|
||
clearTimeout(this.prefetchHandle)
|
||
}
|
||
}
|
||
this.prefetchHandle = null
|
||
this.prefetchScheduled = false
|
||
this.isPrefetching = false
|
||
},
|
||
|
||
async preloadInitialPages() {
|
||
const preloadCount = Math.min(3, this.totalPages)
|
||
if (!preloadCount) {
|
||
this.initialPreloadedCount = 0
|
||
return
|
||
}
|
||
|
||
for (let pageNumber = 1; pageNumber <= preloadCount; pageNumber += 1) {
|
||
await this.renderTextLayer(pageNumber, { visible: pageNumber === 1 })
|
||
}
|
||
|
||
if (preloadCount >= 1) {
|
||
await this.renderCanvas(1)
|
||
}
|
||
|
||
this.initialPreloadedCount = preloadCount
|
||
},
|
||
|
||
unloadPage(pageNumber) {
|
||
if (!pageNumber) return
|
||
const index = pageNumber - 1
|
||
const container = this.pageContainers[index]
|
||
if (!container || container.dataset.status !== 'rendered') return
|
||
|
||
const canvas = container.querySelector('.pdf-canvas')
|
||
if (canvas) {
|
||
canvas.remove()
|
||
}
|
||
const textLayer = container.querySelector('.textLayer')
|
||
if (textLayer) {
|
||
textLayer.style.display = ''
|
||
}
|
||
|
||
container.classList.remove('is-loading')
|
||
this.renderedPages.delete(pageNumber)
|
||
container.dataset.status = this.pageTextDivs[index]?.length ? 'text-ready' : 'prefetched'
|
||
container.classList.add('prefetched')
|
||
},
|
||
|
||
handleSearch: debounce(async function () {
|
||
const keyword = this.keyword.trim()
|
||
if (!keyword) {
|
||
this.resetSearch()
|
||
return
|
||
}
|
||
if (!this.pdfDoc) return
|
||
|
||
// if (this.searching) return
|
||
this.searching = true
|
||
await this.$nextTick()
|
||
try {
|
||
const allPrepared = this.pageTextDivs.length === this.totalPages && this.pageTextDivs.every(items => items && items.length)
|
||
if (!allPrepared) {
|
||
try {
|
||
await this.ensureAllTextLayersLoaded()
|
||
} catch (error) {
|
||
console.error('全文预处理失败', error)
|
||
}
|
||
}
|
||
|
||
|
||
await this.highlightMatches()
|
||
} finally {
|
||
this.searching = false
|
||
}
|
||
}, 200),
|
||
|
||
async ensureAllTextLayersLoaded() {
|
||
if (!this.pdfDoc || !this.totalPages) return
|
||
let total = this.totalPages
|
||
for (let pageNumber = 1; pageNumber <= total; pageNumber += 1) {
|
||
if (this.totalPages !== total) break
|
||
if (!this.pageTextDivs[pageNumber - 1] || !this.pageTextDivs[pageNumber - 1].length) {
|
||
await this.renderTextLayer(pageNumber, { visible: pageNumber === 1, force: true })
|
||
await this.$nextTick()
|
||
}
|
||
}
|
||
},
|
||
|
||
async highlightMatches(options = {}) {
|
||
const { preserveIndex = false, skipNavigate = false } = options
|
||
const keyword = this.keyword.trim()
|
||
if (!keyword) {
|
||
this.resetSearch()
|
||
return
|
||
}
|
||
|
||
const previousIndex = preserveIndex ? this.currentResultIndex : 0
|
||
|
||
this.clearHighlights()
|
||
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
const pattern = new RegExp(`(${escapedKeyword})`, 'gi')
|
||
const results = []
|
||
const matchRanges = Array.from({ length: this.totalPages || 0 }, () => [])
|
||
|
||
for (let pageIndex = 0; pageIndex < this.totalPages; pageIndex += 1) {
|
||
let textDivs = this.pageTextDivs[pageIndex]
|
||
if (!textDivs || !textDivs.length) {
|
||
const container = this.pageContainers[pageIndex]
|
||
const isRendered = container?.dataset.status === 'rendered'
|
||
await this.renderTextLayer(pageIndex + 1, { visible: !!isRendered, force: true })
|
||
await this.$nextTick()
|
||
textDivs = this.pageTextDivs[pageIndex]
|
||
}
|
||
if (!textDivs || !textDivs.length) {
|
||
continue
|
||
}
|
||
|
||
const segments = []
|
||
let position = 0
|
||
textDivs.forEach((div, divIndex) => {
|
||
const original = div.dataset.originalText || div.textContent || ''
|
||
segments.push({
|
||
div,
|
||
divIndex,
|
||
text: original,
|
||
start: position,
|
||
end: position + original.length,
|
||
})
|
||
position += original.length
|
||
})
|
||
|
||
if (!segments.length) {
|
||
continue
|
||
}
|
||
|
||
const pageText = segments.map((seg) => seg.text).join('')
|
||
if (!pageText) {
|
||
continue
|
||
}
|
||
|
||
pattern.lastIndex = 0
|
||
const perDivHighlights = new Map()
|
||
const matchRecords = []
|
||
|
||
let match
|
||
while ((match = pattern.exec(pageText)) !== null) {
|
||
if (!match[0]) continue
|
||
const start = match.index
|
||
const end = start + match[0].length
|
||
const matchIndex = matchRecords.length
|
||
matchRecords.push({
|
||
pageIndex,
|
||
start,
|
||
end,
|
||
elements: [],
|
||
segments: [],
|
||
})
|
||
|
||
let segIndex = segments.findIndex((seg) => start < seg.end && end > seg.start)
|
||
if (segIndex === -1) continue
|
||
|
||
let currentStart = start
|
||
while (segIndex < segments.length && currentStart < end) {
|
||
const seg = segments[segIndex]
|
||
const highlightStart = Math.max(0, currentStart - seg.start)
|
||
const highlightEnd = Math.min(seg.text.length, end - seg.start)
|
||
if (highlightEnd > highlightStart) {
|
||
const ranges = perDivHighlights.get(seg.div) || []
|
||
ranges.push({
|
||
start: highlightStart,
|
||
end: highlightEnd,
|
||
matchIndex,
|
||
})
|
||
perDivHighlights.set(seg.div, ranges)
|
||
matchRecords[matchIndex].segments.push({
|
||
divIndex: seg.divIndex,
|
||
start: highlightStart,
|
||
end: highlightEnd,
|
||
})
|
||
}
|
||
if (end <= seg.end) break
|
||
currentStart = seg.end
|
||
segIndex += 1
|
||
}
|
||
}
|
||
|
||
if (!matchRecords.length) {
|
||
continue
|
||
}
|
||
|
||
perDivHighlights.forEach((ranges, div) => {
|
||
const original = div.dataset.originalText || div.textContent || ''
|
||
if (!original) return
|
||
|
||
const sorted = ranges
|
||
.slice()
|
||
.sort((a, b) => (a.start === b.start ? a.end - b.end : a.start - b.start))
|
||
|
||
let cursor = 0
|
||
let html = ''
|
||
sorted.forEach(({ start, end, matchIndex }) => {
|
||
if (start > cursor) {
|
||
html += this.escapeForHtml(original.slice(cursor, start))
|
||
}
|
||
const text = original.slice(start, end)
|
||
html += `<mark class="search-highlight" data-match-index="${matchIndex}">${this.escapeForHtml(text)}</mark>`
|
||
cursor = end
|
||
})
|
||
if (cursor < original.length) {
|
||
html += this.escapeForHtml(original.slice(cursor))
|
||
}
|
||
div.innerHTML = html
|
||
})
|
||
|
||
perDivHighlights.forEach((_ranges, div) => {
|
||
const marks = div.querySelectorAll('mark.search-highlight')
|
||
marks.forEach((mark) => {
|
||
const matchIndex = Number(mark.dataset.matchIndex)
|
||
if (!Number.isNaN(matchIndex) && matchRecords[matchIndex]) {
|
||
matchRecords[matchIndex].elements.push(mark)
|
||
mark.dataset.pageIndex = String(pageIndex)
|
||
}
|
||
})
|
||
})
|
||
|
||
const pageMatches = matchRanges[pageIndex]
|
||
matchRecords.forEach((record) => {
|
||
if (!record.elements.length) return
|
||
const newIndex = results.length
|
||
record.elements.forEach((mark) => {
|
||
mark.dataset.matchIndex = String(newIndex)
|
||
})
|
||
results.push({
|
||
pageIndex,
|
||
element: record.elements[0],
|
||
elements: record.elements,
|
||
})
|
||
pageMatches.push({
|
||
matchIndex: newIndex,
|
||
segments: record.segments.map((item) => ({ ...item })),
|
||
})
|
||
})
|
||
}
|
||
|
||
this.pageMatchRanges = matchRanges
|
||
this.searchResults = results
|
||
if (!results.length) {
|
||
this.currentResultIndex = -1
|
||
this.$message.info(`未找到“${keyword}”相关内容`)
|
||
return
|
||
}
|
||
|
||
if (preserveIndex && previousIndex < results.length && previousIndex >= 0) {
|
||
this.currentResultIndex = previousIndex
|
||
} else {
|
||
this.currentResultIndex = 0
|
||
}
|
||
|
||
if (!skipNavigate) {
|
||
await this.navigateToResult(this.currentResultIndex, true, false)
|
||
} else {
|
||
this.scheduleActiveHighlightRefresh()
|
||
}
|
||
},
|
||
|
||
focusCurrentResult() {
|
||
this.navigateToResult(this.currentResultIndex)
|
||
},
|
||
|
||
async navigateToResult(index, ensureRendered = false, useSmoothScroll = true) {
|
||
if (!this.searchResults.length || index < 0 || index >= this.searchResults.length) return
|
||
const currentResult = this.searchResults[index]
|
||
const pageNumber = currentResult.pageIndex + 1
|
||
|
||
if (ensureRendered || !this.pageTextDivs[pageNumber - 1] || !this.pageTextDivs[pageNumber - 1].length) {
|
||
await this.renderTextLayer(pageNumber, { visible: true, force: true })
|
||
await this.renderCanvas(pageNumber)
|
||
await this.$nextTick()
|
||
this.applyHighlightsToPage(pageNumber - 1)
|
||
this.scheduleActiveHighlightRefresh()
|
||
}
|
||
|
||
this.currentResultIndex = index
|
||
this.scheduleActiveHighlightRefresh()
|
||
|
||
const performScroll = () => {
|
||
const target = this.searchResults[this.currentResultIndex]?.element
|
||
if (!target) return
|
||
const wrapper = this.$refs.pdfWrapper
|
||
if (!wrapper) return
|
||
|
||
if (!useSmoothScroll) {
|
||
// 直接跳转到匹配位置,不做平滑滚动动画
|
||
try {
|
||
target.scrollIntoView({
|
||
behavior: 'auto',
|
||
block: 'center',
|
||
inline: 'nearest',
|
||
})
|
||
} catch (e) {
|
||
// 兼容性兜底
|
||
const container = target.closest('.pdf-page') || target
|
||
if (!container) return
|
||
const wrapperOffsetTop = container.offsetTop
|
||
const containerHeight = container.offsetHeight || target.offsetHeight || 0
|
||
const desired = wrapperOffsetTop - Math.max((wrapper.clientHeight - containerHeight) / 2, 0)
|
||
this.cancelScrollAnimation()
|
||
wrapper.scrollTop = desired
|
||
}
|
||
return
|
||
}
|
||
|
||
const container = target.closest('.pdf-page') || target
|
||
if (!container) return
|
||
const wrapperOffsetTop = container.offsetTop
|
||
const containerHeight = container.offsetHeight || target.offsetHeight || 0
|
||
const desired = wrapperOffsetTop - Math.max((wrapper.clientHeight - containerHeight) / 2, 0)
|
||
this.smoothScrollTo(wrapper, desired)
|
||
}
|
||
|
||
if (useSmoothScroll) {
|
||
this.$nextTick(() => performScroll())
|
||
} else {
|
||
performScroll()
|
||
}
|
||
},
|
||
|
||
applyHighlightsToPage(pageIndex) {
|
||
if (!this.keyword || !this.pageMatchRanges || !this.pageMatchRanges.length) return
|
||
const textDivs = this.pageTextDivs[pageIndex]
|
||
if (!textDivs || !textDivs.length) return
|
||
|
||
const pageMatches = this.pageMatchRanges[pageIndex]
|
||
if (!pageMatches || !pageMatches.length) return
|
||
|
||
textDivs.forEach((div) => {
|
||
const original = div.dataset.originalText
|
||
if (typeof original !== 'undefined') {
|
||
div.textContent = original
|
||
}
|
||
})
|
||
|
||
const perDivRanges = new Map()
|
||
pageMatches.forEach((match) => {
|
||
if (!match || !Array.isArray(match.segments)) return
|
||
match.segments.forEach((segment) => {
|
||
const { divIndex, start, end } = segment || {}
|
||
if (typeof divIndex !== 'number') return
|
||
const ranges = perDivRanges.get(divIndex) || []
|
||
ranges.push({
|
||
start,
|
||
end,
|
||
matchIndex: match.matchIndex,
|
||
})
|
||
perDivRanges.set(divIndex, ranges)
|
||
})
|
||
})
|
||
|
||
const updatedElements = new Map()
|
||
|
||
perDivRanges.forEach((ranges, divIndex) => {
|
||
const div = textDivs[divIndex]
|
||
if (!div) return
|
||
const original = div.dataset.originalText || ''
|
||
if (!original) return
|
||
|
||
const sorted = ranges
|
||
.slice()
|
||
.filter((item) => typeof item.start === 'number' && typeof item.end === 'number')
|
||
.sort((a, b) => (a.start === b.start ? a.end - b.end : a.start - b.start))
|
||
|
||
let cursor = 0
|
||
let html = ''
|
||
sorted.forEach(({ start, end, matchIndex }) => {
|
||
if (start > cursor) {
|
||
html += this.escapeForHtml(original.slice(cursor, start))
|
||
}
|
||
const text = original.slice(start, end)
|
||
html += `<mark class="search-highlight" data-match-index="${matchIndex}">${this.escapeForHtml(text)}</mark>`
|
||
cursor = end
|
||
})
|
||
if (cursor < original.length) {
|
||
html += this.escapeForHtml(original.slice(cursor))
|
||
}
|
||
div.innerHTML = html
|
||
|
||
const marks = div.querySelectorAll('mark.search-highlight')
|
||
marks.forEach((mark) => {
|
||
const matchIndex = Number(mark.dataset.matchIndex)
|
||
if (Number.isNaN(matchIndex)) return
|
||
const elements = updatedElements.get(matchIndex) || []
|
||
elements.push(mark)
|
||
updatedElements.set(matchIndex, elements)
|
||
})
|
||
})
|
||
|
||
updatedElements.forEach((elements, matchIndex) => {
|
||
if (!this.searchResults[matchIndex]) return
|
||
this.searchResults[matchIndex].elements = elements
|
||
this.searchResults[matchIndex].element = elements[0] || null
|
||
})
|
||
|
||
this.scheduleActiveHighlightRefresh()
|
||
},
|
||
|
||
updateCurrentResultActiveState() {
|
||
const activePageContainers = new Set()
|
||
|
||
this.searchResults.forEach((item, idx) => {
|
||
if (!item) return
|
||
const elements = item.elements && item.elements.length ? item.elements : item.element ? [item.element] : []
|
||
elements.forEach((el) => {
|
||
if (!el) return
|
||
if (idx === this.currentResultIndex) {
|
||
el.classList.add('is-active', 'is-current')
|
||
const container = el.closest('.pdf-page')
|
||
if (container) {
|
||
activePageContainers.add(container)
|
||
}
|
||
} else {
|
||
el.classList.remove('is-active', 'is-current')
|
||
}
|
||
})
|
||
})
|
||
|
||
this.pageContainers.forEach((container) => {
|
||
if (!container) return
|
||
container.classList.remove('has-active-match')
|
||
})
|
||
activePageContainers.forEach((container) => {
|
||
container.classList.add('has-active-match')
|
||
})
|
||
},
|
||
|
||
scheduleActiveHighlightRefresh() {
|
||
this.$nextTick(() => {
|
||
if (typeof window !== 'undefined' && window.requestAnimationFrame) {
|
||
window.requestAnimationFrame(() => {
|
||
this.updateCurrentResultActiveState()
|
||
})
|
||
} else {
|
||
this.updateCurrentResultActiveState()
|
||
}
|
||
})
|
||
},
|
||
|
||
cancelScrollAnimation() {
|
||
if (this.scrollAnimationFrame !== null && typeof window !== 'undefined') {
|
||
cancelAnimationFrame(this.scrollAnimationFrame)
|
||
this.scrollAnimationFrame = null
|
||
}
|
||
},
|
||
|
||
smoothScrollTo(container, target, baseDuration = 240) {
|
||
if (!container) return
|
||
const maxScroll = container.scrollHeight - container.clientHeight
|
||
const finalTarget = Math.min(Math.max(target, 0), Math.max(maxScroll, 0))
|
||
const start = container.scrollTop
|
||
const change = finalTarget - start
|
||
if (Math.abs(change) < 1) {
|
||
container.scrollTop = finalTarget
|
||
return
|
||
}
|
||
|
||
const distanceFactor = Math.min(Math.abs(change) / Math.max(container.clientHeight, 1), 2.4)
|
||
const duration = Math.min(460, baseDuration + distanceFactor * 110)
|
||
|
||
const startTime = performance.now()
|
||
const ease = (t) => 1 - Math.pow(1 - t, 4)
|
||
const velocityBoost = Math.min(Math.max(Math.abs(change) / 2400, 0), 0.25)
|
||
|
||
this.cancelScrollAnimation()
|
||
|
||
const step = (now) => {
|
||
const elapsed = now - startTime
|
||
const progress = Math.min(elapsed / duration, 1)
|
||
const eased = ease(progress)
|
||
const basePosition = start + change * eased
|
||
const overshoot = velocityBoost * Math.sin(eased * Math.PI)
|
||
container.scrollTop = Math.min(Math.max(basePosition + overshoot * change, 0), maxScroll)
|
||
|
||
if (progress < 1) {
|
||
this.scrollAnimationFrame = requestAnimationFrame(step)
|
||
} else {
|
||
this.scrollAnimationFrame = null
|
||
}
|
||
}
|
||
|
||
this.scrollAnimationFrame = requestAnimationFrame(step)
|
||
},
|
||
|
||
async goToPrevious() {
|
||
if (!this.searchResults.length) return
|
||
let targetIndex = this.currentResultIndex
|
||
if (targetIndex === -1) {
|
||
targetIndex = this.searchResults.length - 1
|
||
} else {
|
||
targetIndex -= 1
|
||
if (targetIndex < 0) {
|
||
targetIndex = this.searchResults.length - 1
|
||
}
|
||
}
|
||
await this.navigateToResult(targetIndex, false, false)
|
||
},
|
||
|
||
async goToNext() {
|
||
if (!this.searchResults.length) return
|
||
let targetIndex = this.currentResultIndex
|
||
if (targetIndex === -1) {
|
||
targetIndex = 0
|
||
} else {
|
||
targetIndex += 1
|
||
if (targetIndex > this.searchResults.length - 1) {
|
||
targetIndex = 0
|
||
}
|
||
}
|
||
await this.navigateToResult(targetIndex, false, false)
|
||
},
|
||
|
||
resetSearch() {
|
||
this.keyword = ''
|
||
this.clearHighlights()
|
||
this.searchResults = []
|
||
this.currentResultIndex = -1
|
||
this.pageMatchRanges = []
|
||
},
|
||
|
||
clearHighlights() {
|
||
this.pageTextDivs.forEach((textDivs) => {
|
||
if (!textDivs || !textDivs.length) return
|
||
textDivs.forEach((div) => {
|
||
const original = div.dataset.originalText
|
||
if (typeof original !== 'undefined') {
|
||
div.textContent = original
|
||
}
|
||
})
|
||
})
|
||
},
|
||
|
||
resetViewerState() {
|
||
this.clearHighlights()
|
||
this.searchResults = []
|
||
this.currentResultIndex = -1
|
||
this.pageMatchRanges = []
|
||
this.pageTextDivs = []
|
||
this.renderedPages.clear()
|
||
this.pageContainers = []
|
||
this.totalPages = 0
|
||
this.isDocumentReady = false
|
||
this.disconnectObserver()
|
||
this.cancelPrefetch()
|
||
this.pageCache.clear()
|
||
this.cancelScrollAnimation()
|
||
const wrapper = this.$refs.pdfWrapper
|
||
if (wrapper) {
|
||
wrapper.innerHTML = ''
|
||
wrapper.scrollTop = 0
|
||
}
|
||
},
|
||
|
||
escapeForHtml(text = '') {
|
||
return text
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
},
|
||
|
||
reload() {
|
||
if (!this.pdfUrl) return
|
||
this.loadDocument()
|
||
},
|
||
|
||
setupIntersectionObserver() {
|
||
this.disconnectObserver()
|
||
if (!this.$refs.pdfWrapper) return
|
||
|
||
const options = {
|
||
root: this.$refs.pdfWrapper,
|
||
rootMargin: '120px 0px',
|
||
threshold: 0.01,
|
||
}
|
||
|
||
this.observer = new IntersectionObserver((entries) => {
|
||
entries.forEach((entry) => {
|
||
if (entry.isIntersecting) {
|
||
const page = Number(entry.target.dataset.page)
|
||
if (page) {
|
||
this.renderTextLayer(page, { visible: true, force: true })
|
||
this.scheduleRender(page, { priority: true })
|
||
}
|
||
} else {
|
||
const page = Number(entry.target.dataset.page)
|
||
if (page) {
|
||
this.unloadPage(page)
|
||
}
|
||
}
|
||
})
|
||
}, options)
|
||
|
||
this.pageContainers.forEach((container) => this.observer.observe(container))
|
||
},
|
||
|
||
observeInitialPages() {
|
||
if (!this.pageContainers.length) return
|
||
const initial = this.pageContainers.slice(0, 3)
|
||
initial.forEach((container) => {
|
||
const page = Number(container.dataset.page)
|
||
if (page) {
|
||
if (page === 1) {
|
||
// 已在预处理中完成
|
||
return
|
||
}
|
||
if (page <= this.initialPreloadedCount) {
|
||
this.scheduleRender(page, { priority: true })
|
||
} else {
|
||
this.scheduleRender(page)
|
||
}
|
||
}
|
||
})
|
||
},
|
||
|
||
disconnectObserver() {
|
||
if (this.observer) {
|
||
this.observer.disconnect()
|
||
this.observer = null
|
||
}
|
||
this.renderQueue = []
|
||
this.isProcessingQueue = false
|
||
this.cancelPrefetch()
|
||
},
|
||
},
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.document-search {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: calc(100vh - 84px);
|
||
overflow: hidden;
|
||
background: #f4f7ff;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
color: #1f2430;
|
||
-webkit-font-smoothing: antialiased;
|
||
-moz-osx-font-smoothing: grayscale;
|
||
text-rendering: optimizeLegibility;
|
||
}
|
||
|
||
.floating-search {
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 20px;
|
||
width: auto;
|
||
max-width: calc(100% - 40px);
|
||
z-index: 6;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.floating-search-btn {
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 20px;
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
border: none;
|
||
background: #2b68ff;
|
||
color: #fff;
|
||
font-size: 18px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 10px 26px rgba(31, 114, 234, 0.25);
|
||
cursor: pointer;
|
||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||
z-index: 5;
|
||
}
|
||
|
||
.floating-search-btn:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 12px 30px rgba(31, 114, 234, 0.3);
|
||
}
|
||
|
||
.search-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
background: rgba(255, 255, 255, 0.98);
|
||
border-radius: 999px;
|
||
padding: 8px 12px;
|
||
box-shadow: 0 8px 26px rgba(31, 114, 234, 0.18);
|
||
border: 1px solid rgba(226, 231, 239, 0.8);
|
||
pointer-events: auto;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.search-toolbar::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 4px;
|
||
border-radius: 999px;
|
||
border: 1px solid transparent;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.search-toolbar.is-searching::after {
|
||
border-color: rgba(43, 104, 255, 0.4);
|
||
animation: search-pulse 1.2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes search-pulse {
|
||
0% {
|
||
opacity: 0.25;
|
||
transform: scale(0.98);
|
||
}
|
||
50% {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
100% {
|
||
opacity: 0.25;
|
||
transform: scale(0.98);
|
||
}
|
||
}
|
||
|
||
.slide-fade-enter-active,
|
||
.slide-fade-leave-active {
|
||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||
}
|
||
|
||
.slide-fade-enter,
|
||
.slide-fade-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-6px);
|
||
}
|
||
|
||
.search-box {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
background: #fff;
|
||
border: 1px solid #e4e7f0;
|
||
border-radius: 999px;
|
||
padding: 0 6px 0 10px;
|
||
}
|
||
|
||
.search-input {
|
||
width: 190px;
|
||
}
|
||
|
||
.search-input ::v-deep .el-input__inner {
|
||
border: none;
|
||
box-shadow: none;
|
||
background: transparent;
|
||
padding: 0;
|
||
height: 30px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.search-input ::v-deep .el-input__suffix,
|
||
.search-input ::v-deep .el-input__prefix {
|
||
display: none;
|
||
}
|
||
|
||
.icon-btn {
|
||
width: 28px;
|
||
height: 28px;
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #6c7388;
|
||
cursor: pointer;
|
||
transition: background 0.2s ease, color 0.2s ease;
|
||
}
|
||
|
||
.icon-btn:hover {
|
||
background: rgba(64, 158, 255, 0.12);
|
||
color: #2b68ff;
|
||
}
|
||
|
||
.icon-btn:disabled {
|
||
opacity: 0.35;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.search-icon {
|
||
background: #2b68ff;
|
||
color: #fff;
|
||
}
|
||
|
||
.search-icon:hover {
|
||
background: #1d4fd8;
|
||
color: #fff;
|
||
}
|
||
|
||
.search-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: #4f5875;
|
||
font-size: 12px;
|
||
padding-left: 10px;
|
||
border-left: 1px solid #e4e8f2;
|
||
}
|
||
|
||
.result-indicator {
|
||
font-weight: 600;
|
||
min-width: 48px;
|
||
text-align: center;
|
||
}
|
||
|
||
.viewer-container {
|
||
flex: 1;
|
||
background: #ffffff;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
display: flex;
|
||
min-height: 480px;
|
||
}
|
||
|
||
.pdf-wrapper {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 24px;
|
||
background: #eaeaea;
|
||
position: relative;
|
||
scroll-behavior: smooth;
|
||
overscroll-behavior: contain;
|
||
scrollbar-gutter: stable both-edges;
|
||
-webkit-overflow-scrolling: touch;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
.pdf-page {
|
||
position: relative;
|
||
margin: 0px auto 10px !important;
|
||
box-shadow: 0 10px 30px rgba(25, 64, 158, 0.12);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
background: #ffffff;
|
||
display: block;
|
||
will-change: transform, opacity;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.pdf-page:first-of-type::before,
|
||
.pdf-page:last-of-type::after {
|
||
display: none;
|
||
}
|
||
|
||
.pdf-page::before,
|
||
.pdf-page::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 5%;
|
||
right: 5%;
|
||
height: 12px;
|
||
height: 8px;
|
||
border-radius: 6px;
|
||
background: linear-gradient(180deg, rgba(206, 216, 232, 0.65) 0%, rgba(208, 216, 230, 0.25) 100%);
|
||
box-shadow: 0 2px 6px rgba(102, 125, 160, 0.25);
|
||
}
|
||
|
||
.pdf-page::before {
|
||
top: -20px;
|
||
}
|
||
|
||
.pdf-page::after {
|
||
bottom: -28px;
|
||
}
|
||
|
||
.pdf-page.placeholder {
|
||
box-shadow: none;
|
||
background: linear-gradient(135deg, rgba(233, 239, 255, 0.7), rgba(255, 255, 255, 0.7));
|
||
border: 1px dashed rgba(112, 143, 255, 0.4);
|
||
min-height: 240px;
|
||
color: #7f8bff;
|
||
font-size: 13px;
|
||
letter-spacing: 0.6px;
|
||
}
|
||
|
||
.pdf-page.placeholder::after {
|
||
content: '正在准备页面...';
|
||
}
|
||
|
||
.pdf-page.prefetched {
|
||
background: #fdfdfd;
|
||
box-shadow: inset 0 0 0 1px rgba(112, 143, 255, 0.2);
|
||
position: relative;
|
||
}
|
||
|
||
.pdf-page.prefetched::after {
|
||
content: '滚动到此处以加载内容';
|
||
color: rgba(112, 143, 255, 0.7);
|
||
font-size: 12px;
|
||
letter-spacing: 0.4px;
|
||
}
|
||
|
||
.pdf-canvas {
|
||
width: 100%;
|
||
display: block;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.textLayer {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
pointer-events: auto;
|
||
user-select: text;
|
||
-webkit-user-select: text;
|
||
}
|
||
|
||
.textLayer>span {
|
||
cursor: text;
|
||
user-select: text;
|
||
-webkit-user-select: text;
|
||
}
|
||
|
||
.pdf-page.is-loading::after {
|
||
opacity: 0.5;
|
||
background: rgba(255, 255, 255, 0.7);
|
||
}
|
||
|
||
.pdf-page.is-loading .textLayer,
|
||
.pdf-page.is-loading .pdf-canvas {
|
||
opacity: 0.35;
|
||
}
|
||
|
||
@keyframes pdf-page-spin {
|
||
0% {
|
||
transform: rotate(0deg);
|
||
}
|
||
|
||
100% {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
@keyframes search-glow {
|
||
0%,
|
||
100% {
|
||
box-shadow: 0 0 0 0 rgba(32, 109, 255, 0.45);
|
||
}
|
||
50% {
|
||
box-shadow: 0 0 0 10px rgba(32, 109, 255, 0);
|
||
}
|
||
}
|
||
|
||
@keyframes search-rotate {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
@keyframes search-outline {
|
||
0%,
|
||
100% {
|
||
opacity: 0;
|
||
transform: scale(0.9);
|
||
}
|
||
50% {
|
||
opacity: 1;
|
||
transform: scale(1.05);
|
||
}
|
||
}
|
||
|
||
.pdf-page>canvas.pdf-canvas {
|
||
position: relative;
|
||
top: 0;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.pdf-page>.textLayer {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.search-highlight {
|
||
background: rgba(255, 241, 168, 0.9);
|
||
padding: 0 2px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
::v-deep .search-highlight.is-active,
|
||
::v-deep .search-highlight.is-current {
|
||
background: #206dff !important;
|
||
color: #ffffff;
|
||
box-shadow: 0 0 0 2px rgba(32, 109, 255, 0.35);
|
||
border-radius: 4px;
|
||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||
}
|
||
|
||
.pdf-page.has-active-match {
|
||
box-shadow: 0 0 0 3px rgba(32, 109, 255, 0.45), 0 12px 36px rgba(32, 109, 255, 0.18);
|
||
transition: box-shadow 0.3s ease;
|
||
}
|
||
|
||
::v-deep .textLayer {
|
||
user-select: text;
|
||
pointer-events: auto;
|
||
-webkit-user-select: text;
|
||
-moz-user-select: text;
|
||
}
|
||
|
||
::v-deep .textLayer span,
|
||
::v-deep .textLayer mark {
|
||
user-select: text;
|
||
pointer-events: auto;
|
||
-webkit-user-select: text;
|
||
-moz-user-select: text;
|
||
}
|
||
|
||
.state-panel {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #6072a1;
|
||
gap: 12px;
|
||
font-size: 15px;
|
||
text-align: center;
|
||
}
|
||
|
||
.state-icon {
|
||
font-size: 28px;
|
||
color: #1f72ea;
|
||
}
|
||
|
||
.overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 2;
|
||
background: linear-gradient(180deg, rgba(238, 243, 255, 0.92) 0%, rgba(255, 255, 255, 0.96) 100%);
|
||
}
|
||
|
||
.fade-enter-active,
|
||
.fade-leave-active {
|
||
transition: opacity 0.25s ease;
|
||
}
|
||
|
||
.fade-enter,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
}
|
||
</style> |