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.
@@ -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>