adtec-core-package 3.1.6 → 3.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adtec-core-package/adtec-core-package.css +1 -0
- package/adtec-core-package/adtec-core-package.js +41216 -0
- package/adtec-core-package/adtec-core-package.umd.cjs +87 -0
- package/adtec-core-package/favicon.ico +0 -0
- package/package.json +4 -2
- package/src/api/DocumentApi.ts +11 -1
- package/src/components/upload/DocxJsViewer.vue +286 -0
- package/src/components/upload/ExcelJsViewer.vue +270 -0
- package/src/components/upload/FileView.vue +102 -159
- package/src/components/upload/OfficePreview.vue +479 -0
- package/src/components/upload/OfficePreviewHeaderBar.vue +315 -0
- package/src/components/upload/OfficePreviewToolbar.vue +203 -0
- package/src/components/upload/PdfJsViewer.vue +920 -0
- package/src/utils/docxPreviewUtil.ts +108 -0
- package/src/utils/excelPreviewUtil.ts +101 -0
- package/src/utils/officePreviewUtil.ts +122 -0
- package/src/utils/pdfSearchUtil.ts +127 -0
- package/src/utils/toSameOriginFileUrl.ts +1 -1
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div ref="scrollRef" class="pdf-js-viewer" @scroll="handleScroll">
|
|
3
|
+
<div v-if="showPlaceholder" class="pdf-placeholder">
|
|
4
|
+
<div class="pdf-placeholder-card">
|
|
5
|
+
<span class="pdf-placeholder-spinner" />
|
|
6
|
+
<p class="pdf-placeholder-title">{{ placeholderText }}</p>
|
|
7
|
+
<p v-if="loadingPage > 0 && totalPages > 0" class="pdf-placeholder-sub">
|
|
8
|
+
正在渲染第 {{ loadingPage }} / {{ totalPages }} 页
|
|
9
|
+
</p>
|
|
10
|
+
<div class="pdf-placeholder-skeleton">
|
|
11
|
+
<span />
|
|
12
|
+
<span />
|
|
13
|
+
<span />
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
<div
|
|
18
|
+
v-show="totalPages > 0"
|
|
19
|
+
class="pdf-pages"
|
|
20
|
+
:style="{ gap: `${PAGE_GAP}px` }"
|
|
21
|
+
>
|
|
22
|
+
<div
|
|
23
|
+
v-for="n in totalPages"
|
|
24
|
+
:key="n"
|
|
25
|
+
:ref="(el) => setPageRef(n, el as HTMLElement | null)"
|
|
26
|
+
class="pdf-page-slot"
|
|
27
|
+
:data-page="n"
|
|
28
|
+
:style="{ minHeight: `${pageHeights[n - 1] || 0}px` }"
|
|
29
|
+
/>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<script setup lang="ts">
|
|
35
|
+
import {
|
|
36
|
+
computed,
|
|
37
|
+
nextTick,
|
|
38
|
+
onBeforeUnmount,
|
|
39
|
+
onMounted,
|
|
40
|
+
ref,
|
|
41
|
+
watch,
|
|
42
|
+
} from 'vue'
|
|
43
|
+
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'
|
|
44
|
+
import pdfWorker from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url'
|
|
45
|
+
import { searchPdfDocument, type PdfSearchMatch } from '../../utils/pdfSearchUtil'
|
|
46
|
+
import { computePdfDefaultFitScale } from '../../utils/officePreviewUtil.ts'
|
|
47
|
+
|
|
48
|
+
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
|
|
49
|
+
|
|
50
|
+
const PAGE_GAP = 12
|
|
51
|
+
const VISIBLE_BUFFER = 2
|
|
52
|
+
const HORIZONTAL_PADDING = 24
|
|
53
|
+
const FIT_WIDTH_RETRY = 20
|
|
54
|
+
const FIT_WIDTH_STABLE_FRAMES = 2
|
|
55
|
+
const FIT_WIDTH_CHANGE_THRESHOLD = 0.06
|
|
56
|
+
|
|
57
|
+
const props = defineProps<{
|
|
58
|
+
src?: Blob | ArrayBuffer
|
|
59
|
+
scale?: number
|
|
60
|
+
}>()
|
|
61
|
+
|
|
62
|
+
const emit = defineEmits<{
|
|
63
|
+
loaded: []
|
|
64
|
+
error: [err?: unknown]
|
|
65
|
+
'page-change': [page: number]
|
|
66
|
+
}>()
|
|
67
|
+
|
|
68
|
+
const scrollRef = ref<HTMLElement>()
|
|
69
|
+
const loading = ref(false)
|
|
70
|
+
const isPending = ref(false)
|
|
71
|
+
const loadingPage = ref(0)
|
|
72
|
+
const totalPages = ref(0)
|
|
73
|
+
const currentPage = ref(1)
|
|
74
|
+
const fitScale = ref(1)
|
|
75
|
+
const pageHeights = ref<number[]>([])
|
|
76
|
+
const pageElements = new Map<number, HTMLElement>()
|
|
77
|
+
const renderedScales = new Map<number, number>()
|
|
78
|
+
|
|
79
|
+
let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null
|
|
80
|
+
let loadToken = 0
|
|
81
|
+
let activeTask: { destroy: () => Promise<void> } | null = null
|
|
82
|
+
let observer: IntersectionObserver | null = null
|
|
83
|
+
let visibilityObserver: IntersectionObserver | null = null
|
|
84
|
+
let scaleRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
|
85
|
+
let pendingSrc: Blob | ArrayBuffer | undefined
|
|
86
|
+
let fitInitialized = false
|
|
87
|
+
let lastFitContainerWidth = 0
|
|
88
|
+
let hasEmittedLoaded = false
|
|
89
|
+
const renderingPages = new Set<number>()
|
|
90
|
+
const searchMatches = ref<PdfSearchMatch[]>([])
|
|
91
|
+
const activeMatchIndex = ref(-1)
|
|
92
|
+
const highlightPage = ref(0)
|
|
93
|
+
const lastSearchQuery = ref('')
|
|
94
|
+
|
|
95
|
+
const userScale = computed(() => props.scale ?? 1)
|
|
96
|
+
const renderScale = computed(() => fitScale.value * userScale.value)
|
|
97
|
+
|
|
98
|
+
const showPlaceholder = computed(
|
|
99
|
+
() =>
|
|
100
|
+
!!props.src &&
|
|
101
|
+
(isPending.value || loading.value || totalPages.value === 0),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const placeholderText = computed(() => {
|
|
105
|
+
if (isPending.value) return '等待预览区域就绪…'
|
|
106
|
+
if (totalPages.value > 0) return '正在渲染页面…'
|
|
107
|
+
return '正在解析 PDF 文档…'
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const setPageRef = (pageNum: number, el: HTMLElement | null) => {
|
|
111
|
+
const prev = pageElements.get(pageNum)
|
|
112
|
+
if (el) {
|
|
113
|
+
if (prev === el) return
|
|
114
|
+
pageElements.set(pageNum, el)
|
|
115
|
+
} else if (prev) {
|
|
116
|
+
pageElements.delete(pageNum)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const isIgnorablePdfError = (error: unknown) => {
|
|
121
|
+
const err = error as { name?: string; message?: string }
|
|
122
|
+
const name = err?.name || ''
|
|
123
|
+
const message = String(err?.message || '')
|
|
124
|
+
return (
|
|
125
|
+
name === 'RenderingCancelledException' ||
|
|
126
|
+
name === 'AbortException' ||
|
|
127
|
+
/abort|cancel|destroy/i.test(message)
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const destroyActiveTask = async () => {
|
|
132
|
+
if (!activeTask) return
|
|
133
|
+
const task = activeTask
|
|
134
|
+
activeTask = null
|
|
135
|
+
try {
|
|
136
|
+
await task.destroy()
|
|
137
|
+
} catch {
|
|
138
|
+
// ignore cancellation errors
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const resetViewer = () => {
|
|
143
|
+
totalPages.value = 0
|
|
144
|
+
currentPage.value = 1
|
|
145
|
+
pageHeights.value = []
|
|
146
|
+
fitScale.value = 1
|
|
147
|
+
fitInitialized = false
|
|
148
|
+
lastFitContainerWidth = 0
|
|
149
|
+
loadingPage.value = 0
|
|
150
|
+
hasEmittedLoaded = false
|
|
151
|
+
renderedScales.clear()
|
|
152
|
+
renderingPages.clear()
|
|
153
|
+
pageElements.clear()
|
|
154
|
+
searchMatches.value = []
|
|
155
|
+
activeMatchIndex.value = -1
|
|
156
|
+
highlightPage.value = 0
|
|
157
|
+
lastSearchQuery.value = ''
|
|
158
|
+
isPending.value = false
|
|
159
|
+
if (scrollRef.value) {
|
|
160
|
+
scrollRef.value.scrollTop = 0
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const getPreviewRoot = () => {
|
|
165
|
+
const el = scrollRef.value
|
|
166
|
+
if (!el) return null
|
|
167
|
+
return (el.closest('.office-preview-root') as HTMLElement | null) ?? el
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const isPreviewVisible = () => {
|
|
171
|
+
const target = getPreviewRoot()
|
|
172
|
+
if (!target) return false
|
|
173
|
+
const rect = target.getBoundingClientRect()
|
|
174
|
+
return rect.width > 100 && rect.height > 80
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const getContainerWidth = () => {
|
|
178
|
+
if (!isPreviewVisible()) return 0
|
|
179
|
+
|
|
180
|
+
const stableParent = getPreviewRoot()
|
|
181
|
+
if (!stableParent) return 0
|
|
182
|
+
|
|
183
|
+
let width = stableParent.clientWidth
|
|
184
|
+
if (width <= 0) {
|
|
185
|
+
width = stableParent.getBoundingClientRect().width
|
|
186
|
+
}
|
|
187
|
+
return width > 100 ? width - HORIZONTAL_PADDING : 0
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const waitForStableContainerWidth = async () => {
|
|
191
|
+
let lastWidth = 0
|
|
192
|
+
let stableCount = 0
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < FIT_WIDTH_RETRY; i++) {
|
|
195
|
+
await nextTick()
|
|
196
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
|
197
|
+
if (!isPreviewVisible()) {
|
|
198
|
+
stableCount = 0
|
|
199
|
+
lastWidth = 0
|
|
200
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 50))
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
const width = getContainerWidth()
|
|
204
|
+
if (width > 100) {
|
|
205
|
+
if (Math.abs(width - lastWidth) <= 1) {
|
|
206
|
+
stableCount += 1
|
|
207
|
+
if (stableCount >= FIT_WIDTH_STABLE_FRAMES) {
|
|
208
|
+
return width
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
stableCount = 0
|
|
212
|
+
}
|
|
213
|
+
lastWidth = width
|
|
214
|
+
}
|
|
215
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 50))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return getContainerWidth()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const shouldUpdateFitScale = (width: number, force = false) => {
|
|
222
|
+
if (force || !fitInitialized) return true
|
|
223
|
+
if (!lastFitContainerWidth) return true
|
|
224
|
+
const delta = Math.abs(width - lastFitContainerWidth) / lastFitContainerWidth
|
|
225
|
+
return delta >= FIT_WIDTH_CHANGE_THRESHOLD
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const computeFitScale = async (doc: pdfjsLib.PDFDocumentProxy) => {
|
|
229
|
+
const page = await doc.getPage(1)
|
|
230
|
+
const viewport = page.getViewport({ scale: 1 })
|
|
231
|
+
const width = await waitForStableContainerWidth()
|
|
232
|
+
if (!width || !viewport.width) return 0
|
|
233
|
+
const nextFit = computePdfDefaultFitScale(viewport.width, width)
|
|
234
|
+
lastFitContainerWidth = width
|
|
235
|
+
fitInitialized = true
|
|
236
|
+
return nextFit
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const refreshFitScale = async (force = false) => {
|
|
240
|
+
if (!pdfDoc) return false
|
|
241
|
+
if (!force && userScale.value !== 1) return false
|
|
242
|
+
if (!isPreviewVisible()) return false
|
|
243
|
+
const page = await pdfDoc.getPage(1)
|
|
244
|
+
const viewport = page.getViewport({ scale: 1 })
|
|
245
|
+
const width = getContainerWidth()
|
|
246
|
+
if (!width || !viewport.width) return false
|
|
247
|
+
if (!shouldUpdateFitScale(width, force)) return false
|
|
248
|
+
const nextFit = computePdfDefaultFitScale(viewport.width, width)
|
|
249
|
+
if (Math.abs(nextFit - fitScale.value) < 0.01) {
|
|
250
|
+
lastFitContainerWidth = width
|
|
251
|
+
fitInitialized = true
|
|
252
|
+
return false
|
|
253
|
+
}
|
|
254
|
+
fitScale.value = nextFit
|
|
255
|
+
lastFitContainerWidth = width
|
|
256
|
+
fitInitialized = true
|
|
257
|
+
return true
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const refitLayout = async () => {
|
|
261
|
+
if (!pdfDoc) return
|
|
262
|
+
const changed = await refreshFitScale(true)
|
|
263
|
+
if (changed) {
|
|
264
|
+
scheduleScaleRefresh()
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const updatePageHeights = async (doc: pdfjsLib.PDFDocumentProxy, scale: number) => {
|
|
269
|
+
const heights: number[] = []
|
|
270
|
+
for (let i = 1; i <= doc.numPages; i++) {
|
|
271
|
+
const page = await doc.getPage(i)
|
|
272
|
+
const viewport = page.getViewport({ scale })
|
|
273
|
+
heights.push(Math.ceil(viewport.height))
|
|
274
|
+
}
|
|
275
|
+
pageHeights.value = heights
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const clearPageCanvas = (pageNum: number) => {
|
|
279
|
+
const slot = pageElements.get(pageNum)
|
|
280
|
+
if (!slot) return
|
|
281
|
+
slot.replaceChildren()
|
|
282
|
+
renderedScales.delete(pageNum)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const renderHighlights = (pageNum: number) => {
|
|
286
|
+
const slot = pageElements.get(pageNum)
|
|
287
|
+
if (!slot) return
|
|
288
|
+
const wrapper = slot.querySelector('.pdf-page-inner')
|
|
289
|
+
if (!wrapper) return
|
|
290
|
+
|
|
291
|
+
wrapper.querySelector('.pdf-highlight-layer')?.remove()
|
|
292
|
+
if (searchMatches.value.length === 0) return
|
|
293
|
+
|
|
294
|
+
const pageMatches = searchMatches.value.filter((item) => item.page === pageNum)
|
|
295
|
+
if (pageMatches.length === 0) return
|
|
296
|
+
|
|
297
|
+
const activeMatch =
|
|
298
|
+
activeMatchIndex.value >= 0 ? searchMatches.value[activeMatchIndex.value] : undefined
|
|
299
|
+
const layer = document.createElement('div')
|
|
300
|
+
layer.className = 'pdf-highlight-layer'
|
|
301
|
+
|
|
302
|
+
for (const match of pageMatches) {
|
|
303
|
+
const isActive = activeMatch?.globalIndex === match.globalIndex
|
|
304
|
+
for (const rect of match.rects) {
|
|
305
|
+
const mark = document.createElement('div')
|
|
306
|
+
mark.className = isActive
|
|
307
|
+
? 'pdf-highlight-mark pdf-highlight-mark--active'
|
|
308
|
+
: 'pdf-highlight-mark'
|
|
309
|
+
mark.style.left = `${rect.left}px`
|
|
310
|
+
mark.style.top = `${rect.top}px`
|
|
311
|
+
mark.style.width = `${rect.width}px`
|
|
312
|
+
mark.style.height = `${rect.height}px`
|
|
313
|
+
layer.appendChild(mark)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
wrapper.appendChild(layer)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const paintVisibleHighlights = () => {
|
|
320
|
+
const { start, end } = getVisiblePageRange()
|
|
321
|
+
for (let i = start; i <= end; i++) {
|
|
322
|
+
renderHighlights(i)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const clearAllCanvases = () => {
|
|
327
|
+
for (let i = 1; i <= totalPages.value; i++) {
|
|
328
|
+
clearPageCanvas(i)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const renderPage = async (pageNum: number, scale: number) => {
|
|
333
|
+
if (!pdfDoc || renderingPages.has(pageNum)) return
|
|
334
|
+
const slot = pageElements.get(pageNum)
|
|
335
|
+
if (!slot) return
|
|
336
|
+
|
|
337
|
+
const currentScale = renderedScales.get(pageNum)
|
|
338
|
+
if (currentScale === scale && slot.querySelector('canvas')) {
|
|
339
|
+
renderHighlights(pageNum)
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
renderingPages.add(pageNum)
|
|
344
|
+
try {
|
|
345
|
+
const page = await pdfDoc.getPage(pageNum)
|
|
346
|
+
const viewport = page.getViewport({ scale })
|
|
347
|
+
const canvas = document.createElement('canvas')
|
|
348
|
+
canvas.className = 'pdf-page-canvas'
|
|
349
|
+
|
|
350
|
+
const dpr = window.devicePixelRatio || 1
|
|
351
|
+
canvas.width = Math.floor(viewport.width * dpr)
|
|
352
|
+
canvas.height = Math.floor(viewport.height * dpr)
|
|
353
|
+
canvas.style.width = `${Math.floor(viewport.width)}px`
|
|
354
|
+
canvas.style.height = `${Math.floor(viewport.height)}px`
|
|
355
|
+
|
|
356
|
+
const ctx = canvas.getContext('2d')
|
|
357
|
+
if (!ctx) return
|
|
358
|
+
|
|
359
|
+
const wrapper = document.createElement('div')
|
|
360
|
+
wrapper.className = 'pdf-page-inner'
|
|
361
|
+
wrapper.style.width = `${Math.floor(viewport.width)}px`
|
|
362
|
+
wrapper.style.height = `${Math.floor(viewport.height)}px`
|
|
363
|
+
|
|
364
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
365
|
+
wrapper.appendChild(canvas)
|
|
366
|
+
slot.replaceChildren(wrapper)
|
|
367
|
+
|
|
368
|
+
const renderTask = page.render({ canvasContext: ctx, viewport })
|
|
369
|
+
await renderTask.promise
|
|
370
|
+
renderHighlights(pageNum)
|
|
371
|
+
renderedScales.set(pageNum, scale)
|
|
372
|
+
|
|
373
|
+
if (!hasEmittedLoaded) {
|
|
374
|
+
hasEmittedLoaded = true
|
|
375
|
+
emit('loaded')
|
|
376
|
+
}
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if (!isIgnorablePdfError(error)) {
|
|
379
|
+
emit('error', error)
|
|
380
|
+
}
|
|
381
|
+
} finally {
|
|
382
|
+
renderingPages.delete(pageNum)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const getVisiblePageRange = () => {
|
|
387
|
+
const container = scrollRef.value
|
|
388
|
+
if (!container || totalPages.value === 0) return { start: 1, end: 1 }
|
|
389
|
+
|
|
390
|
+
const scrollTop = container.scrollTop
|
|
391
|
+
const viewBottom = scrollTop + container.clientHeight
|
|
392
|
+
let offset = 0
|
|
393
|
+
let start = 1
|
|
394
|
+
let end = totalPages.value
|
|
395
|
+
|
|
396
|
+
for (let i = 0; i < pageHeights.value.length; i++) {
|
|
397
|
+
const height = pageHeights.value[i] + PAGE_GAP
|
|
398
|
+
const pageTop = offset
|
|
399
|
+
const pageBottom = offset + height
|
|
400
|
+
if (pageBottom >= scrollTop && start === 1) {
|
|
401
|
+
start = i + 1
|
|
402
|
+
}
|
|
403
|
+
if (pageTop > viewBottom) {
|
|
404
|
+
end = i
|
|
405
|
+
break
|
|
406
|
+
}
|
|
407
|
+
offset += height
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
start: Math.max(1, start - VISIBLE_BUFFER),
|
|
412
|
+
end: Math.min(totalPages.value, end + VISIBLE_BUFFER),
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const renderVisiblePages = async () => {
|
|
417
|
+
if (!pdfDoc || totalPages.value === 0) return
|
|
418
|
+
const { start, end } = getVisiblePageRange()
|
|
419
|
+
const scale = renderScale.value
|
|
420
|
+
|
|
421
|
+
for (let i = 1; i <= totalPages.value; i++) {
|
|
422
|
+
if (i < start || i > end) {
|
|
423
|
+
if (renderedScales.has(i)) {
|
|
424
|
+
clearPageCanvas(i)
|
|
425
|
+
}
|
|
426
|
+
continue
|
|
427
|
+
}
|
|
428
|
+
await renderPage(i, scale)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const setupObserver = () => {
|
|
433
|
+
observer?.disconnect()
|
|
434
|
+
const root = scrollRef.value
|
|
435
|
+
if (!root) return
|
|
436
|
+
|
|
437
|
+
observer = new IntersectionObserver(
|
|
438
|
+
(entries) => {
|
|
439
|
+
const scale = renderScale.value
|
|
440
|
+
for (const entry of entries) {
|
|
441
|
+
if (!entry.isIntersecting) continue
|
|
442
|
+
const pageNum = Number((entry.target as HTMLElement).dataset.page)
|
|
443
|
+
if (!pageNum) continue
|
|
444
|
+
void renderPage(pageNum, scale)
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
{ root, rootMargin: '240px 0px', threshold: 0.01 },
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
pageElements.forEach((el) => observer?.observe(el))
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const getPageScrollTop = (pageNum: number) => {
|
|
454
|
+
let offset = 0
|
|
455
|
+
for (let i = 0; i < pageNum - 1; i++) {
|
|
456
|
+
offset += (pageHeights.value[i] || 0) + PAGE_GAP
|
|
457
|
+
}
|
|
458
|
+
return offset
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const getMatchAnchorTop = (match: PdfSearchMatch) => {
|
|
462
|
+
if (!match.rects.length) return 0
|
|
463
|
+
const tops = match.rects.map((rect) => rect.top)
|
|
464
|
+
const bottoms = match.rects.map((rect) => rect.top + rect.height)
|
|
465
|
+
return (Math.min(...tops) + Math.max(...bottoms)) / 2
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const getMatchScrollTop = (match: PdfSearchMatch) => {
|
|
469
|
+
const pageTop = getPageScrollTop(match.page)
|
|
470
|
+
const container = scrollRef.value
|
|
471
|
+
const viewHeight = container?.clientHeight ?? 0
|
|
472
|
+
const anchorTop = getMatchAnchorTop(match)
|
|
473
|
+
const centeredOffset = Math.max(0, anchorTop + PAGE_GAP / 2 - viewHeight * 0.35)
|
|
474
|
+
return pageTop + centeredOffset
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const scrollToMatch = async (match: PdfSearchMatch) => {
|
|
478
|
+
const pageNum = match.page
|
|
479
|
+
highlightPage.value = pageNum
|
|
480
|
+
|
|
481
|
+
await renderPage(pageNum, renderScale.value)
|
|
482
|
+
await nextTick()
|
|
483
|
+
|
|
484
|
+
const container = scrollRef.value
|
|
485
|
+
if (!container) return
|
|
486
|
+
|
|
487
|
+
const top = getMatchScrollTop(match)
|
|
488
|
+
const maxScroll = Math.max(container.scrollHeight - container.clientHeight, 0)
|
|
489
|
+
container.scrollTop = Math.min(Math.max(0, top), maxScroll)
|
|
490
|
+
|
|
491
|
+
currentPage.value = pageNum
|
|
492
|
+
emit('page-change', pageNum)
|
|
493
|
+
|
|
494
|
+
await renderVisiblePages()
|
|
495
|
+
paintVisibleHighlights()
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const updateCurrentPage = () => {
|
|
499
|
+
const container = scrollRef.value
|
|
500
|
+
if (!container || totalPages.value === 0) return
|
|
501
|
+
|
|
502
|
+
const anchor = container.scrollTop + container.clientHeight * 0.25
|
|
503
|
+
let offset = 0
|
|
504
|
+
for (let i = 0; i < pageHeights.value.length; i++) {
|
|
505
|
+
const height = (pageHeights.value[i] || 0) + PAGE_GAP
|
|
506
|
+
if (offset + height > anchor) {
|
|
507
|
+
if (currentPage.value !== i + 1) {
|
|
508
|
+
currentPage.value = i + 1
|
|
509
|
+
emit('page-change', currentPage.value)
|
|
510
|
+
}
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
offset += height
|
|
514
|
+
}
|
|
515
|
+
if (currentPage.value !== totalPages.value) {
|
|
516
|
+
currentPage.value = totalPages.value
|
|
517
|
+
emit('page-change', currentPage.value)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const scrollToPage = async (pageNum: number) => {
|
|
522
|
+
const target = Math.min(Math.max(1, pageNum), totalPages.value || 1)
|
|
523
|
+
await nextTick()
|
|
524
|
+
scrollRef.value?.scrollTo({ top: getPageScrollTop(target), behavior: 'smooth' })
|
|
525
|
+
currentPage.value = target
|
|
526
|
+
emit('page-change', target)
|
|
527
|
+
await renderVisiblePages()
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const runSearch = async (query: string) => {
|
|
531
|
+
if (!pdfDoc) return { total: 0, index: -1 }
|
|
532
|
+
lastSearchQuery.value = query
|
|
533
|
+
const matches = await searchPdfDocument(pdfDoc, query, renderScale.value)
|
|
534
|
+
searchMatches.value = matches
|
|
535
|
+
if (matches.length === 0) {
|
|
536
|
+
activeMatchIndex.value = -1
|
|
537
|
+
highlightPage.value = 0
|
|
538
|
+
return { total: 0, index: -1 }
|
|
539
|
+
}
|
|
540
|
+
activeMatchIndex.value = 0
|
|
541
|
+
highlightPage.value = matches[0].page
|
|
542
|
+
await scrollToMatch(matches[0])
|
|
543
|
+
return { total: matches.length, index: 0 }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const clearSearch = () => {
|
|
547
|
+
searchMatches.value = []
|
|
548
|
+
activeMatchIndex.value = -1
|
|
549
|
+
highlightPage.value = 0
|
|
550
|
+
lastSearchQuery.value = ''
|
|
551
|
+
paintVisibleHighlights()
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const gotoSearchMatch = async (direction: 1 | -1) => {
|
|
555
|
+
if (searchMatches.value.length === 0) return { total: 0, index: -1 }
|
|
556
|
+
const total = searchMatches.value.length
|
|
557
|
+
let next = activeMatchIndex.value + direction
|
|
558
|
+
if (next < 0) next = total - 1
|
|
559
|
+
if (next >= total) next = 0
|
|
560
|
+
activeMatchIndex.value = next
|
|
561
|
+
const match = searchMatches.value[next]
|
|
562
|
+
await scrollToMatch(match)
|
|
563
|
+
return { total, index: next }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
let scrollRaf = 0
|
|
567
|
+
|
|
568
|
+
const handleScroll = () => {
|
|
569
|
+
if (scrollRaf) return
|
|
570
|
+
scrollRaf = requestAnimationFrame(() => {
|
|
571
|
+
scrollRaf = 0
|
|
572
|
+
updateCurrentPage()
|
|
573
|
+
void renderVisiblePages().then(() => {
|
|
574
|
+
if (searchMatches.value.length > 0) {
|
|
575
|
+
paintVisibleHighlights()
|
|
576
|
+
}
|
|
577
|
+
})
|
|
578
|
+
})
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const scheduleScaleRefresh = () => {
|
|
582
|
+
if (scaleRefreshTimer) {
|
|
583
|
+
clearTimeout(scaleRefreshTimer)
|
|
584
|
+
}
|
|
585
|
+
scaleRefreshTimer = setTimeout(async () => {
|
|
586
|
+
if (!pdfDoc) return
|
|
587
|
+
const container = scrollRef.value
|
|
588
|
+
const scrollTop = container?.scrollTop ?? 0
|
|
589
|
+
const scrollHeight = container?.scrollHeight ?? 0
|
|
590
|
+
const clientHeight = container?.clientHeight ?? 0
|
|
591
|
+
const scrollRatio =
|
|
592
|
+
scrollHeight > clientHeight ? scrollTop / (scrollHeight - clientHeight) : 0
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
clearAllCanvases()
|
|
596
|
+
await updatePageHeights(pdfDoc, renderScale.value)
|
|
597
|
+
await nextTick()
|
|
598
|
+
setupObserver()
|
|
599
|
+
await renderVisiblePages()
|
|
600
|
+
|
|
601
|
+
if (container) {
|
|
602
|
+
const newScrollHeight = container.scrollHeight
|
|
603
|
+
const maxScroll = Math.max(newScrollHeight - clientHeight, 0)
|
|
604
|
+
container.scrollTop = scrollRatio * maxScroll
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (lastSearchQuery.value) {
|
|
608
|
+
const prevIndex = activeMatchIndex.value
|
|
609
|
+
const matches = await searchPdfDocument(pdfDoc, lastSearchQuery.value, renderScale.value)
|
|
610
|
+
searchMatches.value = matches
|
|
611
|
+
if (matches.length > 0) {
|
|
612
|
+
activeMatchIndex.value = Math.min(Math.max(prevIndex, 0), matches.length - 1)
|
|
613
|
+
highlightPage.value = matches[activeMatchIndex.value].page
|
|
614
|
+
}
|
|
615
|
+
paintVisibleHighlights()
|
|
616
|
+
}
|
|
617
|
+
} finally {
|
|
618
|
+
// 缩放重绘不再切换 loading,避免布局抖动引发二次放大
|
|
619
|
+
}
|
|
620
|
+
}, 120)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const ensureVisibilityObserver = () => {
|
|
624
|
+
const el = getPreviewRoot()
|
|
625
|
+
if (!el) return
|
|
626
|
+
|
|
627
|
+
visibilityObserver?.disconnect()
|
|
628
|
+
visibilityObserver = new IntersectionObserver(
|
|
629
|
+
(entries) => {
|
|
630
|
+
if (!entries[0]?.isIntersecting) return
|
|
631
|
+
if (pendingSrc) {
|
|
632
|
+
const src = pendingSrc
|
|
633
|
+
pendingSrc = undefined
|
|
634
|
+
void loadPdf(src)
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
{ threshold: 0.01 },
|
|
638
|
+
)
|
|
639
|
+
visibilityObserver.observe(el)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const requestLoadPdf = (src: Blob | ArrayBuffer) => {
|
|
643
|
+
if (!isPreviewVisible()) {
|
|
644
|
+
pendingSrc = src
|
|
645
|
+
isPending.value = true
|
|
646
|
+
ensureVisibilityObserver()
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
pendingSrc = undefined
|
|
650
|
+
isPending.value = false
|
|
651
|
+
void loadPdf(src)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const toArrayBuffer = async (src: Blob | ArrayBuffer) => {
|
|
655
|
+
if (src instanceof ArrayBuffer) return src
|
|
656
|
+
return src.arrayBuffer()
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const loadPdf = async (src: Blob | ArrayBuffer) => {
|
|
660
|
+
const token = ++loadToken
|
|
661
|
+
await destroyActiveTask()
|
|
662
|
+
resetViewer()
|
|
663
|
+
isPending.value = false
|
|
664
|
+
loading.value = true
|
|
665
|
+
|
|
666
|
+
try {
|
|
667
|
+
const data = await toArrayBuffer(src)
|
|
668
|
+
if (token !== loadToken) return
|
|
669
|
+
|
|
670
|
+
const loadingTask = pdfjsLib.getDocument({ data })
|
|
671
|
+
activeTask = loadingTask
|
|
672
|
+
const doc = await loadingTask.promise
|
|
673
|
+
if (token !== loadToken) return
|
|
674
|
+
|
|
675
|
+
pdfDoc = doc
|
|
676
|
+
totalPages.value = doc.numPages
|
|
677
|
+
await nextTick()
|
|
678
|
+
const nextFit = await computeFitScale(doc)
|
|
679
|
+
if (token !== loadToken) return
|
|
680
|
+
if (nextFit <= 0) {
|
|
681
|
+
pendingSrc = src
|
|
682
|
+
isPending.value = true
|
|
683
|
+
loading.value = false
|
|
684
|
+
ensureVisibilityObserver()
|
|
685
|
+
return
|
|
686
|
+
}
|
|
687
|
+
fitScale.value = nextFit
|
|
688
|
+
|
|
689
|
+
await updatePageHeights(doc, renderScale.value)
|
|
690
|
+
if (token !== loadToken) return
|
|
691
|
+
|
|
692
|
+
await nextTick()
|
|
693
|
+
setupObserver()
|
|
694
|
+
loadingPage.value = 1
|
|
695
|
+
await renderVisiblePages()
|
|
696
|
+
} catch (error) {
|
|
697
|
+
if (token !== loadToken) return
|
|
698
|
+
if (!isIgnorablePdfError(error)) {
|
|
699
|
+
emit('error', error)
|
|
700
|
+
}
|
|
701
|
+
} finally {
|
|
702
|
+
if (token === loadToken) {
|
|
703
|
+
loading.value = false
|
|
704
|
+
activeTask = null
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
watch(
|
|
710
|
+
() => props.src,
|
|
711
|
+
(src) => {
|
|
712
|
+
if (!src) {
|
|
713
|
+
loadToken += 1
|
|
714
|
+
pendingSrc = undefined
|
|
715
|
+
isPending.value = false
|
|
716
|
+
void destroyActiveTask()
|
|
717
|
+
pdfDoc = null
|
|
718
|
+
resetViewer()
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
requestLoadPdf(src)
|
|
722
|
+
},
|
|
723
|
+
{ immediate: true },
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
watch(
|
|
727
|
+
() => props.scale,
|
|
728
|
+
(value, oldValue) => {
|
|
729
|
+
if (!pdfDoc || value === oldValue) return
|
|
730
|
+
scheduleScaleRefresh()
|
|
731
|
+
},
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
defineExpose({
|
|
735
|
+
currentPage,
|
|
736
|
+
totalPages,
|
|
737
|
+
scrollToPage,
|
|
738
|
+
runSearch,
|
|
739
|
+
clearSearch,
|
|
740
|
+
gotoSearchMatch,
|
|
741
|
+
searchMatches,
|
|
742
|
+
activeMatchIndex,
|
|
743
|
+
refitLayout,
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
onMounted(() => {
|
|
747
|
+
ensureVisibilityObserver()
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
onBeforeUnmount(() => {
|
|
751
|
+
loadToken += 1
|
|
752
|
+
if (scaleRefreshTimer) clearTimeout(scaleRefreshTimer)
|
|
753
|
+
observer?.disconnect()
|
|
754
|
+
observer = null
|
|
755
|
+
visibilityObserver?.disconnect()
|
|
756
|
+
visibilityObserver = null
|
|
757
|
+
pdfDoc = null
|
|
758
|
+
pendingSrc = undefined
|
|
759
|
+
void destroyActiveTask()
|
|
760
|
+
})
|
|
761
|
+
</script>
|
|
762
|
+
|
|
763
|
+
<style scoped lang="scss">
|
|
764
|
+
.pdf-js-viewer {
|
|
765
|
+
position: relative;
|
|
766
|
+
width: 100%;
|
|
767
|
+
height: 100%;
|
|
768
|
+
min-height: 0;
|
|
769
|
+
overflow: auto;
|
|
770
|
+
background: #f5f5f5;
|
|
771
|
+
-webkit-overflow-scrolling: touch;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.pdf-pages {
|
|
775
|
+
display: flex;
|
|
776
|
+
flex-direction: column;
|
|
777
|
+
align-items: center;
|
|
778
|
+
padding: 8px 0 16px;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
.pdf-page-slot {
|
|
782
|
+
width: 100%;
|
|
783
|
+
display: flex;
|
|
784
|
+
justify-content: center;
|
|
785
|
+
position: relative;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
:deep(.pdf-page-inner) {
|
|
789
|
+
position: relative;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
:deep(.pdf-highlight-layer) {
|
|
793
|
+
position: absolute;
|
|
794
|
+
inset: 0;
|
|
795
|
+
pointer-events: none;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
:deep(.pdf-highlight-mark) {
|
|
799
|
+
position: absolute;
|
|
800
|
+
background: rgba(255, 214, 0, 0.42);
|
|
801
|
+
border-radius: 2px;
|
|
802
|
+
box-shadow: 0 0 0 1px rgba(255, 193, 7, 0.35);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
:deep(.pdf-highlight-mark--active) {
|
|
806
|
+
background: rgba(255, 87, 34, 0.5);
|
|
807
|
+
box-shadow: 0 0 0 2px rgba(255, 87, 34, 0.75);
|
|
808
|
+
z-index: 1;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
:deep(.pdf-page-canvas) {
|
|
812
|
+
display: block;
|
|
813
|
+
background: #fff;
|
|
814
|
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
.pdf-loading {
|
|
818
|
+
position: absolute;
|
|
819
|
+
top: 12px;
|
|
820
|
+
left: 50%;
|
|
821
|
+
transform: translateX(-50%);
|
|
822
|
+
z-index: 2;
|
|
823
|
+
width: fit-content;
|
|
824
|
+
padding: 8px 16px;
|
|
825
|
+
border-radius: 20px;
|
|
826
|
+
background: rgba(0, 0, 0, 0.65);
|
|
827
|
+
color: #fff;
|
|
828
|
+
font-size: 13px;
|
|
829
|
+
pointer-events: none;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
.pdf-placeholder {
|
|
833
|
+
position: absolute;
|
|
834
|
+
inset: 0;
|
|
835
|
+
z-index: 2;
|
|
836
|
+
display: flex;
|
|
837
|
+
align-items: center;
|
|
838
|
+
justify-content: center;
|
|
839
|
+
background: #f5f5f5;
|
|
840
|
+
pointer-events: none;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
.pdf-placeholder-card {
|
|
844
|
+
display: flex;
|
|
845
|
+
flex-direction: column;
|
|
846
|
+
align-items: center;
|
|
847
|
+
gap: 10px;
|
|
848
|
+
width: min(360px, 88%);
|
|
849
|
+
padding: 24px 28px;
|
|
850
|
+
border-radius: 12px;
|
|
851
|
+
background: #fff;
|
|
852
|
+
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
.pdf-placeholder-spinner {
|
|
856
|
+
width: 32px;
|
|
857
|
+
height: 32px;
|
|
858
|
+
border: 3px solid #e5e7eb;
|
|
859
|
+
border-top-color: #409eff;
|
|
860
|
+
border-radius: 50%;
|
|
861
|
+
animation: pdf-placeholder-spin 0.9s linear infinite;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
.pdf-placeholder-title {
|
|
865
|
+
margin: 0;
|
|
866
|
+
font-size: 14px;
|
|
867
|
+
font-weight: 600;
|
|
868
|
+
color: #374151;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
.pdf-placeholder-sub {
|
|
872
|
+
margin: 0;
|
|
873
|
+
font-size: 12px;
|
|
874
|
+
color: #6b7280;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
.pdf-placeholder-skeleton {
|
|
878
|
+
display: flex;
|
|
879
|
+
flex-direction: column;
|
|
880
|
+
gap: 8px;
|
|
881
|
+
width: 100%;
|
|
882
|
+
margin-top: 8px;
|
|
883
|
+
|
|
884
|
+
span {
|
|
885
|
+
display: block;
|
|
886
|
+
height: 10px;
|
|
887
|
+
border-radius: 6px;
|
|
888
|
+
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
|
|
889
|
+
background-size: 200% 100%;
|
|
890
|
+
animation: pdf-skeleton-shimmer 1.4s ease-in-out infinite;
|
|
891
|
+
|
|
892
|
+
&:nth-child(1) {
|
|
893
|
+
width: 92%;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
&:nth-child(2) {
|
|
897
|
+
width: 78%;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
&:nth-child(3) {
|
|
901
|
+
width: 64%;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
@keyframes pdf-placeholder-spin {
|
|
907
|
+
to {
|
|
908
|
+
transform: rotate(360deg);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
@keyframes pdf-skeleton-shimmer {
|
|
913
|
+
0% {
|
|
914
|
+
background-position: 200% 0;
|
|
915
|
+
}
|
|
916
|
+
100% {
|
|
917
|
+
background-position: -200% 0;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
</style>
|