1108 lines
37 KiB
Vue
1108 lines
37 KiB
Vue
<template>
|
||
<div class="document-search">
|
||
<div class="search-toolbar">
|
||
<el-input v-model="keyword" class="search-input" placeholder="输入关键字搜索 PDF" clearable
|
||
@keyup.enter.native="handleSearch" @clear="resetSearch">
|
||
<el-button slot="append" icon="el-icon-search" @click="handleSearch" :loading="searching" :disabled="searching">
|
||
搜索
|
||
</el-button>
|
||
</el-input>
|
||
<div class="search-status" v-if="searchResults.length">
|
||
<span class="result-indicator">{{ currentResultIndex + 1 }}/{{ searchResults.length }}</span>
|
||
<el-button class="nav-btn" type="text" icon="el-icon-arrow-up" :disabled="!searchResults.length"
|
||
@click="goToPrevious">
|
||
上一处
|
||
</el-button>
|
||
<el-button class="nav-btn" type="text" icon="el-icon-arrow-down" :disabled="!searchResults.length"
|
||
@click="goToNext">
|
||
下一处
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="viewer-container">
|
||
<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
|
||
|
||
export default {
|
||
name: 'DocumentSearch',
|
||
computed: {
|
||
overlayType() {
|
||
if (this.loading) {
|
||
return 'loading'
|
||
}
|
||
if (this.error) {
|
||
return 'error'
|
||
}
|
||
if (!this.pdfUrl) {
|
||
return 'no-url'
|
||
}
|
||
return null
|
||
},
|
||
},
|
||
data() {
|
||
return {
|
||
pdfUrl: '',
|
||
pdfDoc: null,
|
||
keyword: '',
|
||
loading: false,
|
||
error: null,
|
||
scale: DEFAULT_SCALE,
|
||
eventBus: new EventBus(),
|
||
pageTextDivs: [],
|
||
searchResults: [],
|
||
currentResultIndex: -1,
|
||
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,
|
||
}
|
||
},
|
||
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
|
||
}
|
||
}
|
||
|
||
this.pdfUrl = this.$route.query.url || 'http://192.168.0.14:9090/smart-bid/technicalSolutionDatabase/2025/10/30/fe5b46ea37554516a71e7c0c486d3715.pdf'
|
||
if (this.pdfUrl) {
|
||
this.loadDocument()
|
||
}
|
||
},
|
||
beforeDestroy() {
|
||
this.disconnectObserver()
|
||
},
|
||
watch: {
|
||
'$route.query.url'(newUrl, oldUrl) {
|
||
if (newUrl !== oldUrl) {
|
||
this.pdfUrl = newUrl || ''
|
||
this.resetViewerState()
|
||
if (this.pdfUrl) {
|
||
this.loadDocument()
|
||
}
|
||
}
|
||
},
|
||
},
|
||
methods: {
|
||
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) {
|
||
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,
|
||
}
|
||
if (outputScale !== 1) {
|
||
renderContext.transform = [outputScale, 0, 0, outputScale, 0, 0]
|
||
}
|
||
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 (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 = 'none'
|
||
}
|
||
|
||
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
|
||
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)
|
||
}
|
||
}
|
||
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()
|
||
}
|
||
}
|
||
},
|
||
|
||
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 = []
|
||
|
||
this.pageTextDivs.forEach((textDivs, pageIndex) => {
|
||
if (!textDivs || !textDivs.length) {
|
||
return
|
||
}
|
||
|
||
const segments = []
|
||
let position = 0
|
||
textDivs.forEach((div) => {
|
||
const original = div.dataset.originalText || div.textContent || ''
|
||
segments.push({
|
||
div,
|
||
text: original,
|
||
start: position,
|
||
end: position + original.length,
|
||
})
|
||
position += original.length
|
||
})
|
||
|
||
if (!segments.length) {
|
||
return
|
||
}
|
||
|
||
const pageText = segments.map((seg) => seg.text).join('')
|
||
if (!pageText) {
|
||
return
|
||
}
|
||
|
||
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: [],
|
||
})
|
||
|
||
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)
|
||
}
|
||
if (end <= seg.end) break
|
||
currentStart = seg.end
|
||
segIndex += 1
|
||
}
|
||
}
|
||
|
||
if (!matchRecords.length) {
|
||
return
|
||
}
|
||
|
||
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)
|
||
}
|
||
})
|
||
})
|
||
|
||
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,
|
||
})
|
||
})
|
||
})
|
||
|
||
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) {
|
||
this.navigateToResult(this.currentResultIndex, false)
|
||
}
|
||
},
|
||
|
||
focusCurrentResult() {
|
||
this.navigateToResult(this.currentResultIndex)
|
||
},
|
||
|
||
async navigateToResult(index, ensureRendered = false) {
|
||
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.highlightMatches({ preserveIndex: true, skipNavigate: true })
|
||
}
|
||
|
||
this.currentResultIndex = index
|
||
this.searchResults.forEach((item, idx) => {
|
||
if (!item) return
|
||
const elements = item.elements && item.elements.length ? item.elements : [item.element]
|
||
elements.forEach((el) => {
|
||
if (!el) return
|
||
if (idx === this.currentResultIndex) {
|
||
el.classList.add('is-active')
|
||
} else {
|
||
el.classList.remove('is-active')
|
||
}
|
||
})
|
||
})
|
||
|
||
const target = this.searchResults[this.currentResultIndex]?.element
|
||
if (!target) return
|
||
const wrapper = this.$refs.pdfWrapper
|
||
if (!wrapper) return
|
||
|
||
this.$nextTick(() => {
|
||
const targetRect = target.getBoundingClientRect()
|
||
const wrapperRect = wrapper.getBoundingClientRect()
|
||
const offset = targetRect.top - wrapperRect.top - wrapper.clientHeight / 2
|
||
wrapper.scrollTo({
|
||
top: wrapper.scrollTop + offset,
|
||
behavior: 'smooth'
|
||
})
|
||
})
|
||
},
|
||
|
||
goToPrevious() {
|
||
if (!this.searchResults.length) return
|
||
const idx = (this.currentResultIndex - 1 + this.searchResults.length) % this.searchResults.length
|
||
this.navigateToResult(idx)
|
||
},
|
||
|
||
goToNext() {
|
||
if (!this.searchResults.length) return
|
||
const idx = (this.currentResultIndex + 1) % this.searchResults.length
|
||
this.navigateToResult(idx)
|
||
},
|
||
|
||
resetSearch() {
|
||
this.keyword = ''
|
||
this.clearHighlights()
|
||
this.searchResults = []
|
||
this.currentResultIndex = -1
|
||
},
|
||
|
||
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.pageTextDivs = []
|
||
this.renderedPages.clear()
|
||
this.pageContainers = []
|
||
this.totalPages = 0
|
||
this.isDocumentReady = false
|
||
this.disconnectObserver()
|
||
this.cancelPrefetch()
|
||
this.pageCache.clear()
|
||
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: 16px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.search-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.search-input {
|
||
flex: 1;
|
||
min-width: 240px;
|
||
}
|
||
|
||
.search-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: #506dff;
|
||
}
|
||
|
||
.result-indicator {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.nav-btn {
|
||
padding: 0 8px;
|
||
}
|
||
|
||
.search-preparing {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: #506dff;
|
||
font-size: 14px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.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: linear-gradient(180deg, #eef3ff 0%, #ffffff 100%);
|
||
background: #eaeaea;
|
||
position: relative;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.search-highlight.is-active {
|
||
background: rgba(32, 109, 255, 0.85);
|
||
color: #ffffff;
|
||
}
|
||
|
||
.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> |